Coverflow Carousel
Original · free3D coverflow carousel
byWeb InnoventixReact + Tailwind
carcoverflowcarousels
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-coverflow.jsoncar-coverflow.tsx
"use client";
import { useEffect, useRef, useState } from "react";
import { motion, useReducedMotion } from "motion/react";
type Tone = "indigo" | "violet" | "emerald" | "rose" | "amber" | "sky";
interface Series {
id: string;
img: string;
title: string;
location: string;
year: string;
frames: string;
blurb: string;
tone: Tone;
}
const SERIES: Series[] = [
{
id: "northern-silence",
img: "g01.webp",
title: "Northern Silence",
location: "Lofoten, Norway",
year: "2024",
frames: "38 frames",
blurb:
"Twelve nights above the Arctic Circle chasing a blue hour that never fully closes. Shot on a hand-wound medium-format body at minus eighteen degrees.",
tone: "sky",
},
{
id: "concrete-bloom",
img: "g02.webp",
title: "Concrete Bloom",
location: "Osaka, Japan",
year: "2023",
frames: "26 frames",
blurb:
"Brutalist housing blocks softened by the plants their residents refuse to give up. A quiet study in how people domesticate raw concrete.",
tone: "emerald",
},
{
id: "salt-and-static",
img: "g03.webp",
title: "Salt & Static",
location: "Atacama, Chile",
year: "2024",
frames: "31 frames",
blurb:
"The driest desert on earth, photographed the one week an unseasonal fog rolled in. For about forty minutes the salt flats turned to mirrors.",
tone: "amber",
},
{
id: "midnight-market",
img: "g04.webp",
title: "Midnight Market",
location: "Marrakech, Morocco",
year: "2022",
frames: "44 frames",
blurb:
"Jemaa el-Fnaa after the tour buses leave, lit only by gas lamps and phone screens. Available light, no flash, a single fast prime.",
tone: "rose",
},
{
id: "tidal-drift",
img: "g05.webp",
title: "Tidal Drift",
location: "Cornwall, United Kingdom",
year: "2023",
frames: "22 frames",
blurb:
"A six-week residency tracking a single cove through the spring tides. Long exposures that flatten the sea into slow, moving weather.",
tone: "indigo",
},
{
id: "amber-transit",
img: "g06.webp",
title: "Amber Transit",
location: "Reykjavik, Iceland",
year: "2024",
frames: "29 frames",
blurb:
"Sodium streetlights against fresh snow, the last winter before the city swapped them for cold white LEDs. An accidental farewell.",
tone: "amber",
},
{
id: "paper-districts",
img: "g07.webp",
title: "Paper Districts",
location: "Lisbon, Portugal",
year: "2023",
frames: "35 frames",
blurb:
"Hand-painted shopfront signage across Alfama and Mouraria, some of it decades old. Documented the season before the next round of renovations.",
tone: "violet",
},
{
id: "low-country",
img: "g08.webp",
title: "Low Country",
location: "Mekong Delta, Vietnam",
year: "2022",
frames: "41 frames",
blurb:
"Life at water level from a wooden boat at dawn — floating markets, stilt houses, and the slow commerce that moves along the river.",
tone: "emerald",
},
];
const TONES: Record<Tone, { text: string; dot: string; ring: string; chip: string }> = {
indigo: {
text: "text-indigo-600 dark:text-indigo-300",
dot: "bg-indigo-500",
ring: "ring-indigo-400/70",
chip: "bg-indigo-500/10 text-indigo-700 ring-1 ring-inset ring-indigo-500/25 dark:text-indigo-300",
},
violet: {
text: "text-violet-600 dark:text-violet-300",
dot: "bg-violet-500",
ring: "ring-violet-400/70",
chip: "bg-violet-500/10 text-violet-700 ring-1 ring-inset ring-violet-500/25 dark:text-violet-300",
},
emerald: {
text: "text-emerald-600 dark:text-emerald-300",
dot: "bg-emerald-500",
ring: "ring-emerald-400/70",
chip: "bg-emerald-500/10 text-emerald-700 ring-1 ring-inset ring-emerald-500/25 dark:text-emerald-300",
},
rose: {
text: "text-rose-600 dark:text-rose-300",
dot: "bg-rose-500",
ring: "ring-rose-400/70",
chip: "bg-rose-500/10 text-rose-700 ring-1 ring-inset ring-rose-500/25 dark:text-rose-300",
},
amber: {
text: "text-amber-600 dark:text-amber-300",
dot: "bg-amber-500",
ring: "ring-amber-400/70",
chip: "bg-amber-500/10 text-amber-700 ring-1 ring-inset ring-amber-500/25 dark:text-amber-300",
},
sky: {
text: "text-sky-600 dark:text-sky-300",
dot: "bg-sky-500",
ring: "ring-sky-400/70",
chip: "bg-sky-500/10 text-sky-700 ring-1 ring-inset ring-sky-500/25 dark:text-sky-300",
},
};
function place(offset: number, cardW: number) {
const abs = Math.abs(offset);
const dir = Math.sign(offset);
const x = abs === 0 ? 0 : dir * (cardW * 0.6 + (abs - 1) * cardW * 0.34);
const rotateY = abs === 0 ? 0 : -dir * 43;
const z = abs === 0 ? 40 : -abs * 80;
const scale = abs === 0 ? 1 : Math.max(0.62, 0.84 - (abs - 1) * 0.07);
const opacity = abs > 3 ? 0 : abs === 0 ? 1 : Math.max(0.22, 1 - abs * 0.22);
return { x, rotateY, z, scale, opacity, visible: abs <= 3, zi: 100 - abs };
}
export default function CarCoverflow() {
const reduce = useReducedMotion();
const n = SERIES.length;
const [active, setActive] = useState(Math.floor(n / 2));
const stageRef = useRef<HTMLDivElement | null>(null);
const [stageW, setStageW] = useState(0);
const startX = useRef<number | null>(null);
const moved = useRef(false);
useEffect(() => {
const el = stageRef.current;
if (!el) return;
setStageW(el.clientWidth);
const ro = new ResizeObserver((entries) => {
for (const entry of entries) setStageW(entry.contentRect.width);
});
ro.observe(el);
return () => ro.disconnect();
}, []);
const go = (dir: number) => setActive((a) => Math.min(n - 1, Math.max(0, a + dir)));
const cardW = Math.round(Math.min(268, Math.max(172, stageW * 0.4)));
const cardH = Math.round(cardW * 1.3);
const reflectionH = Math.round(cardH * 0.34);
const stageHeight = cardH + reflectionH + 56;
const activeSeries = SERIES[active];
const tone = TONES[activeSeries.tone];
const count = `${String(active + 1).padStart(2, "0")} / ${String(n).padStart(2, "0")}`;
const springLike = "linear-gradient(to right, transparent, rgba(255,255,255,0.4), transparent)";
return (
<section className="relative w-full overflow-hidden bg-gradient-to-b from-slate-50 to-white py-20 dark:from-slate-950 dark:to-slate-900 sm:py-28">
<style>{`
@keyframes cvf-drift {
0%, 100% { transform: translate3d(0, 0, 0) scale(1); }
50% { transform: translate3d(0, -26px, 0) scale(1.08); }
}
@keyframes cvf-sheen {
0% { transform: translateX(-160%) skewX(-16deg); opacity: 0; }
45% { opacity: 0.55; }
60% { opacity: 0.55; }
100% { transform: translateX(260%) skewX(-16deg); opacity: 0; }
}
@keyframes cvf-pulse {
0%, 100% { opacity: 0.5; }
50% { opacity: 1; }
}
.cvf-orb { animation: cvf-drift 15s ease-in-out infinite; }
.cvf-orb-2 { animation: cvf-drift 19s ease-in-out infinite reverse; }
.cvf-sheen { animation: cvf-sheen 4.6s ease-in-out infinite; }
.cvf-live { animation: cvf-pulse 2.2s ease-in-out infinite; }
@media (prefers-reduced-motion: reduce) {
.cvf-orb, .cvf-orb-2, .cvf-sheen, .cvf-live { animation: none !important; }
}
`}</style>
<div aria-hidden="true" className="pointer-events-none absolute inset-0 overflow-hidden">
<div className="cvf-orb absolute -left-24 top-8 h-72 w-72 rounded-full bg-indigo-400/20 blur-3xl dark:bg-indigo-500/15" />
<div className="cvf-orb-2 absolute -right-16 bottom-0 h-80 w-80 rounded-full bg-sky-400/20 blur-3xl dark:bg-violet-500/15" />
</div>
<div className="relative mx-auto max-w-6xl px-4 sm:px-6">
<div className="mx-auto mb-10 max-w-2xl text-center sm:mb-12">
<span className="inline-flex items-center gap-2 rounded-full bg-slate-900/5 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500 ring-1 ring-inset ring-slate-900/10 dark:bg-white/5 dark:text-slate-400 dark:ring-white/10">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.6} strokeLinecap="round" className="h-3.5 w-3.5">
<rect x="7" y="4" width="10" height="16" rx="1.5" />
<path d="M4 7v10" />
<path d="M20 7v10" />
</svg>
Field Archive
</span>
<h2 className="mt-5 text-3xl font-semibold tracking-tight text-slate-900 dark:text-white sm:text-4xl">
A field archive, arranged in coverflow
</h2>
<p className="mt-3 text-base leading-relaxed text-slate-600 dark:text-slate-300">
Eight editorial series from four years on assignment. Drag, swipe, or use the arrow keys to move through the covers.
</p>
</div>
<div
role="region"
aria-roledescription="carousel"
aria-label="Editorial photo archive"
onKeyDown={(e) => {
if (e.key === "ArrowRight" || e.key === "ArrowDown") {
e.preventDefault();
go(1);
} else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
e.preventDefault();
go(-1);
} else if (e.key === "Home") {
e.preventDefault();
setActive(0);
} else if (e.key === "End") {
e.preventDefault();
setActive(n - 1);
}
}}
>
<div aria-live="polite" aria-atomic="true" className="sr-only">
{`Showing ${activeSeries.title}, ${activeSeries.location}, ${activeSeries.year}. Cover ${active + 1} of ${n}.`}
</div>
<div
ref={stageRef}
tabIndex={0}
role="group"
aria-roledescription="slides"
aria-label="Series covers. Use the left and right arrow keys to browse."
className="relative mx-auto w-full max-w-4xl select-none rounded-3xl outline-none focus-visible:ring-2 focus-visible:ring-indigo-500/70 focus-visible:ring-offset-4 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-950"
style={{ height: stageHeight, perspective: 1300, perspectiveOrigin: "50% 42%", touchAction: "pan-y" }}
onPointerDown={(e) => {
startX.current = e.clientX;
moved.current = false;
}}
onPointerMove={(e) => {
if (startX.current !== null && Math.abs(e.clientX - startX.current) > 8) moved.current = true;
}}
onPointerUp={(e) => {
if (startX.current !== null) {
const dx = e.clientX - startX.current;
if (moved.current && Math.abs(dx) > 44) go(dx < 0 ? 1 : -1);
}
startX.current = null;
}}
onPointerLeave={() => {
startX.current = null;
}}
>
{SERIES.map((s, i) => {
const p = place(i - active, cardW);
const isActive = i === active;
const st = TONES[s.tone];
return (
<div
key={s.id}
className="pointer-events-none absolute inset-0 flex items-center justify-center"
style={{ transformStyle: "preserve-3d" }}
>
<motion.button
type="button"
onClick={() => {
if (moved.current) return;
setActive(i);
}}
aria-label={`${s.title}, ${s.location}, ${s.year}. Cover ${i + 1} of ${n}`}
aria-current={isActive ? "true" : undefined}
aria-hidden={p.visible ? undefined : true}
tabIndex={p.visible ? 0 : -1}
className="relative flex flex-col items-center rounded-2xl outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-950"
style={{
width: cardW,
zIndex: p.zi,
transformStyle: "preserve-3d",
pointerEvents: p.visible ? "auto" : "none",
}}
animate={{ x: p.x, z: p.z, rotateY: p.rotateY, scale: p.scale, opacity: p.opacity }}
transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 210, damping: 26, mass: 0.9 }}
>
<div
className="relative overflow-hidden rounded-2xl bg-slate-200 shadow-2xl shadow-slate-950/40 ring-1 ring-slate-900/10 dark:bg-slate-800 dark:ring-white/10"
style={{ width: cardW, height: cardH }}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={`/img/gallery/${s.img}`}
alt={`${s.title} — ${s.location}, ${s.year}`}
loading="lazy"
draggable={false}
className="h-full w-full object-cover"
style={{ width: cardW, height: cardH }}
/>
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-slate-950/85 via-slate-950/10 to-transparent" />
<div className="pointer-events-none absolute inset-x-0 bottom-0 p-3 text-left">
<p className="truncate text-sm font-semibold text-white">{s.title}</p>
<p className="truncate text-[11px] text-slate-300">{s.location}</p>
</div>
{isActive && (
<div className={`pointer-events-none absolute inset-0 rounded-2xl ring-2 ${st.ring}`} />
)}
{isActive && !reduce && (
<div className="pointer-events-none absolute inset-0 overflow-hidden rounded-2xl">
<div
className="cvf-sheen absolute inset-y-0 -left-1/3 w-1/3"
style={{ backgroundImage: springLike }}
/>
</div>
)}
</div>
<div
aria-hidden="true"
className="mt-2 overflow-hidden"
style={{
width: cardW,
height: reflectionH,
WebkitMaskImage: "linear-gradient(to bottom, rgba(0,0,0,0.45), transparent 88%)",
maskImage: "linear-gradient(to bottom, rgba(0,0,0,0.45), transparent 88%)",
}}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={`/img/gallery/${s.img}`}
alt=""
loading="lazy"
draggable={false}
className="rounded-2xl object-cover"
style={{ width: cardW, height: cardH, transform: "scaleY(-1)" }}
/>
</div>
</motion.button>
</div>
);
})}
</div>
<div className="mx-auto mt-8 min-h-[136px] max-w-xl text-center">
<motion.div
key={activeSeries.id}
initial={reduce ? false : { opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.45, ease: "easeOut" }}
>
<span className={`inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-medium ${tone.chip}`}>
<span className={`h-1.5 w-1.5 rounded-full ${tone.dot} cvf-live`} />
{activeSeries.frames} · {activeSeries.year}
</span>
<h3 className="mt-4 text-2xl font-semibold tracking-tight text-slate-900 dark:text-white sm:text-3xl">
{activeSeries.title}
</h3>
<p className={`mt-1 text-sm font-medium ${tone.text}`}>{activeSeries.location}</p>
<p className="mt-3 text-sm leading-relaxed text-slate-600 dark:text-slate-300">{activeSeries.blurb}</p>
</motion.div>
</div>
<div className="mt-8 flex items-center justify-center gap-5">
<button
type="button"
onClick={() => go(-1)}
disabled={active === 0}
aria-label="Previous cover"
className="flex h-11 w-11 items-center justify-center rounded-full bg-white text-slate-700 shadow-sm ring-1 ring-inset ring-slate-900/10 transition hover:bg-slate-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 disabled:cursor-not-allowed disabled:opacity-40 dark:bg-slate-800 dark:text-slate-200 dark:ring-white/10 dark:hover:bg-slate-700"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" className="h-5 w-5">
<path d="m15 18-6-6 6-6" />
</svg>
</button>
<div role="group" aria-label="Select a cover" className="flex items-center gap-2">
{SERIES.map((s, i) => {
const isActive = i === active;
return (
<button
key={s.id}
type="button"
onClick={() => setActive(i)}
aria-label={`Show ${s.title}`}
aria-current={isActive ? "true" : undefined}
className={`h-2 rounded-full transition-all duration-300 focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-950 ${
isActive
? `w-7 ${TONES[s.tone].dot}`
: "w-2 bg-slate-300 hover:bg-slate-400 dark:bg-slate-600 dark:hover:bg-slate-500"
}`}
/>
);
})}
</div>
<button
type="button"
onClick={() => go(1)}
disabled={active === n - 1}
aria-label="Next cover"
className="flex h-11 w-11 items-center justify-center rounded-full bg-white text-slate-700 shadow-sm ring-1 ring-inset ring-slate-900/10 transition hover:bg-slate-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 disabled:cursor-not-allowed disabled:opacity-40 dark:bg-slate-800 dark:text-slate-200 dark:ring-white/10 dark:hover:bg-slate-700"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" className="h-5 w-5">
<path d="m9 18 6-6-6-6" />
</svg>
</button>
</div>
<p className="mt-4 text-center text-xs text-slate-400 dark:text-slate-500">
<kbd className="rounded border border-slate-300 px-1.5 py-0.5 font-sans text-[10px] dark:border-slate-600">←</kbd>
<span className="mx-1">/</span>
<kbd className="rounded border border-slate-300 px-1.5 py-0.5 font-sans text-[10px] dark:border-slate-600">→</kbd>
<span className="mx-2">to browse</span>
<span className="tabular-nums">· {count}</span>
</p>
</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

Fade Carousel
Originalcrossfade carousel

Cards Carousel
Originalpeeking card 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

