Cards Carousel
Original · freepeeking card carousel
byWeb InnoventixReact + Tailwind
carcardscarousels
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-cards.jsoncar-cards.tsx
"use client";
import {
useCallback,
useEffect,
useId,
useRef,
useState,
type KeyboardEvent,
} from "react";
import {
animate,
motion,
useMotionValue,
useReducedMotion,
type PanInfo,
} from "motion/react";
type Slide = {
id: string;
index: string;
place: string;
region: string;
country: string;
tag: string;
season: string;
coords: string;
note: string;
img: string;
};
const SLIDES: Slide[] = [
{
id: "reine",
index: "01",
place: "Reine",
region: "Lofoten Archipelago",
country: "Norway",
tag: "Arctic",
season: "Feb – Apr · aurora season",
coords: "67.93° N, 13.09° E",
note: "Red rorbu cabins crouch under granite spires that fall straight into the sea. In deep winter the sun barely clears the ridge, and the whole fjord turns the colour of cold steel.",
img: "/img/gallery/g01.webp",
},
{
id: "gasadalur",
index: "02",
place: "Gásadalur",
region: "Vágar, Faroe Islands",
country: "Denmark",
tag: "Cliffside",
season: "May – Aug · long light",
coords: "62.10° N, 7.27° W",
note: "Until a tunnel was blasted through the mountain in 2004, the postman crossed on foot. The Múlafossur waterfall still drops off the headland straight into the open Atlantic.",
img: "/img/gallery/g02.webp",
},
{
id: "biei",
index: "03",
place: "Biei",
region: "Kamikawa, Hokkaidō",
country: "Japan",
tag: "Highland",
season: "Dec – Feb · powder",
coords: "43.59° N, 142.46° E",
note: "Rolling patchwork hills the locals call the undulating fields. Under a metre of dry snow the lone trees on the ridgeline read like ink strokes drawn across rice paper.",
img: "/img/gallery/g03.webp",
},
{
id: "paine",
index: "04",
place: "Torres del Paine",
region: "Última Esperanza",
country: "Chile",
tag: "Patagonia",
season: "Nov – Mar · austral summer",
coords: "50.94° S, 73.40° W",
note: "Three granite towers hold the weather hostage. Guanaco graze the pampas below while the wind, funnelled straight off the Southern Ice Field, quietly rearranges the lakes.",
img: "/img/gallery/g04.webp",
},
{
id: "tre-cime",
index: "05",
place: "Tre Cime di Lavaredo",
region: "Dolomites, South Tyrol",
country: "Italy",
tag: "Alpine",
season: "Jun – Sep · refuges open",
coords: "46.62° N, 12.30° E",
note: "Three pale limestone monoliths that flush rose at dusk — the enrosadira. The circular trail rounds a First World War refuge still perched on the saddle between them.",
img: "/img/gallery/g05.webp",
},
{
id: "trotternish",
index: "06",
place: "Trotternish Ridge",
region: "Isle of Skye",
country: "Scotland",
tag: "Moorland",
season: "Sep – Nov · clear spells",
coords: "57.61° N, 6.19° W",
note: "A landslip coast of basalt pinnacles, the Old Man of Storr among them. Fog rolls off the Sound of Raasay, swallows the pinnacles whole, then slowly hands them back.",
img: "/img/gallery/g06.webp",
},
];
const GAP = 28;
function clamp(value: number, lo: number, hi: number): number {
return Math.min(Math.max(value, lo), hi);
}
export default function CarCards() {
const reduced = useReducedMotion();
const uid = useId();
const headingId = `${uid}-heading`;
const slides = SLIDES;
const last = slides.length - 1;
const [active, setActive] = useState(2);
const [viewportW, setViewportW] = useState(0);
const viewportRef = useRef<HTMLDivElement | null>(null);
const draggedRef = useRef(false);
const x = useMotionValue(0);
useEffect(() => {
const el = viewportRef.current;
if (!el) return;
setViewportW(el.clientWidth);
const ro = new ResizeObserver((entries) => {
for (const entry of entries) setViewportW(entry.contentRect.width);
});
ro.observe(el);
return () => ro.disconnect();
}, []);
const cardW =
viewportW === 0 ? 0 : clamp(Math.round(viewportW * 0.66), 236, 432);
const step = cardW + GAP;
const trackX =
viewportW === 0 ? 0 : viewportW / 2 - (active * step + cardW / 2);
const maxX = viewportW === 0 ? 0 : viewportW / 2 - cardW / 2;
const minX =
viewportW === 0 ? 0 : viewportW / 2 - (last * step + cardW / 2);
useEffect(() => {
if (viewportW === 0) return;
const controls = animate(
x,
trackX,
reduced
? { duration: 0 }
: { type: "spring" as const, stiffness: 260, damping: 34 },
);
return () => controls.stop();
}, [trackX, viewportW, reduced, x]);
const goTo = useCallback((i: number) => setActive(clamp(i, 0, last)), [last]);
const go = useCallback(
(dir: number) => setActive((prev) => clamp(prev + dir, 0, last)),
[last],
);
const onKeyDown = useCallback(
(e: KeyboardEvent<HTMLDivElement>) => {
if (e.key === "ArrowLeft") {
e.preventDefault();
go(-1);
} else if (e.key === "ArrowRight") {
e.preventDefault();
go(1);
} else if (e.key === "Home") {
e.preventDefault();
goTo(0);
} else if (e.key === "End") {
e.preventDefault();
goTo(last);
}
},
[go, goTo, last],
);
const onDragEnd = useCallback(
(_event: MouseEvent | TouchEvent | PointerEvent, info: PanInfo) => {
draggedRef.current = Math.abs(info.offset.x) > 6;
if (viewportW === 0 || step === 0) return;
const ideal = (viewportW / 2 - cardW / 2 - x.get()) / step;
let next = Math.round(ideal);
if (Math.abs(info.velocity.x) > 320) {
next = info.velocity.x < 0 ? active + 1 : active - 1;
}
setActive(clamp(next, 0, last));
},
[viewportW, step, cardW, x, active, last],
);
const onCardClick = useCallback(
(i: number) => {
if (draggedRef.current) {
draggedRef.current = false;
return;
}
setActive(i);
},
[],
);
const cardTransition = reduced
? { duration: 0 }
: { type: "spring" as const, stiffness: 220, damping: 30 };
const progress = ((active + 1) / slides.length) * 100;
return (
<section
aria-labelledby={headingId}
className="relative w-full overflow-hidden bg-gradient-to-b from-neutral-50 via-amber-50/40 to-neutral-100 py-20 text-neutral-900 sm:py-28 dark:from-neutral-950 dark:via-neutral-950 dark:to-black dark:text-neutral-100"
>
<style>{`
@keyframes carc-reveal {
from { opacity: 0; transform: translateY(16px); }
to { opacity: 1; transform: none; }
}
@keyframes carc-drift {
0%, 100% { transform: translate3d(0, 0, 0) scale(1); }
50% { transform: translate3d(0, -26px, 0) scale(1.06); }
}
.carc-reveal { animation: carc-reveal 0.7s cubic-bezier(0.22, 0.61, 0.36, 1) both; }
.carc-drift { animation: carc-drift 15s ease-in-out infinite; }
@media (prefers-reduced-motion: reduce) {
.carc-reveal, .carc-drift { animation: none !important; }
}
`}</style>
{/* atmosphere */}
<div aria-hidden className="pointer-events-none absolute inset-0 -z-10">
<div className="carc-drift absolute -left-24 top-4 h-72 w-72 rounded-full bg-amber-300/30 blur-3xl dark:bg-amber-500/10" />
<div
className="carc-drift absolute -right-24 bottom-0 h-80 w-80 rounded-full bg-sky-300/25 blur-3xl dark:bg-indigo-500/10"
style={{ animationDelay: "-6s" }}
/>
<div className="absolute inset-0 opacity-[0.04] mix-blend-overlay [background-image:radial-gradient(circle_at_1px_1px,currentColor_1px,transparent_0)] [background-size:22px_22px] dark:opacity-[0.06]" />
</div>
<div className="mx-auto w-full max-w-6xl px-4 sm:px-6">
{/* header */}
<header className="mb-12 max-w-2xl">
<div className="carc-reveal flex items-center gap-3 text-xs font-medium uppercase tracking-[0.28em] text-amber-700 dark:text-amber-400">
<span className="h-px w-8 bg-amber-600/60 dark:bg-amber-400/50" />
Field Guide · Vol. IV
</div>
<h2
id={headingId}
className="carc-reveal mt-5 font-serif text-4xl font-medium leading-[1.05] tracking-tight text-neutral-900 sm:text-5xl dark:text-neutral-50"
style={{ animationDelay: "0.06s" }}
>
Where the map goes quiet
</h2>
<p
className="carc-reveal mt-4 text-base leading-relaxed text-neutral-600 dark:text-neutral-400"
style={{ animationDelay: "0.12s" }}
>
Six places at the far edge of the road, arranged for the long way
round. Drag the deck, tap a neighbour, or use the arrow keys.
</p>
</header>
{/* carousel */}
<div
role="group"
aria-roledescription="carousel"
aria-label="Remote destinations"
tabIndex={0}
onKeyDown={onKeyDown}
className="carc-reveal relative rounded-[2rem] outline-none focus-visible:ring-2 focus-visible:ring-amber-500 focus-visible:ring-offset-4 focus-visible:ring-offset-neutral-50 dark:focus-visible:ring-offset-neutral-950"
style={{ animationDelay: "0.18s" }}
>
<div
ref={viewportRef}
className="overflow-hidden py-6 [mask-image:linear-gradient(to_right,transparent,black_9%,black_91%,transparent)]"
>
<motion.div
className="flex cursor-grab items-stretch select-none active:cursor-grabbing"
style={{ x, gap: GAP }}
drag="x"
dragConstraints={{ left: minX, right: maxX }}
dragElastic={0.12}
onPointerDown={() => {
draggedRef.current = false;
}}
onDragEnd={onDragEnd}
>
{slides.map((s, i) => {
const rel = i - active;
const dist = Math.abs(rel);
const isActive = rel === 0;
const scale = isActive ? 1 : Math.max(0.82, 1 - dist * 0.09);
const opacity = isActive
? 1
: Math.max(0.3, 1 - dist * 0.32);
return (
<motion.article
key={s.id}
role="group"
aria-roledescription="slide"
aria-label={`${i + 1} of ${slides.length}`}
className="shrink-0"
style={{ width: cardW || undefined, zIndex: 100 - dist }}
animate={{ scale, opacity }}
transition={cardTransition}
>
<button
type="button"
onClick={() => onCardClick(i)}
aria-current={isActive ? "true" : undefined}
aria-label={
isActive
? `Current destination: ${s.place}, ${s.country}`
: `Go to ${s.place}, ${s.country}`
}
tabIndex={isActive ? 0 : -1}
className={`group block w-full overflow-hidden rounded-3xl border text-left transition-shadow duration-300 outline-none focus-visible:ring-2 focus-visible:ring-amber-500 focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-50 dark:focus-visible:ring-offset-neutral-950 ${
isActive
? "border-amber-500/50 bg-white shadow-2xl shadow-amber-900/10 dark:border-amber-400/40 dark:bg-neutral-900 dark:shadow-black/60"
: "border-neutral-200/80 bg-white/70 shadow-lg shadow-neutral-900/5 dark:border-neutral-800 dark:bg-neutral-900/70"
}`}
>
<div className="relative aspect-[4/5] w-full overflow-hidden bg-neutral-200 dark:bg-neutral-800">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={s.img}
alt={`${s.place}, ${s.country}`}
loading="lazy"
draggable={false}
className="h-full w-full object-cover transition-transform duration-700 ease-out group-hover:scale-[1.04]"
/>
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/70 via-black/10 to-transparent" />
<span className="absolute left-4 top-4 inline-flex items-center gap-1.5 rounded-full bg-white/90 px-2.5 py-1 text-[11px] font-semibold uppercase tracking-wider text-neutral-800 backdrop-blur-sm dark:bg-neutral-950/80 dark:text-neutral-100">
<svg
viewBox="0 0 24 24"
className="h-3 w-3 text-amber-600 dark:text-amber-400"
fill="none"
stroke="currentColor"
strokeWidth={2}
aria-hidden
>
<path
d="M12 21s-6.5-5.5-6.5-10a6.5 6.5 0 1 1 13 0c0 4.5-6.5 10-6.5 10Z"
strokeLinejoin="round"
/>
<circle cx="12" cy="11" r="2" />
</svg>
{s.tag}
</span>
<div className="absolute bottom-0 left-0 right-0 flex items-end justify-between p-5">
<div>
<p className="text-xs uppercase tracking-[0.2em] text-white/70">
{s.region}
</p>
<h3 className="mt-1 font-serif text-2xl font-medium leading-tight text-white">
{s.place}
</h3>
</div>
<span className="font-serif text-3xl font-medium leading-none text-amber-300/90">
{s.index}
</span>
</div>
</div>
<div className="p-5">
<p className="text-[15px] leading-relaxed text-neutral-700 dark:text-neutral-300">
{s.note}
</p>
<dl className="mt-5 flex flex-wrap items-center gap-x-5 gap-y-2 border-t border-neutral-200 pt-4 text-[13px] dark:border-neutral-800">
<div className="flex items-center gap-1.5">
<dt className="sr-only">Best season</dt>
<svg
viewBox="0 0 24 24"
className="h-3.5 w-3.5 text-amber-600 dark:text-amber-400"
fill="none"
stroke="currentColor"
strokeWidth={2}
aria-hidden
>
<circle cx="12" cy="12" r="4" />
<path
d="M12 2v2M12 20v2M2 12h2M20 12h2M5 5l1.5 1.5M17.5 17.5 19 19M19 5l-1.5 1.5M6.5 17.5 5 19"
strokeLinecap="round"
/>
</svg>
<dd className="font-medium text-neutral-700 dark:text-neutral-300">
{s.season}
</dd>
</div>
<div className="flex items-center gap-1.5">
<dt className="sr-only">Coordinates</dt>
<dd className="font-mono text-neutral-500 dark:text-neutral-400">
{s.coords}
</dd>
</div>
</dl>
</div>
</button>
</motion.article>
);
})}
</motion.div>
</div>
</div>
{/* controls */}
<div className="mt-10 flex flex-col items-center gap-6 sm:flex-row sm:justify-between">
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => go(-1)}
disabled={active === 0}
aria-label="Previous destination"
className="inline-flex h-11 w-11 items-center justify-center rounded-full border border-neutral-300 bg-white text-neutral-800 transition hover:border-amber-500 hover:text-amber-700 disabled:cursor-not-allowed disabled:opacity-35 disabled:hover:border-neutral-300 disabled:hover:text-neutral-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-500 focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-50 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-100 dark:hover:border-amber-400 dark:hover:text-amber-400 dark:focus-visible:ring-offset-neutral-950"
>
<svg
viewBox="0 0 24 24"
className="h-5 w-5"
fill="none"
stroke="currentColor"
strokeWidth={2}
aria-hidden
>
<path
d="M15 5l-7 7 7 7"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
<button
type="button"
onClick={() => go(1)}
disabled={active === last}
aria-label="Next destination"
className="inline-flex h-11 w-11 items-center justify-center rounded-full border border-neutral-300 bg-white text-neutral-800 transition hover:border-amber-500 hover:text-amber-700 disabled:cursor-not-allowed disabled:opacity-35 disabled:hover:border-neutral-300 disabled:hover:text-neutral-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-500 focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-50 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-100 dark:hover:border-amber-400 dark:hover:text-amber-400 dark:focus-visible:ring-offset-neutral-950"
>
<svg
viewBox="0 0 24 24"
className="h-5 w-5"
fill="none"
stroke="currentColor"
strokeWidth={2}
aria-hidden
>
<path
d="M9 5l7 7-7 7"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
<div
className="ml-2 flex items-center gap-2"
role="tablist"
aria-label="Choose destination"
>
{slides.map((s, i) => (
<button
key={s.id}
type="button"
role="tab"
aria-selected={i === active}
aria-label={`${s.place}, ${s.country}`}
onClick={() => goTo(i)}
className={`h-2 rounded-full transition-all duration-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-500 focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-50 dark:focus-visible:ring-offset-neutral-950 ${
i === active
? "w-7 bg-amber-500 dark:bg-amber-400"
: "w-2 bg-neutral-300 hover:bg-neutral-400 dark:bg-neutral-700 dark:hover:bg-neutral-600"
}`}
/>
))}
</div>
</div>
<div className="flex w-full items-center gap-4 sm:w-auto">
<div className="h-px flex-1 overflow-hidden rounded-full bg-neutral-200 sm:w-40 sm:flex-none dark:bg-neutral-800">
<div
className="h-full rounded-full bg-amber-500 transition-[width] duration-500 ease-out dark:bg-amber-400"
style={{ width: `${progress}%` }}
/>
</div>
<p className="shrink-0 font-mono text-sm tabular-nums text-neutral-500 dark:text-neutral-400">
<span className="font-semibold text-neutral-900 dark:text-neutral-100">
{String(active + 1).padStart(2, "0")}
</span>
{" / "}
{String(slides.length).padStart(2, "0")}
</p>
</div>
</div>
<div aria-live="polite" className="sr-only">
{`Showing slide ${active + 1} of ${slides.length}: ${slides[active].place}, ${slides[active].country}`}
</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

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

