Overlap Scroll Gallery
Original · freeOverlapping stacked images that fan out 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-overlap-scroll.json"use client";
import { useRef } from "react";
import { motion, useScroll, useTransform, useInView, type MotionValue } from "motion/react";
type Shape = "portrait" | "landscape" | "square";
type Plate = {
src: string;
alt: string;
title: string;
category: string;
location: string;
shape: Shape;
/** resting fan target, in unitless design coordinates */
x: number;
y: number;
rotate: number;
z: number;
};
const PLATES: Plate[] = [
{
src: "/img/gallery/g07.webp",
alt: "Fog rolling between granite spires at first light in the high Dolomites",
title: "Cimon della Pala, Blue Hour",
category: "Alpine",
location: "Dolomiti, Italy",
shape: "portrait",
x: -46,
y: -6,
rotate: -11,
z: 10,
},
{
src: "/img/gallery/g13.webp",
alt: "Long-exposure river cutting through a moss-covered rainforest gorge",
title: "Silver Vein",
category: "Rivers",
location: "Hoh Rainforest, USA",
shape: "landscape",
x: -30,
y: 16,
rotate: -6,
z: 20,
},
{
src: "/img/gallery/g22.webp",
alt: "Overhead pattern of salt evaporation ponds in saturated pink and ochre",
title: "Halite Fields",
category: "Aerial",
location: "Camargue, France",
shape: "square",
x: -16,
y: -22,
rotate: 5,
z: 30,
},
{
src: "/img/gallery/g04.webp",
alt: "Lone figure standing on a dune ridge under a wide desert sky at dusk",
title: "The Long Ridge",
category: "Desert",
location: "Sossusvlei, Namibia",
shape: "portrait",
x: 0,
y: 6,
rotate: 0,
z: 60,
},
{
src: "/img/gallery/g19.webp",
alt: "Bioluminescent tide glowing turquoise along a dark volcanic shoreline",
title: "Cold Light",
category: "Coast",
location: "Vaadhoo, Maldives",
shape: "landscape",
x: 16,
y: -20,
rotate: -4,
z: 30,
},
{
src: "/img/gallery/g28.webp",
alt: "Terraced rice paddies stepping down a green valley in soft morning haze",
title: "Terraces at Dawn",
category: "Fields",
location: "Bali, Indonesia",
shape: "square",
x: 30,
y: 18,
rotate: 7,
z: 20,
},
{
src: "/img/gallery/g31.webp",
alt: "Aurora curtain in green and violet arching over a frozen mountain lake",
title: "Northern Curtain",
category: "Night",
location: "Abisko, Sweden",
shape: "portrait",
x: 46,
y: -4,
rotate: 11,
z: 10,
},
{
src: "/img/gallery/g16.webp",
alt: "Close texture of layered sandstone waves in warm amber and rust tones",
title: "Wave Strata",
category: "Detail",
location: "Coyote Buttes, USA",
shape: "landscape",
x: -8,
y: 30,
rotate: -3,
z: 40,
},
{
src: "/img/gallery/g25.webp",
alt: "Emerald glacier lagoon dotted with drifting icebergs under a pale sky",
title: "Drift",
category: "Ice",
location: "Jökulsárlón, Iceland",
shape: "landscape",
x: 8,
y: 32,
rotate: 4,
z: 40,
},
];
const SHAPE_CLASS: Record<Shape, string> = {
portrait: "aspect-[4/5]",
landscape: "aspect-[13/9]",
square: "aspect-square",
};
const SHAPE_WIDTH: Record<Shape, string> = {
portrait: "w-[min(60vw,17rem)]",
landscape: "w-[min(72vw,22rem)]",
square: "w-[min(58vw,17rem)]",
};
function Plate({
plate,
index,
progress,
}: {
plate: Plate;
index: number;
progress: MotionValue<number>;
}) {
// Fan-out timing: earlier plates settle first, outer plates trail slightly.
const spread = Math.abs(plate.x) / 46; // 0 center → 1 edge
const start = 0.04 + spread * 0.16;
const end = Math.min(0.92, start + 0.5);
const x = useTransform(progress, [start, end], ["0%", `${plate.x * 1.9}%`]);
const y = useTransform(progress, [start, end], ["0%", `${plate.y * 1.15}%`]);
const rotate = useTransform(progress, [start, end], [plate.rotate * 0.12, plate.rotate]);
const scale = useTransform(progress, [start, end], [0.86, 1]);
const opacity = useTransform(progress, [start - 0.02, start + 0.14], [0.35, 1]);
return (
<motion.figure
style={{ x, y, rotate, scale, opacity, zIndex: plate.z }}
className={`gos-plate group absolute left-1/2 top-1/2 -ml-[8rem] -mt-[9rem] ${SHAPE_WIDTH[plate.shape]}`}
>
<div
className={`relative ${SHAPE_CLASS[plate.shape]} overflow-hidden rounded-2xl border border-white/60 bg-white shadow-[0_18px_50px_-20px_rgba(15,23,42,0.55)] ring-1 ring-slate-900/5 transition-shadow duration-500 group-hover:shadow-[0_30px_70px_-24px_rgba(15,23,42,0.7)] dark:border-white/10 dark:bg-zinc-900 dark:ring-white/10`}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={plate.src}
alt={plate.alt}
loading="lazy"
draggable={false}
className="h-full w-full select-none object-cover transition-transform duration-[900ms] ease-out group-hover:scale-[1.05]"
/>
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-slate-950/70 via-slate-950/5 to-transparent opacity-90" />
<figcaption className="absolute inset-x-0 bottom-0 flex items-end justify-between gap-3 p-3.5 sm:p-4">
<div className="min-w-0">
<p className="truncate text-[0.7rem] font-medium uppercase tracking-[0.18em] text-white/70">
{plate.category}
</p>
<p className="truncate text-sm font-semibold text-white sm:text-[0.95rem]">
{plate.title}
</p>
</div>
<span className="shrink-0 rounded-full bg-white/15 px-2.5 py-1 text-[0.65rem] font-medium text-white/90 backdrop-blur-sm ring-1 ring-white/25">
{String(index + 1).padStart(2, "0")}
</span>
</figcaption>
</div>
<span className="pointer-events-none absolute -bottom-6 left-1/2 -translate-x-1/2 whitespace-nowrap text-[0.68rem] font-medium tracking-wide text-slate-500 opacity-0 transition-opacity duration-300 group-hover:opacity-100 dark:text-zinc-400">
{plate.location}
</span>
</motion.figure>
);
}
export default function GalleryOverlapScroll() {
const trackRef = useRef<HTMLDivElement>(null);
const headRef = useRef<HTMLDivElement>(null);
const headInView = useInView(headRef, { once: true, margin: "-15% 0px" });
const { scrollYProgress } = useScroll({
target: trackRef,
offset: ["start 85%", "end 60%"],
});
const railProgress = useTransform(scrollYProgress, [0, 1], ["6%", "100%"]);
return (
<section className="relative w-full overflow-hidden bg-gradient-to-b from-slate-50 via-white to-slate-100 py-24 sm:py-28 dark:from-zinc-950 dark:via-zinc-950 dark:to-black">
{/* ambient wash */}
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 [background:radial-gradient(60%_45%_at_50%_0%,rgba(99,102,241,0.12),transparent_70%),radial-gradient(45%_40%_at_85%_90%,rgba(16,185,129,0.1),transparent_70%)] dark:[background:radial-gradient(60%_45%_at_50%_0%,rgba(129,140,248,0.16),transparent_70%),radial-gradient(45%_40%_at_85%_90%,rgba(16,185,129,0.12),transparent_70%)]"
/>
<div className="relative mx-auto max-w-6xl px-6">
{/* Header */}
<div
ref={headRef}
className={`mx-auto max-w-2xl text-center transition-all duration-700 ease-out ${
headInView ? "translate-y-0 opacity-100" : "translate-y-6 opacity-0"
}`}
>
<span className="inline-flex items-center gap-2 rounded-full border border-slate-200 bg-white/70 px-3.5 py-1.5 text-[0.7rem] font-medium uppercase tracking-[0.2em] text-indigo-600 shadow-sm backdrop-blur dark:border-white/10 dark:bg-white/5 dark:text-indigo-300">
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500" />
Field Notes · Vol. 04
</span>
<h2 className="mt-6 text-balance text-4xl font-semibold tracking-tight text-slate-900 sm:text-5xl dark:text-white">
A stack of places,
<span className="text-indigo-600 dark:text-indigo-300"> fanned open</span>
</h2>
<p className="mx-auto mt-4 max-w-xl text-pretty text-base leading-relaxed text-slate-600 dark:text-zinc-400">
Nine frames from two years on the road, shuffled into a single deck.
Scroll, and the pile spreads out — each photograph finding the light it
was shot in.
</p>
</div>
{/* Stack track — tall enough to drive the fan-out */}
<div ref={trackRef} className="relative mt-16 h-[190vh] sm:mt-20 sm:h-[210vh]">
<div className="sticky top-0 flex h-screen items-center justify-center">
<div className="relative h-[36rem] w-full">
{PLATES.map((plate, i) => (
<Plate key={plate.src} plate={plate} index={i} progress={scrollYProgress} />
))}
</div>
</div>
</div>
{/* Progress rail */}
<div className="mx-auto mt-4 flex max-w-md items-center gap-4">
<span className="text-[0.7rem] font-medium uppercase tracking-[0.18em] text-slate-400 dark:text-zinc-500">
Deck
</span>
<div className="relative h-px flex-1 overflow-hidden rounded-full bg-slate-200 dark:bg-white/10">
<motion.span
style={{ width: railProgress }}
className="absolute inset-y-0 left-0 rounded-full bg-gradient-to-r from-indigo-500 to-emerald-400"
/>
</div>
<span className="text-[0.7rem] font-medium uppercase tracking-[0.18em] text-slate-400 dark:text-zinc-500">
{PLATES.length} frames
</span>
</div>
</div>
<style>{`
.gos-plate {
will-change: transform, opacity;
animation: gos-breathe 9s ease-in-out infinite;
}
.gos-plate:nth-child(even) {
animation-duration: 11s;
animation-direction: alternate-reverse;
}
@keyframes gos-breathe {
0%, 100% { filter: saturate(1); }
50% { filter: saturate(1.06); }
}
@media (prefers-reduced-motion: reduce) {
.gos-plate {
animation: none !important;
}
}
`}</style>
</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.

