A dark portfolio wall of tiles in mixed heights, packed into a masonry layout with CSS grid. When the section scrolls into view each tile springs up from a smaller, faded state, one after another. Hovering or tabbing to a tile lifts it, adds a soft glow, zooms the picture and drops in an arrow badge. Filter chips swap the set and replay the stagger. While nobody is interacting, a highlight walks from tile to tile so the wall is never still. It runs four columns on desktop, three on tablets and two on phones.
import { useEffect, useRef, useState } from "react";
import { motion, useInView, useReducedMotion } from "framer-motion";
type ArtKind = "arch" | "stairs" | "towers" | "disc" | "windows" | "roofs" | "slats" | "bridge";
type Tile = { id: string; title: string; place: string; group: string; art: ArtKind; colors: [string, string, string]; span: string };
const filters = ["All", "Homes", "Public", "Interiors"];
// `span` is the tile's height in grid rows: row-span-3 is short, row-span-6 is tall.
const tiles: Tile[] = [
{ id: "t1", title: "Casa Terracotta", place: "Oaxaca", group: "Homes", art: "arch", colors: ["#eadfce", "#c2562f", "#16233f"], span: "row-span-5" },
{ id: "t2", title: "Harbour Steps", place: "Aarhus", group: "Public", art: "stairs", colors: ["#16233f", "#7dd3fc", "#fde68a"], span: "row-span-6" },
{ id: "t3", title: "Three Sisters", place: "Rotterdam", group: "Homes", art: "towers", colors: ["#f4a261", "#16233f", "#fde68a"], span: "row-span-4" },
{ id: "t4", title: "Moon Room", place: "Kyoto", group: "Interiors", art: "disc", colors: ["#0f766e", "#eadfce", "#fb7185"], span: "row-span-6" },
{ id: "t5", title: "Night Library", place: "Tartu", group: "Public", art: "windows", colors: ["#0f1830", "#25365c", "#fde68a"], span: "row-span-3" },
{ id: "t6", title: "Saltwater Pavilion", place: "Porto", group: "Public", art: "roofs", colors: ["#c5e6f6", "#16233f", "#eadfce"], span: "row-span-6" },
{ id: "t7", title: "Cedar Hall", place: "Bergen", group: "Interiors", art: "slats", colors: ["#eadfce", "#a8794f", "#16233f"], span: "row-span-5" },
{ id: "t8", title: "Coral Crossing", place: "Valencia", group: "Public", art: "bridge", colors: ["#fb7185", "#16233f", "#ffe4d6"], span: "row-span-5" },
{ id: "t9", title: "Blue Courtyard", place: "Tangier", group: "Homes", art: "arch", colors: ["#25365c", "#c5e6f6", "#f4a261"], span: "row-span-4" },
];
const STAGGER = 0.07; // seconds between one tile's entrance and the next
const SPOT_MS = 1500; // how long the idle highlight rests on each tile
const spotOrder = [1, 6, 3, 8, 0, 5, 2, 7, 4];
function Art({ kind, c }: { kind: ArtKind; c: [string, string, string] }) {
const [a, b, d] = c;
const p = { viewBox: "0 0 300 400", preserveAspectRatio: "xMidYMid slice", "aria-hidden": true, className: "absolute inset-0 h-full w-full" } as const;
switch (kind) {
case "arch":
return <svg {...p}><rect width="300" height="400" fill={a} /><path d="M60 400V190a90 90 0 0 1 180 0v210Z" fill={b} /><path d="M105 400V200a45 45 0 0 1 90 0v200Z" fill={d} /><circle cx="248" cy="62" r="22" fill={b} opacity="0.5" /></svg>;
case "stairs":
return <svg {...p}><rect width="300" height="400" fill={a} /><circle cx="222" cy="92" r="46" fill={d} />{[0, 1, 2, 3, 4, 5].map((i) => <rect key={i} x={i * 50} y={350 - i * 46} width={300 - i * 50} height="50" fill={b} opacity={1 - i * 0.12} />)}</svg>;
case "towers":
return <svg {...p}><rect width="300" height="400" fill={a} /><rect x="36" y="150" width="68" height="250" fill={b} /><rect x="116" y="70" width="68" height="330" fill={b} /><rect x="196" y="190" width="68" height="210" fill={b} />{[0, 1, 2, 3, 4, 5].map((i) => <rect key={i} x={132 + (i % 2) * 22} y={96 + Math.floor(i / 2) * 44} width="12" height="22" fill={d} />)}</svg>;
case "disc":
return <svg {...p}><rect width="300" height="400" fill={a} /><circle cx="150" cy="170" r="104" fill={b} /><rect x="0" y="300" width="300" height="100" fill={b} opacity="0.18" /><rect x="128" y="250" width="44" height="150" fill={d} /></svg>;
case "windows":
return <svg {...p}><rect width="300" height="400" fill={a} />{Array.from({ length: 24 }, (_, i) => <rect key={i} x={24 + (i % 6) * 44} y={70 + Math.floor(i / 6) * 70} width="32" height="52" rx="3" fill={[2, 7, 8, 15, 19, 22].includes(i) ? d : b} />)}</svg>;
case "roofs":
return <svg {...p}><rect width="300" height="400" fill={a} /><path d="M0 260c60-90 120-90 150 0 30-90 90-90 150 0v140H0Z" fill={b} /><path d="M0 330c50-60 100-60 150 0 50-60 100-60 150 0v70H0Z" fill={d} /><circle cx="70" cy="90" r="30" fill="#fff" opacity="0.7" /></svg>;
case "slats":
return <svg {...p}><rect width="300" height="400" fill={a} />{[0, 1, 2, 3, 4, 5, 6, 7, 8].map((i) => <rect key={i} x={18 + i * 32} y="0" width="14" height="400" fill={b} />)}<path d="M0 400 300 150v250Z" fill={d} opacity="0.55" /></svg>;
default:
return <svg {...p}><rect width="300" height="400" fill={a} /><path d="M-20 300a170 170 0 0 1 340 0" fill="none" stroke={b} strokeWidth="26" /><path d="M40 300a110 110 0 0 1 220 0" fill="none" stroke={d} strokeWidth="18" /><rect y="292" width="300" height="108" fill={b} /></svg>;
}
}
export default function MasonryGalleryWithStaggeredRevealAndHoverLift() {
const reduce = useReducedMotion();
const section = useRef<HTMLElement>(null);
const inView = useInView(section, { amount: 0.15 });
const seen = useInView(section, { amount: 0.15, once: true });
const [filter, setFilter] = useState("All");
const [spot, setSpot] = useState("");
const [held, setHeld] = useState(false);
const [focused, setFocused] = useState("");
const shown = tiles.filter((t) => filter === "All" || t.group === filter);
// With nobody hovering, a highlight walks across the tiles so the wall is never static.
const shownIds = shown.map((t) => t.id);
const idle = inView && !held && !reduce;
useEffect(() => {
if (!idle) { setSpot(""); return; }
let step = 0;
const tick = () => { if (!document.hidden) { setSpot(shownIds[spotOrder[step % spotOrder.length] % shownIds.length]); step += 1; } };
const first = window.setTimeout(tick, 1100);
const id = window.setInterval(tick, SPOT_MS);
return () => { window.clearTimeout(first); window.clearInterval(id); };
}, [idle, filter]);
return (
<section ref={section} className="relative overflow-hidden bg-[#090e1c] px-5 py-16 text-slate-100 sm:px-8 sm:py-20">
<div aria-hidden="true" className="pointer-events-none absolute -top-40 right-0 h-96 w-96 rounded-full bg-sky-500/15 blur-3xl" />
<div className="relative mx-auto max-w-6xl">
<div className="flex flex-col gap-8 lg:flex-row lg:items-end lg:justify-between">
<div className="max-w-xl">
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-sky-300">Selected work, 2021 to 2025</p>
<h2 className="mt-4 text-4xl font-semibold leading-[1.05] tracking-tight sm:text-5xl">Buildings that sit quietly and age well.</h2>
</div>
<div className="flex flex-wrap gap-2" role="group" aria-label="Filter projects">
{filters.map((f) => (
<button
key={f}
type="button"
aria-pressed={filter === f}
onClick={() => setFilter(f)}
className={`relative h-10 rounded-full px-4 text-sm font-medium transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-300 focus-visible:ring-offset-2 focus-visible:ring-offset-[#090e1c] ${filter === f ? "text-slate-950" : "text-slate-300 hover:text-white"}`}
>
{filter === f && <motion.span layoutId="masonry-gallery-chip" className="absolute inset-0 rounded-full bg-sky-300" transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 380, damping: 30 }} />}
{filter !== f && <span aria-hidden="true" className="absolute inset-0 rounded-full border border-white/15" />}
<span className="relative">{f}</span>
</button>
))}
</div>
</div>
<ul
key={filter}
className="mt-10 grid list-none auto-rows-[40px] grid-flow-row-dense grid-cols-2 gap-3 p-0 sm:gap-4 md:grid-cols-3 lg:grid-cols-4"
onPointerEnter={(e) => e.pointerType === "mouse" && setHeld(true)}
onPointerLeave={() => setHeld(false)}
>
{shown.map((t, i) => {
const active = focused === t.id || (!held && spot === t.id);
return (
<motion.li
key={t.id}
className={t.span}
initial={reduce ? false : { opacity: 0, scale: 0.82, y: 36 }}
animate={seen || reduce ? { opacity: 1, scale: 1, y: 0 } : undefined}
transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 170, damping: 20, delay: 0.05 + i * STAGGER }}
>
<motion.a
href="#"
onFocus={() => setFocused(t.id)}
onBlur={() => setFocused("")}
className="group relative block h-full w-full overflow-hidden rounded-2xl bg-slate-800 outline-none ring-sky-300 ring-offset-2 ring-offset-[#090e1c] focus-visible:ring-2"
animate={{ y: active ? -8 : 0, scale: active ? 1.025 : 1, boxShadow: active ? "0 24px 48px -12px rgba(56,189,248,0.35)" : "0 0 0 0 rgba(56,189,248,0)" }}
whileHover={reduce ? undefined : { y: -8, scale: 1.025, boxShadow: "0 24px 48px -12px rgba(56,189,248,0.35)" }}
transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 300, damping: 22 }}
>
<span className={`absolute inset-0 block transition-transform duration-700 ease-out group-hover:scale-110 motion-reduce:transition-none ${active ? "scale-110" : ""}`}>
<Art kind={t.art} c={t.colors} />
</span>
<span aria-hidden="true" className="absolute inset-x-0 bottom-0 h-28 bg-gradient-to-t from-slate-950/85 to-transparent" />
<span className={`absolute right-3 top-3 flex h-9 w-9 items-center justify-center rounded-full bg-white text-slate-950 transition duration-300 group-hover:translate-y-0 group-hover:opacity-100 motion-reduce:transition-none ${active ? "translate-y-0 opacity-100" : "-translate-y-2 opacity-0"}`}>
<svg viewBox="0 0 20 20" aria-hidden="true" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M6 14 14 6M7 6h7v7" /></svg>
</span>
<span className="absolute inset-x-0 bottom-0 block p-4">
<span className="block truncate text-sm font-semibold text-white sm:text-base">{t.title}</span>
<span className="mt-0.5 block truncate text-xs text-slate-300">{t.place} <span aria-hidden="true">/</span> {t.group}</span>
</span>
</motion.a>
</motion.li>
);
})}
</ul>
<p className="mt-8 text-sm text-slate-400" aria-live="polite">
Showing {shown.length} of {tiles.length} projects.
</p>
</div>
</section>
);
}
Install with one command: npx shadcn@latest add https://we.inc/r/masonry-gallery-with-staggered-reveal-and-hover-lift.json
It is a CSS grid with short fixed rows (`auto-rows-[40px]`) and `grid-flow-row-dense`. Each tile spans a different number of rows, and dense flow drops the next tile into the first gap that fits. Unlike CSS columns, reading order runs left to right and tiles are never split or clipped when they are transformed.
Every tile is a motion element that starts at 82 percent scale, 36 pixels low and transparent. `useInView` reports when the section enters the viewport, and each tile then animates to its final state with a delay of its index times `STAGGER`, so they arrive in sequence.
The list has `key={filter}`. Changing the key makes React mount a fresh list, so every tile runs its entrance again from the start.
Touch devices do not hover, so the title and location are always visible and a tap follows the link. The idle highlight shows the lifted state on its own.
As many as you like. For long lists cap the delay, for example `Math.min(i, 12) * STAGGER`, so tiles far down the page do not wait several seconds to appear.
Related: Coverflow Gallery with 3D Perspective and Autoplay, Draggable Card Stack Carousel with Spring Physics, Blog Post Card Grid with Category Tags and Reading Time.
More galleries and carousels or all free UI components. Start for free to have a full site built around it.
We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.