Masonry Reveal Gallery
Original · freeA masonry gallery whose items stagger-reveal as they scroll into view.
Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.
npx shadcn@latest add https://webinnoventix.com/r/gallery-masonry-reveal.json"use client";
import { useRef, useState, useEffect, useCallback } from "react";
import {
motion,
useScroll,
useTransform,
AnimatePresence,
} from "motion/react";
type Photo = {
src: string;
w: number;
h: number;
title: string;
category: string;
caption: string;
};
const PHOTOS: Photo[] = [
{
src: "/img/gallery/g03.webp",
w: 800,
h: 1040,
title: "Low Tide, Sennen",
category: "Coastline",
caption: "First light draining off the granite flats before the crowds arrive.",
},
{
src: "/img/gallery/g11.webp",
w: 1040,
h: 720,
title: "Ridgeline Fog",
category: "Mountains",
caption: "A temperature inversion held over the valley for nearly an hour.",
},
{
src: "/img/gallery/g07.webp",
w: 900,
h: 900,
title: "Market Hands",
category: "Street",
caption: "Weighing saffron by the gram in the old spice quarter.",
},
{
src: "/img/gallery/g19.webp",
w: 800,
h: 1040,
title: "Stairwell No. 4",
category: "Architecture",
caption: "A cast-iron spiral photographed straight down from the top floor.",
},
{
src: "/img/gallery/g22.webp",
w: 1040,
h: 720,
title: "Salt Pans at Dusk",
category: "Aerial",
caption: "Evaporation ponds turning rose and copper as the sun drops.",
},
{
src: "/img/gallery/g14.webp",
w: 900,
h: 900,
title: "Studio Portrait, Ada",
category: "Portrait",
caption: "Single softbox, camera-left, one honest frame out of forty.",
},
{
src: "/img/gallery/g28.webp",
w: 800,
h: 1040,
title: "Rain on the 42",
category: "Street",
caption: "Neon bleeding across a wet tram window on the last run home.",
},
{
src: "/img/gallery/g05.webp",
w: 1040,
h: 720,
title: "Pine Understory",
category: "Forest",
caption: "Backlit ferns in a stand of old-growth Douglas fir.",
},
{
src: "/img/gallery/g31.webp",
w: 900,
h: 900,
title: "Ceramic Study",
category: "Still Life",
caption: "Raw stoneware bowls drying on the wheel-shop windowsill.",
},
{
src: "/img/gallery/g17.webp",
w: 800,
h: 1040,
title: "Desert Doorway",
category: "Architecture",
caption: "Whitewashed adobe holding the last warmth of the afternoon.",
},
{
src: "/img/gallery/g24.webp",
w: 1040,
h: 720,
title: "Harbour Reflections",
category: "Coastline",
caption: "Fishing boats mirrored in glassy water before the wind picks up.",
},
{
src: "/img/gallery/g09.webp",
w: 900,
h: 900,
title: "Frost Detail",
category: "Macro",
caption: "Hoarfrost crystals on a fallen oak leaf at minus six.",
},
];
const COLUMN_COUNT = 3;
function splitIntoColumns(items: Photo[], columns: number): Photo[][] {
const buckets: Photo[][] = Array.from({ length: columns }, () => []);
items.forEach((item, i) => {
buckets[i % columns].push(item);
});
return buckets;
}
export default function GalleryMasonryReveal() {
const sectionRef = useRef<HTMLElement | null>(null);
const [active, setActive] = useState<number | null>(null);
const { scrollYProgress } = useScroll({
target: sectionRef,
offset: ["start end", "end start"],
});
const glowY = useTransform(scrollYProgress, [0, 1], ["-12%", "18%"]);
const glowOpacity = useTransform(
scrollYProgress,
[0, 0.25, 0.75, 1],
[0, 0.9, 0.9, 0],
);
const columns = splitIntoColumns(PHOTOS, COLUMN_COUNT);
const close = useCallback(() => setActive(null), []);
const next = useCallback(
() => setActive((i) => (i === null ? i : (i + 1) % PHOTOS.length)),
[],
);
const prev = useCallback(
() =>
setActive((i) =>
i === null ? i : (i - 1 + PHOTOS.length) % PHOTOS.length,
),
[],
);
useEffect(() => {
if (active === null) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") close();
else if (e.key === "ArrowRight") next();
else if (e.key === "ArrowLeft") prev();
};
window.addEventListener("keydown", onKey);
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
window.removeEventListener("keydown", onKey);
document.body.style.overflow = prevOverflow;
};
}, [active, close, next, prev]);
const activePhoto = active === null ? null : PHOTOS[active];
return (
<section
ref={sectionRef}
aria-label="Photography collection — masonry gallery"
className="relative w-full overflow-hidden bg-gradient-to-b from-slate-50 via-white to-slate-100 px-5 py-24 text-slate-900 sm:px-8 lg:px-12 lg:py-32 dark:from-zinc-950 dark:via-zinc-950 dark:to-black dark:text-zinc-50"
>
<style>{`
@keyframes gmr-shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
@keyframes gmr-pulse {
0%, 100% { transform: scale(1); opacity: 0.55; }
50% { transform: scale(1.35); opacity: 1; }
}
.gmr-shimmer {
background-image: linear-gradient(
90deg,
transparent 0%,
rgba(255,255,255,0.14) 45%,
rgba(255,255,255,0.28) 50%,
rgba(255,255,255,0.14) 55%,
transparent 100%
);
background-size: 200% 100%;
animation: gmr-shimmer 2.6s linear infinite;
}
.gmr-dot { animation: gmr-pulse 2s ease-in-out infinite; }
@media (prefers-reduced-motion: reduce) {
.gmr-shimmer, .gmr-dot { animation: none !important; }
}
`}</style>
{/* ambient glow */}
<motion.div
aria-hidden
style={{ y: glowY, opacity: glowOpacity }}
className="pointer-events-none absolute left-1/2 top-0 h-[38rem] w-[38rem] -translate-x-1/2 rounded-full bg-gradient-to-tr from-indigo-300/40 via-violet-300/30 to-transparent blur-3xl dark:from-indigo-600/25 dark:via-violet-700/20"
/>
<div
aria-hidden
className="pointer-events-none absolute -right-24 bottom-10 h-72 w-72 rounded-full bg-emerald-300/20 blur-3xl dark:bg-emerald-500/10"
/>
<div className="relative mx-auto max-w-6xl">
{/* header */}
<div className="mb-14 flex flex-col gap-6 sm:flex-row sm:items-end sm:justify-between">
<div className="max-w-xl">
<span className="inline-flex items-center gap-2 rounded-full border border-indigo-200/70 bg-indigo-50/60 px-3 py-1 text-xs font-medium tracking-wide text-indigo-700 backdrop-blur dark:border-indigo-400/20 dark:bg-indigo-500/10 dark:text-indigo-300">
<span className="gmr-dot h-1.5 w-1.5 rounded-full bg-indigo-500 dark:bg-indigo-400" />
Field Archive · 2024–2026
</span>
<h2 className="mt-5 text-4xl font-semibold tracking-tight text-slate-900 sm:text-5xl dark:text-white">
Between Light & Weather
</h2>
<p className="mt-4 text-base leading-relaxed text-slate-600 dark:text-zinc-400">
Twelve frames from a working year on the coast and in the hills —
printed, culled, and gathered here. Scroll to let each one settle
into place.
</p>
</div>
<div className="flex shrink-0 items-center gap-2 text-sm text-slate-500 dark:text-zinc-500">
<span className="rounded-lg border border-slate-200 bg-white/70 px-3 py-1.5 font-medium text-slate-700 shadow-sm dark:border-zinc-800 dark:bg-zinc-900/70 dark:text-zinc-300">
{PHOTOS.length} photographs
</span>
</div>
</div>
{/* masonry */}
<div className="flex gap-4 sm:gap-5">
{columns.map((col, colIndex) => (
<div key={colIndex} className="flex flex-1 flex-col gap-4 sm:gap-5">
{col.map((photo) => {
const globalIndex = PHOTOS.indexOf(photo);
return (
<motion.figure
key={photo.src}
initial={{ opacity: 0, y: 48, scale: 0.96 }}
whileInView={{ opacity: 1, y: 0, scale: 1 }}
viewport={{ once: true, margin: "0px 0px -12% 0px" }}
transition={{
duration: 0.7,
ease: [0.22, 1, 0.36, 1],
delay: (globalIndex % COLUMN_COUNT) * 0.08,
}}
className="group relative"
>
<button
type="button"
onClick={() => setActive(globalIndex)}
aria-label={`Open “${photo.title}” — ${photo.category}`}
className="relative block w-full overflow-hidden rounded-2xl border border-slate-200/80 bg-slate-100 text-left shadow-sm outline-none ring-indigo-500 transition-shadow duration-300 hover:shadow-xl focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:border-zinc-800 dark:bg-zinc-900 dark:ring-indigo-400 dark:focus-visible:ring-offset-zinc-950"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={photo.src}
alt={`${photo.title} — ${photo.caption}`}
loading="lazy"
draggable={false}
width={photo.w}
height={photo.h}
className="block w-full transform-gpu object-cover transition-transform duration-[900ms] ease-[cubic-bezier(0.22,1,0.36,1)] group-hover:scale-[1.05]"
style={{ aspectRatio: `${photo.w} / ${photo.h}` }}
/>
{/* gradient scrim */}
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/70 via-black/5 to-transparent opacity-70 transition-opacity duration-300 group-hover:opacity-90" />
{/* caption */}
<figcaption className="absolute inset-x-0 bottom-0 translate-y-2 p-4 opacity-0 transition-all duration-300 group-hover:translate-y-0 group-hover:opacity-100 group-focus-visible:translate-y-0 group-focus-visible:opacity-100">
<span className="text-[0.65rem] font-semibold uppercase tracking-[0.18em] text-indigo-200">
{photo.category}
</span>
<h3 className="mt-1 text-lg font-semibold leading-tight text-white">
{photo.title}
</h3>
</figcaption>
{/* corner index */}
<span className="absolute right-3 top-3 rounded-full bg-white/85 px-2 py-0.5 text-[0.7rem] font-semibold tabular-nums text-slate-700 opacity-0 backdrop-blur transition-opacity duration-300 group-hover:opacity-100 dark:bg-zinc-900/80 dark:text-zinc-200">
{String(globalIndex + 1).padStart(2, "0")}
</span>
</button>
</motion.figure>
);
})}
</div>
))}
</div>
</div>
{/* lightbox */}
<AnimatePresence>
{activePhoto && (
<motion.div
role="dialog"
aria-modal="true"
aria-label={`${activePhoto.title}, ${activePhoto.category}`}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.25 }}
className="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/80 p-4 backdrop-blur-md sm:p-8"
onClick={close}
>
<motion.div
initial={{ opacity: 0, scale: 0.94, y: 16 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.96, y: 8 }}
transition={{ duration: 0.32, ease: [0.22, 1, 0.36, 1] }}
className="relative flex max-h-full w-full max-w-4xl flex-col overflow-hidden rounded-2xl bg-white shadow-2xl dark:bg-zinc-950"
onClick={(e) => e.stopPropagation()}
>
<div className="relative flex items-center justify-center bg-slate-100 dark:bg-black">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={activePhoto.src}
alt={`${activePhoto.title} — ${activePhoto.caption}`}
loading="lazy"
draggable={false}
width={activePhoto.w}
height={activePhoto.h}
className="max-h-[70vh] w-auto max-w-full object-contain"
/>
</div>
<div className="flex items-start justify-between gap-4 border-t border-slate-100 p-5 dark:border-zinc-900">
<div>
<span className="text-[0.65rem] font-semibold uppercase tracking-[0.18em] text-indigo-600 dark:text-indigo-400">
{activePhoto.category}
</span>
<h3 className="mt-1 text-xl font-semibold text-slate-900 dark:text-white">
{activePhoto.title}
</h3>
<p className="mt-1.5 max-w-md text-sm leading-relaxed text-slate-600 dark:text-zinc-400">
{activePhoto.caption}
</p>
</div>
<span className="shrink-0 rounded-lg bg-slate-100 px-2.5 py-1 text-xs font-semibold tabular-nums text-slate-500 dark:bg-zinc-900 dark:text-zinc-400">
{String(active! + 1).padStart(2, "0")} /{" "}
{String(PHOTOS.length).padStart(2, "0")}
</span>
</div>
{/* controls */}
<button
type="button"
onClick={prev}
aria-label="Previous photograph"
className="absolute left-3 top-1/2 -translate-y-1/2 rounded-full bg-white/90 p-2.5 text-slate-800 shadow-lg outline-none ring-indigo-500 transition hover:bg-white focus-visible:ring-2 dark:bg-zinc-900/90 dark:text-zinc-100 dark:ring-indigo-400"
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
<path d="M15 18l-6-6 6-6" />
</svg>
</button>
<button
type="button"
onClick={next}
aria-label="Next photograph"
className="absolute right-3 top-1/2 -translate-y-1/2 rounded-full bg-white/90 p-2.5 text-slate-800 shadow-lg outline-none ring-indigo-500 transition hover:bg-white focus-visible:ring-2 dark:bg-zinc-900/90 dark:text-zinc-100 dark:ring-indigo-400"
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
<path d="M9 18l6-6-6-6" />
</svg>
</button>
<button
type="button"
onClick={close}
aria-label="Close"
className="absolute right-3 top-3 rounded-full bg-white/90 p-2 text-slate-800 shadow-lg outline-none ring-indigo-500 transition hover:bg-white focus-visible:ring-2 dark:bg-zinc-900/90 dark:text-zinc-100 dark:ring-indigo-400"
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</section>
);
}Dependencies
Licence
Built by Web Innoventix. Free for personal and commercial use, no attribution required.
Built by Web Innoventix
Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.
Get a free quoteSimilar components
Browse all →
Hover Caption Gallery
OriginalA responsive image grid where each tile subtly zooms and reveals a caption bar on hover.

Masonry Gallery
OriginalA Pinterest-style masonry gallery using CSS columns with varied-height images; tiles lift and brighten on hover.

Filterable Gallery
OriginalA filterable gallery with category tabs; clicking a tab animates the grid to show only that category.

Tilt Cards Gallery
OriginalA grid of image cards that tilt in 3D toward the cursor on hover, with a soft glare.

Lightbox Gallery
OriginalA thumbnail grid where clicking a thumb opens a full-screen lightbox with prev/next arrows and keyboard support.

Expanding Strip Gallery
OriginalA horizontal strip of image panels; hovering a panel expands it and shrinks the others to reveal its caption.

Marquee Gallery
OriginalTwo rows of images auto-scrolling in opposite directions, pausing on hover, with edge fade masks.

Hover Zoom Overlay Gallery
OriginalA grid where each image scales up under a dark gradient overlay revealing a title and a view button on hover.

Polaroid Stack Gallery
OriginalA scattered stack of slightly-rotated polaroid photos that straighten and lift on hover.
Featured Thumbs Gallery
OriginalA large featured image with a thumbnail row; selecting a thumbnail swaps the featured image with a crossfade.

Carousel Gallery
OriginalAn image carousel with arrows and dots, smooth slide transitions and autoplay that pauses on hover.

Duotone Hover Gallery
OriginalA grid where images sit under a coloured duotone wash that clears to full colour on hover.

