Slideshow Fade Gallery
Original · freeAn auto-advancing crossfade slideshow with a caption and progress dots.
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-slideshow-fade.json"use client";
import { useCallback, useEffect, useRef, useState } from "react";
type Slide = {
src: string;
alt: string;
title: string;
category: string;
caption: string;
location: string;
};
const SLIDES: Slide[] = [
{
src: "/img/gallery/g04.webp",
alt: "Sunrise breaking over a fog-filled alpine valley with layered ridgelines",
title: "First Light on the Ridge",
category: "Landscape",
caption: "The fog burned off in under ten minutes — I shot forty frames and kept this one.",
location: "Dolomites, Italy",
},
{
src: "/img/gallery/g11.webp",
alt: "Neon-lit rain-slicked street in a dense night city with reflections on the pavement",
title: "After the Rain",
category: "Street",
caption: "Every puddle turned into a second skyline once the signs flickered on.",
location: "Shinjuku, Tokyo",
},
{
src: "/img/gallery/g19.webp",
alt: "Portrait of an elderly craftsman with weathered hands holding a hand-thrown clay bowl",
title: "Fifty Years at the Wheel",
category: "Portrait",
caption: "He shaped the bowl in ninety seconds and told me it was still too fast.",
location: "Bat Trang, Vietnam",
},
{
src: "/img/gallery/g23.webp",
alt: "Minimalist desert dune curve in warm golden light with a single ripple line",
title: "One Clean Line",
category: "Landscape",
caption: "No footprints, no wind — just the dune holding its shape before noon.",
location: "Erg Chebbi, Morocco",
},
{
src: "/img/gallery/g07.webp",
alt: "Close-up of dew-covered fern fronds unfurling in soft green forest light",
title: "Unfurling",
category: "Macro",
caption: "Shot at f/2.8 an hour after dawn, before the drops evaporated.",
location: "Olympic Forest, USA",
},
{
src: "/img/gallery/g30.webp",
alt: "Aerial view of turquoise coastline meeting terracotta cliffs and a lone boat",
title: "The Only Boat Out",
category: "Aerial",
caption: "Flew the drone to 120 meters and waited for the boat to hit the light.",
location: "Amalfi Coast, Italy",
},
{
src: "/img/gallery/g15.webp",
alt: "Architectural detail of a spiral concrete staircase seen from directly below",
title: "Concrete Spiral",
category: "Architecture",
caption: "Lay flat on the floor for this one — the symmetry only works from dead center.",
location: "Vatican Museums, Rome",
},
{
src: "/img/gallery/g27.webp",
alt: "Silhouetted lone tree on a hilltop against a deep violet dusk sky with stars emerging",
title: "Between Two Skies",
category: "Landscape",
caption: "The blue hour lasts about twelve minutes here — this was minute nine.",
location: "Tuscany, Italy",
},
{
src: "/img/gallery/g33.webp",
alt: "Vibrant spice market stall stacked with pyramids of red, ochre and gold powders",
title: "Pyramids of Color",
category: "Travel",
caption: "The vendor rebuilds these cones every morning by hand before the crowds.",
location: "Marrakech, Morocco",
},
];
const SLIDE_DURATION = 5200;
export default function GallerySlideshowFade() {
const [active, setActive] = useState(0);
const [paused, setPaused] = useState(false);
const [progress, setProgress] = useState(0);
const rafRef = useRef<number | null>(null);
const startRef = useRef<number | null>(null);
const liveRef = useRef<HTMLDivElement | null>(null);
const count = SLIDES.length;
const goTo = useCallback((index: number) => {
setActive(((index % count) + count) % count);
setProgress(0);
startRef.current = null;
}, [count]);
const next = useCallback(() => goTo(active + 1), [active, goTo]);
const prev = useCallback(() => goTo(active - 1), [active, goTo]);
useEffect(() => {
if (paused) {
if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);
startRef.current = null;
return;
}
const tick = (now: number) => {
if (startRef.current === null) startRef.current = now;
const elapsed = now - startRef.current;
const ratio = Math.min(elapsed / SLIDE_DURATION, 1);
setProgress(ratio);
if (ratio >= 1) {
startRef.current = null;
setProgress(0);
setActive((a) => (a + 1) % count);
} else {
rafRef.current = requestAnimationFrame(tick);
}
};
rafRef.current = requestAnimationFrame(tick);
return () => {
if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);
};
}, [active, paused, count]);
const onKeyDown = (e: React.KeyboardEvent) => {
if (e.target !== e.currentTarget) return;
if (e.key === "ArrowRight") {
e.preventDefault();
next();
} else if (e.key === "ArrowLeft") {
e.preventDefault();
prev();
} else if (e.key === " " || e.key === "Enter") {
e.preventDefault();
setPaused((p) => !p);
}
};
const current = SLIDES[active];
return (
<section className="relative w-full overflow-hidden bg-neutral-50 px-4 py-20 sm:px-6 lg:px-8 dark:bg-neutral-950">
<style>{`
@keyframes gsf-fade-in {
from { opacity: 0; transform: scale(1.06); }
to { opacity: 1; transform: scale(1); }
}
@keyframes gsf-rise {
from { opacity: 0; transform: translateY(14px); }
to { opacity: 1; transform: translateY(0); }
}
.gsf-slide-active {
animation: gsf-fade-in 1200ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
.gsf-meta-rise {
animation: gsf-rise 700ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
@media (prefers-reduced-motion: reduce) {
.gsf-slide-active,
.gsf-meta-rise {
animation: none !important;
}
}
`}</style>
{/* ambient glow */}
<div
aria-hidden
className="pointer-events-none absolute -top-40 left-1/2 h-96 w-[42rem] -translate-x-1/2 rounded-full bg-indigo-300/30 blur-3xl dark:bg-indigo-600/15"
/>
<div className="relative mx-auto max-w-6xl">
<header className="mx-auto mb-10 max-w-2xl text-center">
<span className="inline-flex items-center gap-2 rounded-full border border-neutral-200 bg-white/70 px-3 py-1 text-xs font-medium tracking-wide text-indigo-600 uppercase backdrop-blur dark:border-neutral-800 dark:bg-neutral-900/60 dark:text-indigo-400">
<span className="h-1.5 w-1.5 rounded-full bg-indigo-500" />
Field Notes
</span>
<h2 className="mt-4 text-3xl font-semibold tracking-tight text-neutral-900 sm:text-4xl dark:text-neutral-50">
A Year in Frames
</h2>
<p className="mt-3 text-base text-neutral-600 dark:text-neutral-400">
Nine stops from the road — landscapes, faces and the odd concrete spiral.
</p>
</header>
<div
role="group"
aria-roledescription="carousel"
aria-label="Photo slideshow"
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-neutral-200 bg-neutral-900 shadow-2xl shadow-neutral-900/10 ring-1 ring-black/5 focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-50 dark:border-neutral-800 dark:shadow-black/40 dark:focus-visible:ring-offset-neutral-950"
>
<div className="relative aspect-[16/10] w-full sm:aspect-[16/9]">
{SLIDES.map((slide, i) => {
const isActive = i === active;
return (
<figure
key={slide.src}
aria-hidden={!isActive}
aria-roledescription="slide"
aria-label={`${i + 1} of ${count}: ${slide.title}`}
className={`absolute inset-0 transition-opacity duration-1000 ease-out ${
isActive ? "z-10 opacity-100" : "z-0 opacity-0"
}`}
>
{/* 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 ${isActive ? "gsf-slide-active" : ""}`}
/>
<div className="absolute inset-0 bg-gradient-to-t from-neutral-950/85 via-neutral-950/25 to-transparent" />
</figure>
);
})}
{/* caption */}
<div className="absolute inset-x-0 bottom-0 z-20 p-6 sm:p-9">
<div key={active} className="gsf-meta-rise max-w-xl">
<div className="flex items-center gap-3 text-xs font-medium tracking-wide text-white/80 uppercase">
<span className="rounded-full bg-white/15 px-2.5 py-1 backdrop-blur-sm">
{current.category}
</span>
<span className="flex items-center gap-1.5 text-white/70">
<svg viewBox="0 0 24 24" fill="none" className="h-3.5 w-3.5" aria-hidden>
<path
d="M12 21s7-6.3 7-11a7 7 0 1 0-14 0c0 4.7 7 11 7 11Z"
stroke="currentColor"
strokeWidth="1.6"
/>
<circle cx="12" cy="10" r="2.5" stroke="currentColor" strokeWidth="1.6" />
</svg>
{current.location}
</span>
</div>
<h3 className="mt-3 text-2xl font-semibold tracking-tight text-white sm:text-3xl">
{current.title}
</h3>
<p className="mt-2 max-w-md text-sm leading-relaxed text-white/75 sm:text-base">
{current.caption}
</p>
</div>
</div>
{/* prev / next */}
<button
type="button"
onClick={prev}
aria-label="Previous photo"
className="absolute top-1/2 left-3 z-30 flex h-11 w-11 -translate-y-1/2 items-center justify-center rounded-full bg-white/10 text-white opacity-0 backdrop-blur-md transition hover:bg-white/25 focus:opacity-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-white group-hover:opacity-100 sm:left-5"
>
<svg viewBox="0 0 24 24" fill="none" className="h-5 w-5" aria-hidden>
<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 photo"
className="absolute top-1/2 right-3 z-30 flex h-11 w-11 -translate-y-1/2 items-center justify-center rounded-full bg-white/10 text-white opacity-0 backdrop-blur-md transition hover:bg-white/25 focus:opacity-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-white group-hover:opacity-100 sm:right-5"
>
<svg viewBox="0 0 24 24" fill="none" className="h-5 w-5" aria-hidden>
<path d="M9 6l6 6-6 6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
{/* pause / play */}
<button
type="button"
onClick={() => setPaused((p) => !p)}
aria-label={paused ? "Play slideshow" : "Pause slideshow"}
aria-pressed={paused}
className="absolute top-4 right-4 z-30 flex h-9 w-9 items-center justify-center rounded-full bg-white/10 text-white backdrop-blur-md transition hover:bg-white/25 focus:outline-none focus-visible:ring-2 focus-visible:ring-white sm:top-5 sm:right-5"
>
{paused ? (
<svg viewBox="0 0 24 24" fill="currentColor" className="h-4 w-4" aria-hidden>
<path d="M8 5v14l11-7z" />
</svg>
) : (
<svg viewBox="0 0 24 24" fill="currentColor" className="h-4 w-4" aria-hidden>
<path d="M7 5h3v14H7zM14 5h3v14h-3z" />
</svg>
)}
</button>
</div>
{/* progress dots */}
<div
role="tablist"
aria-label="Choose a photo"
className="flex items-center justify-center gap-2.5 bg-neutral-950 px-4 py-4"
>
{SLIDES.map((slide, i) => {
const isActive = i === active;
return (
<button
key={slide.src}
type="button"
role="tab"
aria-selected={isActive}
aria-label={`Go to ${slide.title}`}
onClick={() => goTo(i)}
className="group/dot relative h-2.5 rounded-full focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400 focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-950"
style={{ width: isActive ? 44 : 10 }}
>
<span className="absolute inset-0 rounded-full bg-white/20 transition group-hover/dot:bg-white/35" />
{isActive && (
<span
className="absolute inset-y-0 left-0 rounded-full bg-indigo-400"
style={{ width: `${Math.max(progress * 100, 8)}%` }}
/>
)}
</button>
);
})}
</div>
</div>
{/* footer counter */}
<div className="mx-auto mt-6 flex max-w-6xl items-center justify-between px-1 text-sm text-neutral-500 dark:text-neutral-500">
<span className="tabular-nums">
<span className="font-semibold text-neutral-800 dark:text-neutral-200">
{String(active + 1).padStart(2, "0")}
</span>{" "}
/ {String(count).padStart(2, "0")}
</span>
<span aria-live="polite" ref={liveRef} className="truncate pl-4 text-right">
{current.title} — {current.location}
</span>
</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.

