Carousel Thumbs Gallery
Original · freeA large slide carousel synced with a clickable thumbnail strip.
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-carousel-thumbs.json"use client";
import { useCallback, useEffect, useId, useRef, useState } from "react";
import { AnimatePresence, motion } from "motion/react";
type Slide = {
src: string;
alt: string;
title: string;
category: string;
caption: string;
location: string;
};
const SLIDES: Slide[] = [
{
src: "/img/gallery/g03.webp",
alt: "Terraced rice fields glowing gold under a low morning sun",
title: "Terraces at First Light",
category: "Landscape",
caption: "Dawn spills across the stepped paddies before the valley wakes.",
location: "Bali, Indonesia",
},
{
src: "/img/gallery/g07.webp",
alt: "A ceramicist's hands shaping wet clay on a spinning wheel",
title: "The Potter's Hands",
category: "Craft",
caption: "Twenty years of muscle memory in a single pull of the wall.",
location: "Bat Trang Village",
},
{
src: "/img/gallery/g12.webp",
alt: "Neon-lit alley reflecting in a rain-slicked street at night",
title: "After the Rain",
category: "Street",
caption: "The city rewrites itself in reflections once the storm clears.",
location: "Shinjuku, Tokyo",
},
{
src: "/img/gallery/g18.webp",
alt: "Snow-dusted pine forest fading into pale winter fog",
title: "Silence, Measured",
category: "Wild",
caption: "Fog erases the horizon and the forest holds its breath.",
location: "Hokkaidō Highlands",
},
{
src: "/img/gallery/g21.webp",
alt: "Close portrait of an elder with deep laugh lines and bright eyes",
title: "Ninety Winters",
category: "Portrait",
caption: "Every line is a decade she is happy to have earned.",
location: "Chefchaouen, Morocco",
},
{
src: "/img/gallery/g25.webp",
alt: "Aerial view of turquoise coastline meeting dark volcanic rock",
title: "Where Two Blues Meet",
category: "Aerial",
caption: "Shot from 400 feet, the tide draws its own coastline.",
location: "Amalfi Coast",
},
{
src: "/img/gallery/g29.webp",
alt: "Steam rising from a bowl of noodles in a warmly lit kitchen",
title: "Bowl Number Forty",
category: "Food",
caption: "The fortieth bowl of the day, still made like the first.",
location: "Fukuoka, Japan",
},
{
src: "/img/gallery/g33.webp",
alt: "Desert dunes carved into sharp ridges by evening wind",
title: "Wind's Signature",
category: "Landscape",
caption: "The dunes redraw themselves every night; this held for one hour.",
location: "Erg Chebbi, Sahara",
},
];
const SLIDE_COUNT = SLIDES.length;
export default function GalleryCarouselThumbs() {
const [active, setActive] = useState(0);
const [direction, setDirection] = useState(1);
const [paused, setPaused] = useState(false);
const [isHydrated, setIsHydrated] = useState(false);
const uid = useId().replace(/[:]/g, "");
const anim = `gct_${uid}`;
const thumbStripRef = useRef<HTMLDivElement | null>(null);
const regionRef = useRef<HTMLDivElement | null>(null);
useEffect(() => setIsHydrated(true), []);
const goTo = useCallback(
(next: number) => {
setActive((prev) => {
const target = (next + SLIDE_COUNT) % SLIDE_COUNT;
setDirection(target > prev || (prev === SLIDE_COUNT - 1 && target === 0) ? 1 : -1);
return target;
});
},
[],
);
const next = useCallback(() => {
setDirection(1);
setActive((prev) => (prev + 1) % SLIDE_COUNT);
}, []);
const prev = useCallback(() => {
setDirection(-1);
setActive((prev) => (prev - 1 + SLIDE_COUNT) % SLIDE_COUNT);
}, []);
// Autoplay
useEffect(() => {
if (paused || !isHydrated) return;
const reduce =
typeof window !== "undefined" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduce) return;
const id = window.setInterval(next, 5200);
return () => window.clearInterval(id);
}, [paused, next, isHydrated]);
// Keep the active thumbnail visible in the strip
useEffect(() => {
const strip = thumbStripRef.current;
if (!strip) return;
const el = strip.querySelector<HTMLElement>(`[data-thumb="${active}"]`);
if (el) {
el.scrollIntoView({
behavior: "smooth",
inline: "center",
block: "nearest",
});
}
}, [active]);
const onKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.key === "ArrowRight") {
e.preventDefault();
next();
} else if (e.key === "ArrowLeft") {
e.preventDefault();
prev();
} else if (e.key === "Home") {
e.preventDefault();
goTo(0);
} else if (e.key === "End") {
e.preventDefault();
goTo(SLIDE_COUNT - 1);
}
},
[next, prev, goTo],
);
const slide = SLIDES[active];
return (
<section className="relative w-full overflow-hidden bg-gradient-to-b from-white via-slate-50 to-slate-100 px-4 py-16 sm:px-6 sm:py-20 lg:px-8 lg:py-28 dark:from-neutral-950 dark:via-neutral-950 dark:to-black">
<style>{`
@keyframes ${anim}_kenburns {
0% { transform: scale(1.04) translate3d(0,0,0); }
100% { transform: scale(1.12) translate3d(-1.4%, -1.2%, 0); }
}
@keyframes ${anim}_shimmer {
0% { transform: translateX(-120%); }
100% { transform: translateX(220%); }
}
@keyframes ${anim}_progress {
0% { transform: scaleX(0); }
100% { transform: scaleX(1); }
}
.${anim}_ken { animation: ${anim}_kenburns 8s ease-out forwards; }
.${anim}_sheen { animation: ${anim}_shimmer 2.6s ease-in-out infinite; }
.${anim}_bar { animation: ${anim}_progress 5.2s linear forwards; transform-origin: left; }
@media (prefers-reduced-motion: reduce) {
.${anim}_ken,
.${anim}_sheen,
.${anim}_bar { animation: none !important; }
.${anim}_ken { transform: scale(1.04); }
}
`}</style>
<div className="relative mx-auto max-w-6xl">
{/* Header */}
<div className="mb-8 flex flex-col gap-4 sm:mb-10 sm:flex-row sm:items-end sm:justify-between">
<div>
<span className="inline-flex items-center gap-2 rounded-full border border-indigo-200 bg-indigo-50 px-3 py-1 text-xs font-medium uppercase tracking-[0.18em] text-indigo-700 dark:border-indigo-400/25 dark:bg-indigo-400/10 dark:text-indigo-300">
<span className="relative flex h-1.5 w-1.5">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-indigo-500 opacity-60" />
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-indigo-500" />
</span>
Field Notes
</span>
<h2 className="mt-4 text-3xl font-semibold tracking-tight text-slate-900 sm:text-4xl dark:text-white">
A Year in Eight Frames
</h2>
<p className="mt-2 max-w-md text-sm text-slate-600 sm:text-base dark:text-neutral-400">
Selected work from twelve months on the road, paired with the note
scribbled the moment the shutter closed.
</p>
</div>
<div className="flex items-center gap-3 text-sm text-slate-500 dark:text-neutral-500">
<span className="tabular-nums font-medium text-slate-900 dark:text-white">
{String(active + 1).padStart(2, "0")}
</span>
<span className="h-px w-8 bg-slate-300 dark:bg-neutral-700" />
<span className="tabular-nums">{String(SLIDE_COUNT).padStart(2, "0")}</span>
</div>
</div>
{/* Stage */}
<div
ref={regionRef}
role="group"
aria-roledescription="carousel"
aria-label="Featured photograph carousel"
tabIndex={0}
onKeyDown={onKeyDown}
onMouseEnter={() => setPaused(true)}
onMouseLeave={() => setPaused(false)}
onFocus={() => setPaused(true)}
onBlur={() => setPaused(false)}
className="group relative aspect-[4/3] w-full overflow-hidden rounded-2xl bg-slate-200 shadow-xl shadow-slate-900/10 ring-1 ring-slate-900/5 outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white sm:aspect-[16/10] lg:aspect-[16/9] dark:bg-neutral-900 dark:shadow-black/40 dark:ring-white/10 dark:focus-visible:ring-offset-neutral-950"
>
<AnimatePresence initial={false} custom={direction} mode="popLayout">
<motion.div
key={active}
custom={direction}
initial={{ opacity: 0, x: direction * 48, scale: 1.01 }}
animate={{ opacity: 1, x: 0, scale: 1 }}
exit={{ opacity: 0, x: direction * -48, scale: 1.01 }}
transition={{
x: { type: "spring", stiffness: 260, damping: 32 },
opacity: { duration: 0.4, ease: "easeOut" },
scale: { duration: 0.5, ease: "easeOut" },
}}
className="absolute inset-0"
aria-roledescription="slide"
aria-label={`${active + 1} of ${SLIDE_COUNT}: ${slide.title}`}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={slide.src}
alt={slide.alt}
loading="lazy"
draggable={false}
className={`${anim}_ken h-full w-full object-cover`}
/>
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/75 via-black/15 to-transparent" />
<div className="pointer-events-none absolute inset-0 bg-gradient-to-r from-black/25 to-transparent sm:from-black/20" />
</motion.div>
</AnimatePresence>
{/* Caption overlay */}
<div className="pointer-events-none absolute inset-x-0 bottom-0 p-5 sm:p-7 lg:p-9">
<AnimatePresence mode="wait">
<motion.div
key={active}
initial={{ opacity: 0, y: 14 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
transition={{ duration: 0.42, ease: "easeOut", delay: 0.08 }}
className="max-w-xl"
>
<div className="mb-2 flex items-center gap-2.5">
<span className="rounded-full bg-white/15 px-2.5 py-0.5 text-[0.7rem] font-medium uppercase tracking-[0.14em] text-white backdrop-blur-sm">
{slide.category}
</span>
<span className="text-xs font-medium text-white/70">
{slide.location}
</span>
</div>
<h3 className="text-xl font-semibold tracking-tight text-white drop-shadow-sm sm:text-2xl lg:text-3xl">
{slide.title}
</h3>
<p className="mt-1.5 max-w-md text-sm text-white/80 sm:text-base">
{slide.caption}
</p>
</motion.div>
</AnimatePresence>
</div>
{/* Prev / Next controls */}
<button
type="button"
onClick={prev}
aria-label="Previous photograph"
className="absolute left-3 top-1/2 z-10 flex h-10 w-10 -translate-y-1/2 items-center justify-center rounded-full bg-white/85 text-slate-800 shadow-lg backdrop-blur-md transition duration-200 hover:scale-105 hover:bg-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-black/20 sm:left-4 sm:h-11 sm:w-11 dark:bg-neutral-900/80 dark:text-white dark:hover:bg-neutral-900"
>
<svg
viewBox="0 0 24 24"
fill="none"
className="h-5 w-5"
aria-hidden="true"
>
<path
d="M15 18l-6-6 6-6"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
<button
type="button"
onClick={next}
aria-label="Next photograph"
className="absolute right-3 top-1/2 z-10 flex h-10 w-10 -translate-y-1/2 items-center justify-center rounded-full bg-white/85 text-slate-800 shadow-lg backdrop-blur-md transition duration-200 hover:scale-105 hover:bg-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-black/20 sm:right-4 sm:h-11 sm:w-11 dark:bg-neutral-900/80 dark:text-white dark:hover:bg-neutral-900"
>
<svg
viewBox="0 0 24 24"
fill="none"
className="h-5 w-5"
aria-hidden="true"
>
<path
d="M9 6l6 6-6 6"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
{/* Autoplay progress bar */}
<div className="absolute inset-x-0 top-0 z-10 h-0.5 bg-white/15">
{!paused && isHydrated ? (
<div
key={active}
className={`${anim}_bar h-full w-full bg-indigo-400`}
/>
) : null}
</div>
</div>
{/* Thumbnail strip */}
<div
ref={thumbStripRef}
role="tablist"
aria-label="Choose a photograph"
className="mt-4 flex gap-2.5 overflow-x-auto pb-2 sm:mt-5 sm:gap-3 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
>
{SLIDES.map((s, i) => {
const isActive = i === active;
return (
<button
key={s.src}
data-thumb={i}
type="button"
role="tab"
aria-selected={isActive}
aria-label={`Show ${s.title}`}
onClick={() => goTo(i)}
className={`relative aspect-[4/3] h-16 flex-none overflow-hidden rounded-lg outline-none ring-offset-2 transition duration-300 focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-white sm:h-20 dark:ring-offset-neutral-950 ${
isActive
? "ring-2 ring-indigo-500 dark:ring-indigo-400"
: "opacity-60 ring-1 ring-slate-900/10 hover:opacity-100 dark:ring-white/10"
}`}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={s.src}
alt={s.alt}
loading="lazy"
draggable={false}
className="h-full w-full object-cover"
/>
{isActive ? (
<span className="pointer-events-none absolute inset-0 overflow-hidden">
<span
className={`${anim}_sheen absolute inset-y-0 -left-1/3 w-1/3 -skew-x-12 bg-gradient-to-r from-transparent via-white/40 to-transparent`}
/>
</span>
) : (
<span className="pointer-events-none absolute inset-0 bg-slate-900/20 dark:bg-black/30" />
)}
</button>
);
})}
</div>
{/* Dots (compact, keyboard-reachable summary) */}
<div className="mt-5 flex items-center justify-center gap-1.5">
{SLIDES.map((s, i) => (
<button
key={`dot-${s.src}`}
type="button"
aria-label={`Go to slide ${i + 1}`}
aria-current={i === active}
onClick={() => goTo(i)}
className={`h-1.5 rounded-full transition-all duration-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1 focus-visible:ring-offset-white dark:focus-visible:ring-offset-neutral-950 ${
i === active
? "w-6 bg-indigo-500 dark:bg-indigo-400"
: "w-1.5 bg-slate-300 hover:bg-slate-400 dark:bg-neutral-700 dark:hover:bg-neutral-600"
}`}
/>
))}
</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.

