INFINITYUI
HomeComponentsDocsTemplates
Star

Getting Started

  • Introduction
  • Installation
  • CLI
  • Audit

Navigation

  • Spotlight Navbar
  • Glass DockNEW
  • Animated Tab Bar
  • Circle Menu
  • Magnet Tabs
  • Animated Sidebar
  • Apple Spotlight
  • Page TOC RailNEW

Text

  • Flip Text
  • Glitch Text
  • Liquid Text
  • Flip Fade Text
  • Mask Cursor Effect

Cards

  • Glow Border Card
  • Testimonials Card
  • Interactive Book
  • Trading CardsNEW
  • Hover Image
  • Chain of ThoughtNEW
  • Masonry Grid
  • Image Pile
  • Staggered Grid

Inputs

  • AI InputNEW
  • OTP Input
  • Leave Rating

Buttons

  • Social Flip Button
  • Creepy Button

Loaders

  • Jelly Loader
  • Rolling Ball Scroll
  • Glowing Scroll

Backgrounds

  • Light Lines
  • Perspective Grid
  • Liquid Ocean
  • Eagle Vision
  • Flow Scroll
  • Horizontal Scroll

Overlays

  • PersonaNEW
  • Infinite Moving Cards
  • Masked Avatars
  • Stacked Logos
  • Icon Wheel
  • Pixelated CarouselNEW
  • Pixelated Image Trail
  • Flip Scroll
  • Interactive Folder
  • Animated Folder IconNEW
  • Stack Scroll
  • Rubik Cube
Cards

Staggered Grid

#grid#staggered#bento
Infinity
Build withGithub
Build withSlack
Build withTwitter
Build withGithub
Build withSlack
Build withTwitter
Build withGithub
Build withSlack
Build withTwitter
Build withGithub
Build withSlack
Build withTwitter
Build withGithub
Build withSlack
Build withTwitter
Build withGithub
Signals

Signals

Signals
Layouts

Layouts

Layouts
Compute

Compute

Compute
Build withSlack
Build withTwitter
Build withGithub
Build withSlack
Build withTwitter
Build withGithub
Build withSlack
Build withTwitter
Build withGithub
Build withSlack
Build withTwitter
Build withGithub
Build withSlack
Build withTwitter
Build withGithub
Build withSlack
implementedInfinityUI component

This component is fully implemented in InfinityUI and wired into the docs and registry flow.

Installation

Use the registry command to add the component source, and install any package dependencies if needed.

Infinity Registry

Install the component source into your project with the shadcn CLI.

npx shadcn@latest add https://infinityui-pearl.vercel.app/r/staggered-grid
Package Dependencies

Install the npm packages used by this component source.

npm install clsx gsap lucide-react tailwind-merge

Component Code

Copy and paste this code into your component file.

tsx
"use client";

import { useEffect, useRef, useState } from "react";
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import { Github, Slack, Twitter } from "lucide-react";
import { cn } from "@/lib/utils";

if (typeof window !== "undefined") {
    gsap.registerPlugin(ScrollTrigger);
}

export interface BentoItem {
    id: number | string;
    title: string;
    subtitle: string;
    description: string;
    icon: React.ReactNode;
    content?: React.ReactNode;
    image?: string;
}

export interface StaggeredGridProps {
    images: string[];
    bentoItems: BentoItem[];
    centerText?: string;
    credits?: {
        madeBy: { text: string; href: string };
        moreDemos: { text: string; href: string };
    };
    className?: string;
    showFooter?: boolean;
}

export function StaggeredGrid({
    images,
    bentoItems,
    centerText = "Halcyon",
    credits = {
        madeBy: { text: "@codrops", href: "https://x.com/codrops" },
        moreDemos: { text: "More demos", href: "https://tympanus.net/codrops/demos" },
    },
    className,
    showFooter = true,
}: StaggeredGridProps) {
    const [isLoaded, setIsLoaded] = useState(false);
    const [activeBento, setActiveBento] = useState(0);
    const gridFullRef = useRef<HTMLDivElement>(null);
    const textRef = useRef<HTMLDivElement>(null);

    const splitText = (text: string) =>
        text.split("").map((character, index) => (
            <span
                key={index}
                className="char inline-block"
                style={{ willChange: "transform" }}
            >
                {character === " " ? "\u00A0" : character}
            </span>
        ));

    useEffect(() => {
        queueMicrotask(() => setIsLoaded(true));
    }, []);

    useEffect(() => {
        if (!isLoaded || typeof window === "undefined") {
            return;
        }

        if (textRef.current) {
            const chars = textRef.current.querySelectorAll(".char");
            gsap.timeline({
                scrollTrigger: {
                    trigger: textRef.current,
                    start: "top bottom",
                    end: "center center-=25%",
                    scrub: 1,
                },
            }).from(chars, {
                ease: "sine.out",
                yPercent: 300,
                autoAlpha: 0,
                stagger: { each: 0.05, from: "center" },
            });
        }

        if (gridFullRef.current) {
            const gridFullItems = gridFullRef.current.querySelectorAll(".grid__item");
            const numColumns = getComputedStyle(gridFullRef.current)
                .getPropertyValue("grid-template-columns")
                .split(" ").length;
            const middleColumnIndex = Math.floor(numColumns / 2);
            const columns: Element[][] = Array.from({ length: numColumns }, () => []);

            gridFullItems.forEach((item, index) => {
                columns[index % numColumns].push(item);
            });

            columns.forEach((columnItems, columnIndex) => {
                const delayFactor = Math.abs(columnIndex - middleColumnIndex) * 0.2;

                gsap.timeline({
                    scrollTrigger: {
                        trigger: gridFullRef.current,
                        start: "top bottom",
                        end: "center center",
                        scrub: 1.5,
                    },
                })
                    .from(columnItems, {
                        yPercent: 450,
                        autoAlpha: 0,
                        delay: delayFactor,
                        ease: "sine.out",
                    })
                    .from(
                        columnItems.map((item) => item.querySelector(".grid__item-img")),
                        {
                            transformOrigin: "50% 0%",
                            ease: "sine.out",
                        },
                        0
                    );
            });

            const bentoContainer = gridFullRef.current.querySelector(".bento-container");
            if (bentoContainer) {
                gsap.timeline({
                    scrollTrigger: {
                        trigger: gridFullRef.current,
                        start: "top top+=15%",
                        end: "bottom center",
                        scrub: 1,
                        invalidateOnRefresh: true,
                    },
                }).to(
                    bentoContainer,
                    {
                        y: window.innerHeight * 0.1,
                        scale: 1.5,
                        zIndex: 1000,
                        ease: "power2.out",
                        duration: 1,
                        force3D: true,
                    },
                    0
                );
            }
        }

        return () => {
            ScrollTrigger.getAll().forEach((trigger) => trigger.kill());
        };
    }, [isLoaded]);

    const mixedGridItems: (string | "BENTO_GROUP")[] = [
        ...images,
        ...images,
        images[0] || "",
    ].slice(0, 35);
    mixedGridItems[16] = "BENTO_GROUP";

    return (
        <div
            className={cn("relative w-full overflow-hidden shadow", className)}
            style={{ ["--grid-item-translate" as string]: "0px" }}
        >
            <section className="relative grid w-full mt-[10vh] place-items-center">
                <div
                    ref={textRef}
                    className="font-alt flex content-center text-[clamp(3rem,14vw,10rem)] font-bold uppercase leading-[0.7] text-neutral-900 dark:text-white"
                >
                    {splitText(centerText)}
                </div>
            </section>

            <section className="relative grid w-full place-items-center">
                <div
                    ref={gridFullRef}
                    className="grid--full relative my-[10vh] grid h-auto aspect-[1.1] w-full max-w-none grid-cols-7 grid-rows-5 gap-4 p-4"
                >
                    <div className="grid-overlay pointer-events-none absolute inset-0 z-[15] rounded-lg bg-white/80 opacity-0 transition-opacity duration-500 dark:bg-black/80" />
                    {mixedGridItems.map((item, index) => {
                        if (item === "BENTO_GROUP") {
                            if (!bentoItems.length) {
                                return null;
                            }

                            return (
                                <div
                                    key="bento-group"
                                    className="grid__item bento-container relative z-20 col-span-3 row-span-1 flex h-full w-full items-center justify-center gap-2 will-change-transform"
                                >
                                    {bentoItems.map((bentoItem, bentoIndex) => {
                                        const isActive = activeBento === bentoIndex;

                                        return (
                                            <div
                                                key={bentoItem.id}
                                                className={cn(
                                                    "relative h-full cursor-pointer overflow-hidden rounded-2xl transition-all duration-700 ease-[cubic-bezier(0.25,1,0.5,1)]",
                                                    isActive
                                                        ? "bg-zinc-900/10 shadow-2xl"
                                                        : "bg-zinc-950"
                                                )}
                                                style={{ width: isActive ? "60%" : "20%" }}
                                                onMouseEnter={() => setActiveBento(bentoIndex)}
                                                onClick={() => setActiveBento(bentoIndex)}
                                            >
                                                <div
                                                    className={cn(
                                                        "pointer-events-none absolute inset-0 z-50 rounded-2xl border transition-colors duration-700",
                                                        isActive
                                                            ? "border-zinc-500/50"
                                                            : "border-zinc-800/50"
                                                    )}
                                                />
                                                <div className="relative z-10 flex h-full w-full flex-col p-0">
                                                    <div
                                                        className={cn(
                                                            "absolute inset-0 flex flex-col transition-all duration-500 ease-in-out",
                                                            isActive
                                                                ? "translate-y-0 opacity-100"
                                                                : "pointer-events-none translate-y-4 opacity-0"
                                                        )}
                                                    >
                                                        <div className="group/img absolute inset-0 z-0 overflow-hidden bg-zinc-900">
                                                            {bentoItem.image && (
                                                                <>
                                                                    <img
                                                                        src={bentoItem.image}
                                                                        alt={bentoItem.title}
                                                                        className="absolute inset-0 h-full w-full object-cover opacity-90 transition-transform duration-700"
                                                                    />
                                                                    <div className="pointer-events-none absolute bottom-0 left-0 h-40 w-full bg-gradient-to-t from-black via-black/50 to-transparent" />
                                                                </>
                                                            )}
                                                        </div>
                                                        <div className="absolute bottom-0 left-0 z-20 flex h-20 w-full items-center justify-between px-5">
                                                            <h3 className="text-sm font-bold text-white drop-shadow-md">
                                                                {bentoItem.title}
                                                            </h3>
                                                            <div className="text-white/90">
                                                                {bentoItem.icon}
                                                            </div>
                                                        </div>
                                                    </div>
                                                </div>
                                                <div
                                                    className={cn(
                                                        "absolute inset-0 flex flex-col items-center justify-center gap-2 transition-all duration-500",
                                                        isActive
                                                            ? "pointer-events-none scale-90 opacity-0"
                                                            : "scale-100 opacity-100"
                                                    )}
                                                >
                                                    <div className="text-white/50">
                                                        {bentoItem.icon}
                                                    </div>
                                                    <span className="text-[10px] font-medium uppercase tracking-wider text-zinc-500">
                                                        {bentoItem.title}
                                                    </span>
                                                </div>
                                            </div>
                                        );
                                    })}
                                </div>
                            );
                        }

                        if (index === 17 || index === 18) {
                            return null;
                        }

                        if (typeof item === "string") {
                            const Icon = index % 3 === 0 ? Github : index % 3 === 1 ? Slack : Twitter;
                            const label =
                                index % 3 === 0 ? "Github" : index % 3 === 1 ? "Slack" : "Twitter";

                            return (
                                <figure
                                    key={`img-${index}`}
                                    className="grid__item group relative z-10 m-0 cursor-pointer [perspective:800px] will-change-[transform,opacity]"
                                >
                                    <div className="grid__item-img flex h-full w-full items-center justify-center overflow-hidden rounded-xl border border-zinc-200 bg-zinc-100 shadow-sm transition-all duration-500 ease-out [backface-visibility:hidden] will-change-transform group-hover:scale-105 group-hover:shadow-xl dark:border-zinc-900 dark:bg-zinc-950">
                                        <div className="absolute inset-0 z-0 bg-gradient-to-b from-black/40 via-black/80 to-black opacity-0 backdrop-blur-[2px] transition-opacity duration-500 group-hover:opacity-100" />
                                        <div className="relative z-10 flex flex-col items-center justify-center gap-3">
                                            <Icon className="h-8 w-8 text-zinc-400 transition-all duration-300 group-hover:scale-110 group-hover:text-white dark:text-zinc-500" />
                                            <div className="translate-y-2 transform text-center opacity-0 transition-all duration-300 delay-75 group-hover:translate-y-0 group-hover:opacity-100">
                                                <span className="mb-0.5 block text-[10px] font-medium uppercase tracking-wider text-white/90">
                                                    Build with
                                                </span>
                                                <span className="block text-sm font-bold tracking-tight text-white">
                                                    {label}
                                                </span>
                                            </div>
                                        </div>
                                    </div>
                                </figure>
                            );
                        }

                        return null;
                    })}
                </div>
            </section>

            {showFooter && (
                <footer className="frame__footer relative z-50 flex w-full items-center justify-between p-8 text-xs font-medium uppercase tracking-wider text-neutral-900 dark:text-white">
                    <a href={credits.madeBy.href} className="transition-opacity hover:opacity-60">
                        {credits.madeBy.text}
                    </a>
                    <a href={credits.moreDemos.href} className="transition-opacity hover:opacity-60">
                        {credits.moreDemos.text}
                    </a>
                </footer>
            )}
        </div>
    );
}

export default StaggeredGrid;