Fade Carousel
Original · freecrossfade carousel
byWeb InnoventixReact + Tailwind
carfadecarousels
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/car-fade.jsoncar-fade.tsx
"use client";
import { useCallback, useEffect, useState } from "react";
import type { FocusEvent, KeyboardEvent } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
type Slide = {
id: string;
img: string;
kicker: string;
title: string;
location: string;
description: string;
};
const SLIDES: Slide[] = [
{
id: "amalfi",
img: "/img/gallery/g03.webp",
kicker: "Coastal · Nº 01",
title: "Amalfi Before the Crowds",
location: "Praiano, Italy",
description:
"Lemon terraces spill toward a harbor that hasn't woken yet — the softest hour on the whole coast, gone by nine.",
},
{
id: "patagonia",
img: "/img/gallery/g09.webp",
kicker: "Highlands · Nº 02",
title: "The Long Light of Torres",
location: "Patagonia, Chile",
description:
"Granite spires catch the first sun while the valley floor holds onto night for one more stubborn minute.",
},
{
id: "vestrahorn",
img: "/img/gallery/g14.webp",
kicker: "Nightfall · Nº 03",
title: "Aurora over Vestrahorn",
location: "Stokksnes, Iceland",
description:
"Black volcanic sand mirrors a sky that refuses to sit still. Tripods stay planted until the last green ribbon fades.",
},
{
id: "arashiyama",
img: "/img/gallery/g21.webp",
kicker: "Groves · Nº 04",
title: "A Green Corridor in Arashiyama",
location: "Kyoto, Japan",
description:
"Fifty feet of bamboo overhead turns a plain midday into a cathedral of filtered, moving green.",
},
{
id: "erg-chebbi",
img: "/img/gallery/g27.webp",
kicker: "Desert · Nº 05",
title: "Dunes at Erg Chebbi",
location: "Merzouga, Morocco",
description:
"The wind redraws every ridge line by afternoon. By the time camels head home, the sand runs pure copper.",
},
{
id: "oia",
img: "/img/gallery/g33.webp",
kicker: "Islands · Nº 06",
title: "Blue Hour in Oia",
location: "Santorini, Greece",
description:
"The whitewashed lanes empty out and the caldera turns the exact color of the sea a thousand feet below.",
},
];
const AUTOPLAY_MS = 5200;
const EASE = [0.4, 0, 0.2, 1] as const;
const CSS = `
@keyframes cxf-ken { from { transform: scale(1.04); } to { transform: scale(1.16); } }
@keyframes cxf-bar { from { transform: scaleX(0); } to { transform: scaleX(1); } }
@keyframes cxf-pulse { 0%, 100% { opacity: .35; } 50% { opacity: 1; } }
.cxf-ken { animation: cxf-ken 9s ease-out forwards; }
.cxf-bar { animation-name: cxf-bar; animation-timing-function: linear; animation-fill-mode: forwards; will-change: transform; }
.cxf-pulse { animation: cxf-pulse 1.8s ease-in-out infinite; }
@media (prefers-reduced-motion: reduce) {
.cxf-ken, .cxf-bar, .cxf-pulse { animation: none !important; }
.cxf-bar { transform: scaleX(1); }
}
`;
const pad = (n: number) => String(n).padStart(2, "0");
const FOCUS =
"focus:outline-none focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-400";
export default function CrossfadeCarousel() {
const prefersReduced = useReducedMotion();
const animate = !prefersReduced;
const [index, setIndex] = useState(0);
const [isPlaying, setIsPlaying] = useState(true);
const [hovered, setHovered] = useState(false);
const [focused, setFocused] = useState(false);
const len = SLIDES.length;
const active = SLIDES[index];
const paused = hovered || focused;
const running = isPlaying && !paused && animate;
const go = useCallback(
(dir: number) => setIndex((i) => (i + dir + len) % len),
[len],
);
useEffect(() => {
if (!running) return;
const id = window.setTimeout(
() => setIndex((i) => (i + 1) % len),
AUTOPLAY_MS,
);
return () => window.clearTimeout(id);
}, [running, index, len]);
const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
if (e.key === "ArrowRight") {
e.preventDefault();
go(1);
} else if (e.key === "ArrowLeft") {
e.preventDefault();
go(-1);
} else if (e.key === "Home") {
e.preventDefault();
setIndex(0);
} else if (e.key === "End") {
e.preventDefault();
setIndex(len - 1);
}
};
const onBlurCapture = (e: FocusEvent<HTMLDivElement>) => {
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) {
setFocused(false);
}
};
return (
<section className="relative w-full overflow-hidden bg-gradient-to-b from-slate-50 to-slate-100 py-16 dark:from-zinc-950 dark:to-zinc-900 sm:py-24">
<style>{CSS}</style>
<div className="mx-auto w-full max-w-5xl px-4 sm:px-6">
<header className="mb-8 max-w-2xl sm:mb-10">
<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-semibold uppercase tracking-[0.18em] text-slate-600 dark:border-zinc-700 dark:bg-zinc-800/60 dark:text-zinc-300">
Field Notes · 2025
</span>
<h2 className="mt-4 text-3xl font-semibold tracking-tight text-slate-900 dark:text-zinc-50 sm:text-4xl">
Six mornings worth the early alarm
</h2>
<p className="mt-3 text-base text-slate-600 dark:text-zinc-400">
A crossfade reel from a year spent chasing first light. Hover to
pause, then wander with the arrows, arrow keys, or the thumbnails
below.
</p>
</header>
<div
role="group"
aria-roledescription="carousel"
aria-label="Travel photography highlights"
onKeyDown={onKeyDown}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
onFocusCapture={() => setFocused(true)}
onBlurCapture={onBlurCapture}
>
<div className="relative aspect-[16/10] w-full overflow-hidden rounded-3xl bg-slate-200 shadow-2xl shadow-slate-900/20 ring-1 ring-black/10 dark:bg-zinc-800 dark:ring-white/10">
{/* crossfading image layer */}
<AnimatePresence>
<motion.div
key={active.id}
className="absolute inset-0"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: animate ? 0.9 : 0, ease: EASE }}
>
<div className="absolute inset-0 overflow-hidden">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={active.img}
alt={`${active.title} — ${active.location}`}
loading="lazy"
draggable={false}
className={`h-full w-full object-cover ${animate ? "cxf-ken" : ""}`}
/>
</div>
<div
className="absolute inset-0 bg-gradient-to-t from-black/85 via-black/25 to-black/10"
aria-hidden="true"
/>
</motion.div>
</AnimatePresence>
{/* top bar: status + play/pause + counter */}
<div className="absolute inset-x-0 top-0 z-10 flex items-center justify-between p-4 sm:p-5">
<span className="inline-flex items-center gap-2 rounded-full bg-black/30 px-3 py-1 text-xs font-medium text-white ring-1 ring-white/15 backdrop-blur">
<span
className={`h-1.5 w-1.5 rounded-full bg-emerald-400 ${running ? "cxf-pulse" : ""}`}
aria-hidden="true"
/>
{running ? "Playing" : "Paused"}
</span>
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => setIsPlaying((p) => !p)}
aria-pressed={isPlaying}
aria-label={isPlaying ? "Pause slideshow" : "Play slideshow"}
className={`grid h-9 w-9 place-items-center rounded-full bg-black/30 text-white ring-1 ring-white/20 backdrop-blur transition hover:bg-black/50 ${FOCUS}`}
>
{isPlaying ? (
<svg
viewBox="0 0 24 24"
fill="currentColor"
className="h-4 w-4"
aria-hidden="true"
>
<rect x="6" y="5" width="4" height="14" rx="1" />
<rect x="14" y="5" width="4" height="14" rx="1" />
</svg>
) : (
<svg
viewBox="0 0 24 24"
fill="currentColor"
className="h-4 w-4"
aria-hidden="true"
>
<path d="M8 5v14l11-7z" />
</svg>
)}
</button>
<span className="select-none rounded-full bg-black/30 px-3 py-1 text-xs font-semibold tabular-nums text-white ring-1 ring-white/15 backdrop-blur">
{pad(index + 1)} <span className="text-white/50">/</span>{" "}
{pad(len)}
</span>
</div>
</div>
{/* prev / next arrows */}
<button
type="button"
onClick={() => go(-1)}
aria-label="Previous photo"
className={`absolute left-3 top-1/2 z-10 grid h-10 w-10 -translate-y-1/2 place-items-center rounded-full bg-black/30 text-white ring-1 ring-white/20 backdrop-blur transition hover:bg-black/50 sm:left-4 sm:h-11 sm:w-11 ${FOCUS}`}
>
<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={() => go(1)}
aria-label="Next photo"
className={`absolute right-3 top-1/2 z-10 grid h-10 w-10 -translate-y-1/2 place-items-center rounded-full bg-black/30 text-white ring-1 ring-white/20 backdrop-blur transition hover:bg-black/50 sm:right-4 sm:h-11 sm:w-11 ${FOCUS}`}
>
<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>
{/* caption */}
<div className="absolute inset-x-0 bottom-0 z-10 p-5 sm:p-8">
<AnimatePresence mode="wait">
<motion.div
key={active.id}
initial={{ opacity: 0, y: animate ? 16 : 0 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
transition={{
duration: animate ? 0.5 : 0,
ease: EASE,
delay: animate ? 0.12 : 0,
}}
>
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-white/70">
{active.kicker}
</p>
<h3 className="mt-2 text-2xl font-semibold text-white drop-shadow sm:text-3xl">
{active.title}
</h3>
<p className="mt-1.5 flex items-center gap-1.5 text-sm text-white/85">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
className="h-4 w-4 shrink-0 text-rose-300"
aria-hidden="true"
>
<path d="M12 21s7-5.6 7-11a7 7 0 1 0-14 0c0 5.4 7 11 7 11z" />
<circle cx="12" cy="10" r="2.5" />
</svg>
{active.location}
</p>
<p className="mt-3 max-w-xl text-sm leading-relaxed text-white/80 sm:text-base">
{active.description}
</p>
</motion.div>
</AnimatePresence>
</div>
{/* autoplay progress */}
<div className="absolute inset-x-0 bottom-0 z-20 h-1 bg-white/20">
<div
key={`${index}-${isPlaying}`}
className={`h-full origin-left bg-white ${animate ? "cxf-bar" : "scale-x-100"}`}
style={
animate
? {
animationDuration: `${AUTOPLAY_MS}ms`,
animationPlayState: running ? "running" : "paused",
}
: undefined
}
/>
</div>
</div>
{/* thumbnail navigation */}
<div
role="group"
aria-label="Choose a photo"
className="mt-4 flex gap-2 overflow-x-auto pb-1 sm:gap-3"
>
{SLIDES.map((s, i) => {
const current = i === index;
return (
<button
key={s.id}
type="button"
onClick={() => setIndex(i)}
aria-label={`Show ${s.title}, ${s.location}`}
aria-current={current}
className={`relative h-14 w-20 shrink-0 overflow-hidden rounded-xl transition sm:h-16 sm:w-24 ${FOCUS} ${
current
? "opacity-100 ring-2 ring-indigo-500 dark:ring-indigo-400"
: "opacity-55 ring-1 ring-black/10 hover:opacity-100 dark:ring-white/10"
}`}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={s.img}
alt=""
loading="lazy"
draggable={false}
className="h-full w-full object-cover"
/>
</button>
);
})}
</div>
</div>
{/* screen-reader live announcement */}
<div aria-live="polite" className="sr-only">
{`Slide ${index + 1} of ${len}: ${active.title}, ${active.location}`}
</div>
</div>
</section>
);
}Dependencies
motion
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 →
Basic Carousel
Originalbasic image carousel with arrows and dots

Cards Carousel
Originalpeeking card carousel

Coverflow Carousel
Original3D coverflow carousel
Thumbnails Carousel
Originalcarousel synced with thumbnails

Autoplay Carousel
Originalautoplay carousel with progress and pause

Dots Carousel
Originalcarousel with animated dot indicators

Vertical Carousel
Originalvertical carousel

Multi Carousel
Originalmulti-item responsive carousel

Center Focus Carousel
Originalcentre-focused scaling carousel

Progress Carousel
Originalcarousel with a progress bar

Testimonial Carousel
Originaltestimonial quote carousel

Logos Carousel
Originallogo carousel strip

