Deck Cycle Gallery
Original · freeA 3D stacked deck of photos; clicking sends the top card to the back.
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-deck-cycle.json"use client";
import { useCallback, useMemo, useState, type KeyboardEvent } from "react";
import { motion, useReducedMotion } from "motion/react";
type Slide = {
id: number;
src: string;
alt: string;
title: string;
category: string;
caption: string;
location: string;
tone: string;
};
const SLIDES: Slide[] = [
{
id: 1,
src: "/img/gallery/g04.webp",
alt: "Low winter sun breaking through a stand of frost-covered pines",
title: "First Light, Kvaløya",
category: "Landscape",
caption: "Six minutes of usable gold before the ridge swallowed the sun again.",
location: "Tromsø, Norway",
tone: "amber",
},
{
id: 2,
src: "/img/gallery/g11.webp",
alt: "Portrait of a ceramicist holding a freshly thrown bowl, hands dusted with clay",
title: "The Potter's Hands",
category: "Portrait",
caption: "Shot on the studio's north window — no reflector, no flash, just patience.",
location: "Stoke-on-Trent, UK",
tone: "rose",
},
{
id: 3,
src: "/img/gallery/g19.webp",
alt: "Aerial view of turquoise river braids cutting through dark volcanic sand",
title: "Braided Delta",
category: "Aerial",
caption: "Flown at 400 ft; the meltwater rewrites these channels every single week.",
location: "Landmannalaugar, IS",
tone: "emerald",
},
{
id: 4,
src: "/img/gallery/g27.webp",
alt: "Rain-slicked neon alley reflected in a puddle at night",
title: "After the Downpour",
category: "Street",
caption: "Waited out the storm under an awning; the reflections were the whole point.",
location: "Shinjuku, Tokyo",
tone: "indigo",
},
{
id: 5,
src: "/img/gallery/g08.webp",
alt: "Close macro of dew beading on a spider's orb web at dawn",
title: "Tension & Dew",
category: "Macro",
caption: "A 90mm macro at f/8, focus-stacked from nineteen frames handheld.",
location: "Peak District, UK",
tone: "violet",
},
{
id: 6,
src: "/img/gallery/g33.webp",
alt: "Silhouetted surfer walking a black-sand beach under heavy clouds",
title: "Between Sets",
category: "Documentary",
caption: "The swell was flat for an hour — sometimes the wait is the story.",
location: "Reynisfjara, IS",
tone: "amber",
},
{
id: 7,
src: "/img/gallery/g15.webp",
alt: "Overhead flat-lay of a market stall stacked with heirloom tomatoes",
title: "Heirloom Table",
category: "Still Life",
caption: "Natural light, styled in eight minutes before the vendor sold the lot.",
location: "Ortygia, Sicily",
tone: "rose",
},
{
id: 8,
src: "/img/gallery/g22.webp",
alt: "Long-exposure of star trails wheeling above a lone desert arch",
title: "Two Hours of Sky",
category: "Astro",
caption: "Ninety 90-second frames stacked; the arch was lit by a passing car.",
location: "Moab, Utah",
tone: "indigo",
},
];
const TONE: Record<
string,
{ ring: string; chip: string; glow: string; dot: string }
> = {
amber: {
ring: "ring-amber-300/60 dark:ring-amber-400/30",
chip: "bg-amber-100 text-amber-800 dark:bg-amber-400/15 dark:text-amber-200",
glow: "shadow-amber-500/20",
dot: "bg-amber-500",
},
rose: {
ring: "ring-rose-300/60 dark:ring-rose-400/30",
chip: "bg-rose-100 text-rose-800 dark:bg-rose-400/15 dark:text-rose-200",
glow: "shadow-rose-500/20",
dot: "bg-rose-500",
},
emerald: {
ring: "ring-emerald-300/60 dark:ring-emerald-400/30",
chip: "bg-emerald-100 text-emerald-800 dark:bg-emerald-400/15 dark:text-emerald-200",
glow: "shadow-emerald-500/20",
dot: "bg-emerald-500",
},
indigo: {
ring: "ring-indigo-300/60 dark:ring-indigo-400/30",
chip: "bg-indigo-100 text-indigo-800 dark:bg-indigo-400/15 dark:text-indigo-200",
glow: "shadow-indigo-500/20",
dot: "bg-indigo-500",
},
violet: {
ring: "ring-violet-300/60 dark:ring-violet-400/30",
chip: "bg-violet-100 text-violet-800 dark:bg-violet-400/15 dark:text-violet-200",
glow: "shadow-violet-500/20",
dot: "bg-violet-500",
},
};
const VISIBLE = 5;
export default function GalleryDeckCycle() {
const reduce = useReducedMotion();
const [order, setOrder] = useState<number[]>(() =>
SLIDES.map((_, i) => i)
);
const [nudge, setNudge] = useState<0 | 1 | -1>(0);
const cycleForward = useCallback(() => {
setOrder((prev) => {
const next = prev.slice();
const top = next.shift();
if (top !== undefined) next.push(top);
return next;
});
}, []);
const cycleBack = useCallback(() => {
setOrder((prev) => {
const next = prev.slice();
const last = next.pop();
if (last !== undefined) next.unshift(last);
return next;
});
}, []);
const positions = useMemo(() => {
const map = new Map<number, number>();
order.forEach((slideIndex, pos) => map.set(slideIndex, pos));
return map;
}, [order]);
const topSlide = SLIDES[order[0]];
const total = SLIDES.length;
const onKeyDeck = useCallback(
(e: KeyboardEvent) => {
if (e.key === "ArrowRight" || e.key === " " || e.key === "Enter") {
e.preventDefault();
setNudge(-1);
cycleForward();
} else if (e.key === "ArrowLeft") {
e.preventDefault();
setNudge(1);
cycleBack();
}
},
[cycleForward, cycleBack]
);
return (
<section
className="relative w-full overflow-hidden bg-gradient-to-b from-slate-50 via-white to-slate-100 px-6 py-20 sm:py-28 dark:from-zinc-950 dark:via-zinc-950 dark:to-black"
aria-label="Deck cycle photo gallery"
>
<style>{`
@keyframes gdc-float {
0%, 100% { transform: translateY(0px); }
50% { transform: translateY(-8px); }
}
@keyframes gdc-sheen {
0% { transform: translateX(-120%) skewX(-12deg); opacity: 0; }
18% { opacity: 0.55; }
40% { transform: translateX(220%) skewX(-12deg); opacity: 0; }
100% { transform: translateX(220%) skewX(-12deg); opacity: 0; }
}
@keyframes gdc-rise {
from { opacity: 0; transform: translateY(14px); }
to { opacity: 1; transform: translateY(0); }
}
.gdc-float { animation: gdc-float 7s ease-in-out infinite; }
.gdc-sheen { animation: gdc-sheen 6.5s ease-in-out infinite; }
.gdc-rise { animation: gdc-rise 0.7s cubic-bezier(0.22, 1, 0.36, 1) both; }
@media (prefers-reduced-motion: reduce) {
.gdc-float, .gdc-sheen, .gdc-rise { animation: none !important; }
}
`}</style>
{/* ambient blobs */}
<div
aria-hidden="true"
className="pointer-events-none absolute -left-24 top-10 h-72 w-72 rounded-full bg-indigo-300/30 blur-3xl dark:bg-indigo-600/15"
/>
<div
aria-hidden="true"
className="pointer-events-none absolute -right-20 bottom-0 h-80 w-80 rounded-full bg-rose-300/25 blur-3xl dark:bg-violet-700/15"
/>
<div className="relative mx-auto grid max-w-6xl items-center gap-12 lg:grid-cols-[1fr_minmax(360px,520px)] lg:gap-8">
{/* left: copy + live meta */}
<div className="gdc-rise order-2 lg:order-1">
<span className="inline-flex items-center gap-2 rounded-full border border-slate-200 bg-white/70 px-3 py-1 text-xs font-medium uppercase tracking-widest text-slate-500 backdrop-blur dark:border-zinc-800 dark:bg-zinc-900/70 dark:text-zinc-400">
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500" />
Field Portfolio · {new Date().getFullYear()}
</span>
<h2 className="mt-5 text-4xl font-semibold tracking-tight text-slate-900 sm:text-5xl dark:text-white">
The Deck
</h2>
<p className="mt-4 max-w-md text-base leading-relaxed text-slate-600 dark:text-zinc-400">
Eight frames from the road, stacked like contact prints on a
lightbox. Flip through the pile — tap the top photo, drag it away, or
use the arrow keys.
</p>
<div
key={topSlide.id}
className="gdc-rise mt-8 max-w-md rounded-2xl border border-slate-200 bg-white/70 p-5 backdrop-blur dark:border-zinc-800 dark:bg-zinc-900/60"
>
<div className="flex items-center gap-2">
<span
className={`inline-flex items-center rounded-md px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide ${TONE[topSlide.tone].chip}`}
>
{topSlide.category}
</span>
<span className="text-xs text-slate-400 dark:text-zinc-500">
{topSlide.location}
</span>
</div>
<p className="mt-3 text-lg font-medium text-slate-900 dark:text-white">
{topSlide.title}
</p>
<p className="mt-1 text-sm leading-relaxed text-slate-600 dark:text-zinc-400">
{topSlide.caption}
</p>
</div>
<div className="mt-8 flex items-center gap-4">
<button
type="button"
onClick={() => {
setNudge(1);
cycleBack();
}}
aria-label="Send previous photo back to the top of the deck"
className="inline-flex h-11 w-11 items-center justify-center rounded-full border border-slate-200 bg-white text-slate-700 transition hover:-translate-y-0.5 hover:border-slate-300 hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-200 dark:hover:border-zinc-700 dark:focus-visible:ring-offset-black"
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M15 18l-6-6 6-6" />
</svg>
</button>
<button
type="button"
onClick={() => {
setNudge(-1);
cycleForward();
}}
className="group inline-flex items-center gap-2 rounded-full bg-slate-900 px-5 py-2.5 text-sm font-medium text-white transition hover:-translate-y-0.5 hover:bg-slate-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:bg-white dark:text-zinc-900 dark:hover:bg-zinc-200 dark:focus-visible:ring-offset-black"
>
Next frame
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
className="transition-transform group-hover:translate-x-0.5"
>
<path d="M5 12h14M13 6l6 6-6 6" />
</svg>
</button>
<div
className="ml-auto flex items-center gap-1.5"
role="tablist"
aria-label="Deck position"
>
{SLIDES.map((s, i) => {
const isTop = positions.get(i) === 0;
return (
<span
key={s.id}
role="tab"
aria-selected={isTop}
aria-label={`${s.title}${isTop ? " (on top)" : ""}`}
className={`h-1.5 rounded-full transition-all duration-500 ${
isTop
? `w-6 ${TONE[s.tone].dot}`
: "w-1.5 bg-slate-300 dark:bg-zinc-700"
}`}
/>
);
})}
</div>
</div>
</div>
{/* right: the deck */}
<div className="order-1 flex justify-center lg:order-2">
<div
role="button"
tabIndex={0}
aria-label={`Photo deck, ${total} frames. Currently showing ${topSlide.title}. Press Enter or the right arrow to send the top photo to the back.`}
onKeyDown={onKeyDeck}
onClick={() => {
setNudge(-1);
cycleForward();
}}
className="group relative h-[440px] w-[320px] cursor-pointer select-none rounded-3xl outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-4 focus-visible:ring-offset-slate-50 sm:h-[500px] sm:w-[360px] dark:focus-visible:ring-offset-zinc-950"
style={{ perspective: "1400px" }}
>
<div
className="gdc-float absolute inset-0"
style={{ transformStyle: "preserve-3d" }}
>
{SLIDES.map((slide, i) => {
const pos = positions.get(i) ?? 0;
const hidden = pos >= VISIBLE;
const tone = TONE[slide.tone];
const depth = Math.min(pos, VISIBLE);
const y = depth * 20;
const scale = 1 - depth * 0.05;
const z = -depth * 60;
const rot = pos === 0 ? 0 : (i % 2 === 0 ? 1 : -1) * (1.5 + depth);
const opacity = hidden ? 0 : 1 - depth * 0.12;
return (
<motion.figure
key={slide.id}
initial={false}
animate={
reduce
? { opacity: pos === 0 ? 1 : 0.4 }
: {
y,
scale,
z,
rotateZ: rot,
rotateX: pos === 0 ? 0 : 3,
opacity,
x: pos === 0 ? nudge * 2 : 0,
}
}
transition={
reduce
? { duration: 0 }
: {
type: "spring",
stiffness: 260,
damping: 30,
mass: 0.9,
}
}
style={{
transformStyle: "preserve-3d",
zIndex: total - pos,
pointerEvents: pos === 0 ? "auto" : "none",
}}
className={`absolute inset-0 m-0 overflow-hidden rounded-3xl bg-white shadow-2xl ring-1 ${tone.ring} ${tone.glow} dark:bg-zinc-900`}
>
<div className="relative h-full w-full">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={slide.src}
alt={slide.alt}
loading="lazy"
draggable={false}
className="h-full w-full object-cover"
/>
{/* legibility gradient */}
<div
aria-hidden="true"
className="absolute inset-0 bg-gradient-to-t from-black/75 via-black/10 to-transparent"
/>
{/* animated sheen only on the top card */}
{pos === 0 && (
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 overflow-hidden rounded-3xl"
>
<div className="gdc-sheen absolute -inset-y-4 left-0 w-1/3 bg-gradient-to-r from-transparent via-white/40 to-transparent" />
</div>
)}
<figcaption className="absolute inset-x-0 bottom-0 p-5 text-left">
<span
className={`inline-flex items-center rounded-md px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider ${tone.chip}`}
>
{slide.category}
</span>
<p className="mt-2 text-lg font-semibold leading-tight text-white drop-shadow-sm">
{slide.title}
</p>
<p className="mt-0.5 text-xs font-medium text-white/70">
{slide.location}
</p>
</figcaption>
{/* frame counter badge on top card */}
{pos === 0 && (
<div className="absolute right-4 top-4 rounded-full bg-white/90 px-2.5 py-1 text-[11px] font-semibold tabular-nums text-slate-800 backdrop-blur dark:bg-zinc-900/80 dark:text-zinc-100">
{String(i + 1).padStart(2, "0")}
<span className="text-slate-400 dark:text-zinc-500">
{" "}
/ {String(total).padStart(2, "0")}
</span>
</div>
)}
</div>
</motion.figure>
);
})}
</div>
{/* tap hint */}
<div className="pointer-events-none absolute -bottom-9 left-1/2 -translate-x-1/2 whitespace-nowrap text-xs font-medium text-slate-400 opacity-0 transition-opacity duration-300 group-hover:opacity-100 dark:text-zinc-500">
tap to send to back
</div>
</div>
</div>
</div>
</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.

