Thumb Preview Gallery
Original · freeA thumbnail rail where hovering a thumb shows a large preview panel.
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-thumb-preview.json"use client";
import { useCallback, useId, useRef, useState } from "react";
import { AnimatePresence, motion } from "motion/react";
type Shot = {
src: string;
title: string;
category: string;
caption: string;
place: string;
orientation: "portrait" | "landscape" | "square";
};
const SHOTS: Shot[] = [
{
src: "/img/gallery/g04.webp",
title: "Concrete Cathedral",
category: "Architecture",
caption: "Brutalist stairwell photographed on a single overcast afternoon.",
place: "Barbican, London",
orientation: "portrait",
},
{
src: "/img/gallery/g11.webp",
title: "Low Tide, High Sun",
category: "Landscape",
caption: "The estuary drains to a mirror an hour before the light goes gold.",
place: "Holkham, Norfolk",
orientation: "landscape",
},
{
src: "/img/gallery/g18.webp",
title: "Studio No. 7",
category: "Portrait",
caption: "One softbox, one reflector, and a fifteen-minute window of quiet.",
place: "Kreuzberg, Berlin",
orientation: "portrait",
},
{
src: "/img/gallery/g23.webp",
title: "Market Reds",
category: "Street",
caption: "Chillies drying under canvas — shot from the hip while walking.",
place: "Oaxaca, Mexico",
orientation: "square",
},
{
src: "/img/gallery/g27.webp",
title: "Ridgeline",
category: "Landscape",
caption: "The last snowfield holds out against a warm May afternoon.",
place: "Dolomites, Italy",
orientation: "landscape",
},
{
src: "/img/gallery/g30.webp",
title: "Neon Alley",
category: "Street",
caption: "Rain turned the whole lane into a second, upside-down city.",
place: "Shinjuku, Tokyo",
orientation: "portrait",
},
{
src: "/img/gallery/g33.webp",
title: "Ceramic Study",
category: "Still Life",
caption: "A single glazed bowl, raked light, and a very patient assistant.",
place: "Kyoto, Japan",
orientation: "square",
},
{
src: "/img/gallery/g36.webp",
title: "Harbour Cranes",
category: "Industrial",
caption: "Shot at blue hour while the gantries were still and floodlit.",
place: "Rotterdam, Netherlands",
orientation: "landscape",
},
];
export default function GalleryThumbPreview() {
const [active, setActive] = useState(0);
const uid = useId();
const railRef = useRef<HTMLDivElement>(null);
const current = SHOTS[active];
const focusThumb = useCallback((index: number) => {
const el = railRef.current?.querySelector<HTMLButtonElement>(
`[data-thumb="${index}"]`,
);
el?.focus();
}, []);
const onRailKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
const last = SHOTS.length - 1;
let next = active;
switch (event.key) {
case "ArrowRight":
case "ArrowDown":
next = active === last ? 0 : active + 1;
break;
case "ArrowLeft":
case "ArrowUp":
next = active === 0 ? last : active - 1;
break;
case "Home":
next = 0;
break;
case "End":
next = last;
break;
default:
return;
}
event.preventDefault();
setActive(next);
focusThumb(next);
},
[active, focusThumb],
);
return (
<section
aria-labelledby={`${uid}-heading`}
className="relative w-full overflow-hidden bg-gradient-to-b from-slate-50 via-white to-slate-100 px-5 py-20 text-slate-900 sm:px-8 lg:px-12 lg:py-28 dark:from-zinc-950 dark:via-zinc-950 dark:to-black dark:text-zinc-50"
>
<style>{`
@keyframes gtp-kenburns {
0% { transform: scale(1.02); }
100% { transform: scale(1.09); }
}
@keyframes gtp-rise {
0% { opacity: 0; transform: translateY(14px); }
100% { opacity: 1; transform: translateY(0); }
}
@keyframes gtp-sheen {
0% { transform: translateX(-120%) skewX(-12deg); }
100% { transform: translateX(240%) skewX(-12deg); }
}
.gtp-kenburns { animation: gtp-kenburns 9s ease-out forwards; }
.gtp-rise { animation: gtp-rise 0.6s cubic-bezier(0.22, 1, 0.36, 1) forwards; }
@media (prefers-reduced-motion: reduce) {
.gtp-kenburns,
.gtp-rise { animation: none !important; }
}
`}</style>
{/* ambient glow */}
<div
aria-hidden="true"
className="pointer-events-none absolute -top-40 right-[-10%] h-96 w-96 rounded-full bg-indigo-300/40 blur-3xl dark:bg-indigo-600/20"
/>
<div
aria-hidden="true"
className="pointer-events-none absolute bottom-[-20%] left-[-10%] h-96 w-96 rounded-full bg-violet-300/40 blur-3xl dark:bg-violet-700/20"
/>
<div className="relative mx-auto max-w-6xl">
<header className="mb-10 flex flex-col gap-4 sm:mb-14 sm:flex-row sm:items-end sm:justify-between">
<div>
<span className="inline-flex items-center gap-2 rounded-full border border-slate-300/70 bg-white/60 px-3 py-1 text-xs font-medium uppercase tracking-[0.18em] text-slate-500 backdrop-blur dark:border-zinc-700/70 dark:bg-zinc-900/60 dark:text-zinc-400">
<span className="h-1.5 w-1.5 rounded-full bg-indigo-500 dark:bg-indigo-400" />
Field Archive
</span>
<h2
id={`${uid}-heading`}
className="mt-4 text-3xl font-semibold tracking-tight sm:text-4xl lg:text-5xl"
>
Hover the rail,
<span className="bg-gradient-to-r from-indigo-500 to-violet-500 bg-clip-text text-transparent dark:from-indigo-400 dark:to-violet-400">
{" "}
see it full frame
</span>
</h2>
</div>
<p className="max-w-xs text-sm leading-relaxed text-slate-500 dark:text-zinc-400">
Eight frames from the road. Point at any thumbnail to bring it up on
the big screen — arrow keys work too.
</p>
</header>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[1fr_128px] lg:gap-8">
{/* preview panel */}
<figure className="relative order-2 overflow-hidden rounded-3xl border border-slate-200 bg-slate-100 shadow-[0_30px_60px_-25px_rgba(15,23,42,0.35)] lg:order-1 dark:border-zinc-800 dark:bg-zinc-900 dark:shadow-[0_30px_60px_-25px_rgba(0,0,0,0.8)]">
<div className="relative aspect-[4/3] w-full sm:aspect-[16/10]">
<AnimatePresence initial={false} mode="popLayout">
<motion.div
key={active}
initial={{ opacity: 0, scale: 1.04 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.99 }}
transition={{ duration: 0.55, ease: [0.22, 1, 0.36, 1] }}
className="absolute inset-0"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={current.src}
alt={`${current.title} — ${current.caption}`}
loading="lazy"
draggable={false}
className="gtp-kenburns h-full w-full object-cover"
/>
</motion.div>
</AnimatePresence>
{/* caption gradient */}
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/75 via-black/10 to-transparent"
/>
{/* metadata */}
<figcaption
key={`${active}-meta`}
className="gtp-rise absolute inset-x-0 bottom-0 flex flex-col gap-2 p-6 sm:p-8"
>
<span className="inline-flex w-fit items-center gap-1.5 rounded-full bg-white/15 px-3 py-1 text-[0.7rem] font-semibold uppercase tracking-[0.16em] text-white/90 backdrop-blur-sm ring-1 ring-inset ring-white/25">
{current.category}
</span>
<h3 className="text-xl font-semibold text-white sm:text-2xl">
{current.title}
</h3>
<p className="max-w-lg text-sm leading-relaxed text-white/75">
{current.caption}
</p>
<span className="mt-1 flex items-center gap-1.5 text-xs font-medium text-white/60">
<svg
viewBox="0 0 24 24"
fill="none"
className="h-3.5 w-3.5"
aria-hidden="true"
>
<path
d="M12 21s7-5.686 7-11a7 7 0 1 0-14 0c0 5.314 7 11 7 11Z"
stroke="currentColor"
strokeWidth="1.6"
/>
<circle
cx="12"
cy="10"
r="2.4"
stroke="currentColor"
strokeWidth="1.6"
/>
</svg>
{current.place}
</span>
</figcaption>
{/* counter */}
<div className="absolute right-5 top-5 rounded-full bg-black/40 px-3 py-1 text-xs font-medium tabular-nums text-white/90 backdrop-blur-sm ring-1 ring-inset ring-white/20">
{String(active + 1).padStart(2, "0")}
<span className="text-white/50"> / {String(SHOTS.length).padStart(2, "0")}</span>
</div>
</div>
</figure>
{/* thumbnail rail */}
<div
ref={railRef}
role="listbox"
aria-label="Photo thumbnails"
aria-orientation="vertical"
onKeyDown={onRailKeyDown}
className="order-1 flex gap-3 overflow-x-auto pb-2 lg:order-2 lg:max-h-[560px] lg:flex-col lg:overflow-x-visible lg:overflow-y-auto lg:pb-0 lg:pr-1 [scrollbar-width:thin]"
>
{SHOTS.map((shot, index) => {
const isActive = index === active;
return (
<button
key={shot.src}
type="button"
data-thumb={index}
role="option"
aria-selected={isActive}
aria-label={`${shot.title}, ${shot.category}, ${shot.place}`}
tabIndex={isActive ? 0 : -1}
onMouseEnter={() => setActive(index)}
onFocus={() => setActive(index)}
onClick={() => setActive(index)}
className={`group relative aspect-square w-24 shrink-0 overflow-hidden rounded-2xl outline-none ring-offset-2 ring-offset-slate-50 transition-all duration-300 focus-visible:ring-2 focus-visible:ring-indigo-500 lg:w-full dark:ring-offset-zinc-950 dark:focus-visible:ring-indigo-400 ${
isActive
? "scale-[1.03] ring-2 ring-indigo-500 dark:ring-indigo-400"
: "opacity-60 ring-1 ring-slate-200 hover:opacity-100 dark:ring-zinc-800"
}`}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={shot.src}
alt={shot.title}
loading="lazy"
draggable={false}
className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-110"
/>
<span
aria-hidden="true"
className={`pointer-events-none absolute inset-0 bg-gradient-to-t from-black/60 to-transparent transition-opacity duration-300 ${
isActive ? "opacity-100" : "opacity-0 group-hover:opacity-70"
}`}
/>
{/* sheen sweep on hover */}
<span
aria-hidden="true"
className="pointer-events-none absolute inset-0 overflow-hidden"
>
<span className="absolute inset-y-0 -left-1/3 w-1/3 bg-white/25 opacity-0 [animation-fill-mode:forwards] group-hover:opacity-100 group-hover:[animation:gtp-sheen_0.8s_ease-out]" />
</span>
<span
className={`pointer-events-none absolute bottom-1.5 left-2 text-[0.6rem] font-semibold uppercase tracking-wide text-white drop-shadow transition-opacity duration-300 ${
isActive ? "opacity-100" : "opacity-0 group-hover:opacity-100"
}`}
>
{shot.category}
</span>
</button>
);
})}
</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.

