Scroll Stack Gallery
Original · freeImage cards that pin and stack as you scroll, driven by scroll position.
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-scroll-stack.json"use client";
import { useRef } from "react";
import {
motion,
useScroll,
useTransform,
type MotionValue,
} from "motion/react";
type Shot = {
src: string;
alt: string;
title: string;
category: string;
place: string;
year: string;
caption: string;
tone: string; // tailwind gradient accent classes for the card frame glow
};
const SHOTS: Shot[] = [
{
src: "/img/gallery/g04.webp",
alt: "Pale blue timber houses along a harbour at the blue hour",
title: "Cobalt Hour",
category: "Coastal Architecture",
place: "Reykjavík, Iceland",
year: "2024",
caption:
"Shot four minutes after the sun dropped, when the harbour still holds a little daylight and the streetlamps have just woken up.",
tone: "from-indigo-400/60 via-sky-300/40 to-violet-400/60",
},
{
src: "/img/gallery/g11.webp",
alt: "Rows of glowing paper lanterns strung above a narrow evening street",
title: "Paper Lanterns",
category: "Street Life",
place: "Kyoto, Japan",
year: "2023",
caption:
"The Gion backstreets light one lantern at a time. I waited for the row to complete before the shutter.",
tone: "from-amber-400/60 via-rose-300/40 to-amber-500/60",
},
{
src: "/img/gallery/g19.webp",
alt: "A long reading room lined with dark wooden shelves and warm lamps",
title: "The Long Room",
category: "Interiors",
place: "Dublin, Ireland",
year: "2022",
caption:
"Two hundred thousand volumes, one perfect axis of light. Hand-held at 1/15th, breath held longer.",
tone: "from-amber-500/60 via-neutral-300/30 to-amber-400/60",
},
{
src: "/img/gallery/g07.webp",
alt: "Mirror-flat salt flats reflecting a pastel dawn sky",
title: "Salt Flats at Dawn",
category: "Landscape",
place: "Uyuni, Bolivia",
year: "2024",
caption:
"A thin film of water turns the largest salt flat on earth into a mirror. Sky below, sky above.",
tone: "from-rose-300/60 via-indigo-200/30 to-emerald-300/60",
},
{
src: "/img/gallery/g23.webp",
alt: "A crowded spice market with baskets of deep red and ochre powders",
title: "Market Palette",
category: "Documentary",
place: "Marrakech, Morocco",
year: "2023",
caption:
"The vendor arranges his cones every morning like a painter reloading a palette. I bought saffron to earn the frame.",
tone: "from-rose-400/60 via-amber-400/40 to-rose-500/60",
},
{
src: "/img/gallery/g30.webp",
alt: "A monumental concrete building with sweeping brutalist curves",
title: "Concrete Cathedral",
category: "Brutalism",
place: "São Paulo, Brazil",
year: "2022",
caption:
"Raw béton brut, softened only by fifty years of tropical rain. The light does the sculpting now.",
tone: "from-zinc-400/60 via-slate-300/30 to-zinc-500/60",
},
{
src: "/img/gallery/g15.webp",
alt: "Turquoise glacial meltwater cutting through grey stone",
title: "Meltwater",
category: "Nature",
place: "Patagonia, Chile",
year: "2024",
caption:
"Glacier flour gives the water that impossible mint colour. It was four degrees and worth every shiver.",
tone: "from-emerald-300/60 via-sky-300/40 to-emerald-400/60",
},
{
src: "/img/gallery/g28.webp",
alt: "A neon-lit arcade alley reflected in wet pavement at night",
title: "Neon Arcade",
category: "Night",
place: "Osaka, Japan",
year: "2023",
caption:
"It rained for the whole trip. The pavement paid me back by doubling every sign.",
tone: "from-violet-400/60 via-rose-400/40 to-indigo-400/60",
},
];
function StackCard({
shot,
index,
total,
progress,
}: {
shot: Shot;
index: number;
total: number;
progress: MotionValue<number>;
}) {
// Cards further down the stack settle to a slightly smaller scale as the
// ones after them slide up and overlap. Subtle so it reads as depth, not shrink.
const targetScale = 1 - (total - 1 - index) * 0.04;
const start = index / total;
const scale = useTransform(progress, [start, 1], [1, targetScale]);
const filter = useTransform(
progress,
[start, Math.min(start + 0.35, 1)],
["brightness(1)", "brightness(0.82)"],
);
return (
<div className="sticky top-0 flex h-screen items-center justify-center px-4 sm:px-6">
<motion.figure
tabIndex={0}
aria-label={`${shot.title}. ${shot.category}, ${shot.place}, ${shot.year}.`}
style={{
scale,
filter,
top: `calc(8vh + ${index * 1.6}rem)`,
}}
className="group relative flex h-[74vh] w-full max-w-5xl origin-top flex-col overflow-hidden rounded-3xl border border-slate-200/80 bg-white/90 shadow-2xl shadow-slate-900/10 outline-none ring-indigo-400/70 backdrop-blur-sm transition-shadow focus-visible:ring-4 dark:border-white/10 dark:bg-slate-900/80 dark:shadow-black/40 dark:ring-violet-400/70 md:flex-row"
>
{/* accent glow frame */}
<span
aria-hidden
className={`pointer-events-none absolute inset-0 z-10 rounded-3xl bg-gradient-to-br ${shot.tone} opacity-0 mix-blend-overlay transition-opacity duration-500 group-hover:opacity-100 group-focus-visible:opacity-100`}
/>
{/* image */}
<div className="relative h-1/2 w-full overflow-hidden md:h-full md:w-[62%]">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={shot.src}
alt={shot.alt}
loading="lazy"
draggable={false}
className="gss-kenburns h-full w-full object-cover transition-transform duration-[1200ms] ease-out group-hover:scale-[1.04]"
/>
<span
aria-hidden
className="absolute inset-0 bg-gradient-to-t from-black/40 via-transparent to-black/10 md:bg-gradient-to-r md:from-transparent md:via-transparent md:to-white/10 md:dark:to-slate-900/40"
/>
<span className="absolute left-4 top-4 inline-flex items-center gap-1.5 rounded-full bg-black/45 px-3 py-1 text-[0.7rem] font-medium uppercase tracking-widest text-white backdrop-blur-md">
<span className="h-1.5 w-1.5 rounded-full bg-emerald-400" />
{shot.category}
</span>
</div>
{/* caption panel */}
<figcaption className="relative flex h-1/2 flex-1 flex-col justify-between gap-4 p-6 sm:p-8 md:h-full md:w-[38%] md:p-10">
<div>
<div className="flex items-baseline justify-between gap-4">
<span className="font-mono text-xs tabular-nums text-indigo-500 dark:text-violet-400">
{String(index + 1).padStart(2, "0")} / {String(total).padStart(2, "0")}
</span>
<span className="font-mono text-xs tabular-nums text-slate-400 dark:text-slate-500">
{shot.year}
</span>
</div>
<h3 className="mt-3 text-2xl font-semibold leading-tight tracking-tight text-slate-900 dark:text-white sm:text-3xl">
{shot.title}
</h3>
<div className="mt-1 flex items-center gap-2 text-sm text-slate-500 dark:text-slate-400">
<svg
aria-hidden
viewBox="0 0 24 24"
className="h-4 w-4 shrink-0"
fill="none"
stroke="currentColor"
strokeWidth="1.7"
>
<path d="M12 21s-7-6.3-7-11a7 7 0 0114 0c0 4.7-7 11-7 11z" />
<circle cx="12" cy="10" r="2.5" />
</svg>
{shot.place}
</div>
</div>
<p className="text-[0.95rem] leading-relaxed text-slate-600 dark:text-slate-300">
{shot.caption}
</p>
<div className="flex items-center justify-between border-t border-slate-200/70 pt-4 dark:border-white/10">
<span className="text-xs font-medium uppercase tracking-widest text-slate-400 dark:text-slate-500">
Wanderframe · Series 04
</span>
<span
aria-hidden
className="inline-flex h-9 w-9 items-center justify-center rounded-full border border-slate-200 text-slate-500 transition-colors group-hover:border-indigo-400 group-hover:text-indigo-500 dark:border-white/15 dark:text-slate-400 dark:group-hover:border-violet-400 dark:group-hover:text-violet-300"
>
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="1.8">
<path d="M7 17L17 7M17 7H8M17 7v9" />
</svg>
</span>
</div>
</figcaption>
</motion.figure>
</div>
);
}
export default function GalleryScrollStack() {
const containerRef = useRef<HTMLDivElement>(null);
const { scrollYProgress } = useScroll({
target: containerRef,
offset: ["start start", "end end"],
});
const barScaleX = useTransform(scrollYProgress, [0, 1], [0, 1]);
return (
<section className="relative w-full bg-gradient-to-b from-slate-50 via-white to-slate-100 text-slate-900 dark:from-slate-950 dark:via-slate-900 dark:to-black dark:text-white">
<style>{`
@keyframes gss-kenburns {
0% { transform: scale(1) translate3d(0,0,0); }
100% { transform: scale(1.06) translate3d(0,-1.2%,0); }
}
.gss-kenburns { animation: gss-kenburns 18s ease-in-out infinite alternate; }
@keyframes gss-float {
0%,100% { transform: translateY(0); }
50% { transform: translateY(-8px); }
}
.gss-float { animation: gss-float 6s ease-in-out infinite; }
@media (prefers-reduced-motion: reduce) {
.gss-kenburns, .gss-float { animation: none !important; }
}
`}</style>
{/* scroll progress bar */}
<div className="pointer-events-none sticky top-0 z-30 h-1 w-full bg-transparent">
<motion.div
style={{ scaleX: barScaleX }}
className="h-full w-full origin-left bg-gradient-to-r from-indigo-500 via-violet-500 to-emerald-400"
/>
</div>
{/* header */}
<header className="relative mx-auto max-w-5xl px-6 pb-6 pt-24 text-center sm:pt-32">
<span className="gss-float inline-flex items-center gap-2 rounded-full border border-slate-200 bg-white/70 px-4 py-1.5 text-xs font-medium uppercase tracking-[0.2em] text-slate-500 shadow-sm backdrop-blur dark:border-white/10 dark:bg-white/5 dark:text-slate-400">
<span className="h-1.5 w-1.5 rounded-full bg-indigo-500" />
Field Notes · 2022—2024
</span>
<h2 className="mt-6 text-4xl font-semibold tracking-tight sm:text-6xl">
Wanderframe
</h2>
<p className="mx-auto mt-4 max-w-xl text-base leading-relaxed text-slate-600 dark:text-slate-300 sm:text-lg">
Eight frames from four years on the road. Keep scrolling — each print pins,
settles, and lets the next one climb over it.
</p>
<div
aria-hidden
className="mx-auto mt-10 flex w-fit flex-col items-center gap-2 text-slate-400 dark:text-slate-500"
>
<span className="text-[0.7rem] font-medium uppercase tracking-widest">Scroll</span>
<svg viewBox="0 0 24 24" className="h-5 w-5 gss-float" fill="none" stroke="currentColor" strokeWidth="1.8">
<path d="M12 5v14M6 13l6 6 6-6" />
</svg>
</div>
</header>
{/* the stack */}
<div ref={containerRef} className="relative">
{SHOTS.map((shot, i) => (
<StackCard
key={shot.title}
shot={shot}
index={i}
total={SHOTS.length}
progress={scrollYProgress}
/>
))}
</div>
{/* footer */}
<footer className="relative mx-auto max-w-5xl px-6 py-24 text-center">
<p className="text-sm uppercase tracking-[0.25em] text-slate-400 dark:text-slate-500">
End of series
</p>
<p className="mx-auto mt-4 max-w-md text-slate-600 dark:text-slate-300">
Every frame shot on location, available light only. Prints numbered to fifty.
</p>
</footer>
</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.

