Carousel Gallery
Original · freeAn image carousel with arrows and dots, smooth slide transitions and autoplay that pauses on hover.
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.json"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { AnimatePresence, motion } from "motion/react";
type Slide = {
src: string;
title: string;
category: string;
caption: string;
};
const SLIDES: Slide[] = [
{
src: "/img/gallery/g03.webp",
title: "Coastal Light",
category: "Landscape",
caption: "First sun breaking over the northern headland.",
},
{
src: "/img/gallery/g11.webp",
title: "Studio 04",
category: "Portrait",
caption: "Soft key light, one reflector, no retouching.",
},
{
src: "/img/gallery/g18.webp",
title: "Quiet Verticals",
category: "Architecture",
caption: "Concrete and glass in the late afternoon.",
},
{
src: "/img/gallery/g22.webp",
title: "Understory",
category: "Nature",
caption: "Fern detail from the valley floor, shot at f/2.8.",
},
{
src: "/img/gallery/g07.webp",
title: "Market Hour",
category: "Street",
caption: "Movement and stillness on a Tuesday morning.",
},
{
src: "/img/gallery/g29.webp",
title: "Salt & Slate",
category: "Still Life",
caption: "Found textures arranged on a windowsill.",
},
{
src: "/img/gallery/g14.webp",
title: "High Country",
category: "Landscape",
caption: "Ridge line fading into weather at dusk.",
},
];
const AUTOPLAY_MS = 5000;
export default function GalleryCarousel() {
const [index, setIndex] = useState(0);
const [direction, setDirection] = useState(1);
const [paused, setPaused] = useState(false);
const regionRef = useRef<HTMLDivElement>(null);
const count = SLIDES.length;
const goTo = useCallback(
(next: number, dir: number) => {
setDirection(dir);
setIndex(((next % count) + count) % count);
},
[count],
);
const prev = useCallback(() => goTo(index - 1, -1), [goTo, index]);
const next = useCallback(() => goTo(index + 1, 1), [goTo, index]);
useEffect(() => {
if (paused) return;
const id = window.setTimeout(() => goTo(index + 1, 1), AUTOPLAY_MS);
return () => window.clearTimeout(id);
}, [index, paused, goTo]);
const onKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "ArrowLeft") {
event.preventDefault();
prev();
} else if (event.key === "ArrowRight") {
event.preventDefault();
next();
}
};
const active = SLIDES[index];
const variants = {
enter: (dir: number) => ({
x: dir > 0 ? "100%" : "-100%",
opacity: 0,
scale: 1.04,
}),
center: { x: "0%", opacity: 1, scale: 1 },
exit: (dir: number) => ({
x: dir > 0 ? "-100%" : "100%",
opacity: 0,
scale: 1.04,
}),
};
return (
<section className="relative w-full bg-gradient-to-b from-slate-50 to-white px-4 py-20 sm:px-6 lg:py-28 dark:from-neutral-950 dark:to-black">
<style>{`
@keyframes gcar-progress {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
@media (prefers-reduced-motion: reduce) {
.gcar-progress-bar { animation: none !important; transform: scaleX(1); }
}
`}</style>
<div className="mx-auto max-w-5xl">
<div className="mb-8 flex flex-col gap-3 sm:mb-10 sm:flex-row sm:items-end sm:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-indigo-600 dark:text-indigo-400">
Selected Work
</p>
<h2 className="mt-2 text-3xl font-semibold tracking-tight text-slate-900 sm:text-4xl dark:text-white">
Field Notebook
</h2>
</div>
<p className="max-w-sm text-sm leading-relaxed text-slate-500 dark:text-neutral-400">
A rotating set of frames from the archive. Hover to pause, use the
arrows, dots, or your keyboard to move.
</p>
</div>
<div
ref={regionRef}
role="group"
aria-roledescription="carousel"
aria-label="Photo gallery carousel"
tabIndex={0}
onKeyDown={onKeyDown}
onMouseEnter={() => setPaused(true)}
onMouseLeave={() => setPaused(false)}
onFocus={() => setPaused(true)}
onBlur={() => setPaused(false)}
className="group relative overflow-hidden rounded-3xl border border-slate-200/80 bg-slate-100 shadow-[0_20px_60px_-20px_rgba(15,23,42,0.35)] outline-none ring-indigo-500/60 focus-visible:ring-2 dark:border-white/10 dark:bg-neutral-900 dark:shadow-[0_20px_60px_-20px_rgba(0,0,0,0.8)]"
>
<div className="relative aspect-[16/10] w-full sm:aspect-[16/9]">
<AnimatePresence initial={false} custom={direction}>
<motion.div
key={index}
custom={direction}
variants={variants}
initial="enter"
animate="center"
exit="exit"
transition={{
x: { type: "spring", stiffness: 260, damping: 32 },
opacity: { duration: 0.35 },
scale: { duration: 0.5, ease: "easeOut" },
}}
className="absolute inset-0"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={active.src}
alt={`${active.title} — ${active.caption}`}
loading="lazy"
draggable={false}
className="h-full w-full select-none object-cover"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-black/10 to-transparent" />
</motion.div>
</AnimatePresence>
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-10 p-5 sm:p-7">
<AnimatePresence mode="wait">
<motion.div
key={active.title}
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
transition={{ duration: 0.4, ease: "easeOut" }}
>
<span className="inline-flex items-center rounded-full bg-white/15 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.15em] text-white/90 backdrop-blur-md ring-1 ring-white/20">
{active.category}
</span>
<h3 className="mt-3 text-2xl font-semibold text-white drop-shadow-sm sm:text-3xl">
{active.title}
</h3>
<p className="mt-1 max-w-md text-sm text-white/80">
{active.caption}
</p>
</motion.div>
</AnimatePresence>
</div>
<button
type="button"
onClick={prev}
aria-label="Previous image"
className="absolute left-3 top-1/2 z-20 -translate-y-1/2 rounded-full bg-white/80 p-2.5 text-slate-800 shadow-lg backdrop-blur-md transition hover:scale-105 hover:bg-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 active:scale-95 sm:opacity-0 sm:group-hover:opacity-100 dark:bg-neutral-900/70 dark:text-white dark:hover:bg-neutral-900"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
className="h-5 w-5"
aria-hidden="true"
>
<path d="m15 18-6-6 6-6" />
</svg>
</button>
<button
type="button"
onClick={next}
aria-label="Next image"
className="absolute right-3 top-1/2 z-20 -translate-y-1/2 rounded-full bg-white/80 p-2.5 text-slate-800 shadow-lg backdrop-blur-md transition hover:scale-105 hover:bg-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 active:scale-95 sm:opacity-0 sm:group-hover:opacity-100 dark:bg-neutral-900/70 dark:text-white dark:hover:bg-neutral-900"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
className="h-5 w-5"
aria-hidden="true"
>
<path d="m9 18 6-6-6-6" />
</svg>
</button>
<div className="absolute inset-x-0 top-0 z-20 h-1 bg-white/10">
{!paused && (
<div
key={index}
className="gcar-progress-bar h-full origin-left bg-indigo-400"
style={{
animation: `gcar-progress ${AUTOPLAY_MS}ms linear forwards`,
}}
/>
)}
</div>
</div>
<div className="flex items-center justify-between gap-4 border-t border-slate-200/80 bg-white/70 px-5 py-4 backdrop-blur-sm dark:border-white/10 dark:bg-neutral-900/60">
<div
className="flex items-center gap-2"
role="tablist"
aria-label="Choose image"
>
{SLIDES.map((slide, i) => {
const activeDot = i === index;
return (
<button
key={slide.src}
type="button"
role="tab"
aria-selected={activeDot}
aria-label={`Go to ${slide.title}`}
onClick={() => goTo(i, i > index ? 1 : -1)}
className={`h-2.5 rounded-full transition-all duration-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 ${
activeDot
? "w-7 bg-indigo-600 dark:bg-indigo-400"
: "w-2.5 bg-slate-300 hover:bg-slate-400 dark:bg-neutral-700 dark:hover:bg-neutral-600"
}`}
/>
);
})}
</div>
<span
className="text-sm font-medium tabular-nums text-slate-500 dark:text-neutral-400"
aria-live="polite"
>
{String(index + 1).padStart(2, "0")}
<span className="mx-1 text-slate-300 dark:text-neutral-600">
/
</span>
{String(count).padStart(2, "0")}
</span>
</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.

Duotone Hover Gallery
OriginalA grid where images sit under a coloured duotone wash that clears to full colour on hover.

Flip Cards Gallery
OriginalA grid of cards that flip in 3D on hover to reveal a caption and details on the back.

