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
Layouts
Layouts
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-gridPackage Dependencies
Install the npm packages used by this component source.
npm install clsx gsap lucide-react tailwind-mergeComponent 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;