Autoplay Carousel
Original · freeautoplay carousel with progress and pause
byWeb InnoventixReact + Tailwind
carautoplaycarousels
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-autoplay.jsoncar-autoplay.tsx
"use client";
import {
useCallback,
useEffect,
useRef,
useState,
type FocusEvent as ReactFocusEvent,
type KeyboardEvent as ReactKeyboardEvent,
} from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
type Slide = {
id: string;
img: string;
alt: string;
tag: string;
title: string;
blurb: string;
meta: string;
};
const SLIDES: Slide[] = [
{
id: "coast",
img: "/img/gallery/g01.webp",
alt: "Low fog drifting over a rocky coastline just before sunrise",
tag: "Coastline",
title: "First Light on the Cabot Trail",
blurb:
"Forty minutes before sunrise the fog sits low on the water and the whole coast turns the color of slate. We drove the switchbacks in the dark just to be standing here when it broke.",
meta: "Nova Scotia · 04:52",
},
{
id: "concrete",
img: "/img/gallery/g02.webp",
alt: "Sunlight cutting across the raw concrete of a brutalist stairwell",
tag: "Architecture",
title: "The Weight of Raw Concrete",
blurb:
"Brutalism gets a bad name, but stand inside one of these stairwells at noon and watch the light carve the walls. Nothing decorative here — every shadow is doing structural work.",
meta: "Boston · Study 07",
},
{
id: "interior",
img: "/img/gallery/g03.webp",
alt: "An empty reading room with warm oak shelving and a cold marble floor",
tag: "Interiors",
title: "The Quiet Hours",
blurb:
"An empty reading room, twenty minutes before the doors open. Warm oak, cold marble, and a silence you can almost photograph. We shot for two hours and moved absolutely nothing.",
meta: "Lisbon · Winter",
},
{
id: "alpine",
img: "/img/gallery/g04.webp",
alt: "A bare alpine ridge above the treeline under a hard clear sky",
tag: "Expedition",
title: "Above the Treeline",
blurb:
"The last hut is a six-hour climb, and the payoff is a horizon with no straight lines left in it. Thin air, sharper stars, and a kettle that takes forever to boil at this altitude.",
meta: "Valais Alps · 2,940m",
},
{
id: "market",
img: "/img/gallery/g05.webp",
alt: "Crowded market stalls piled with spices and bright textiles",
tag: "Street",
title: "Market Colors at Full Volume",
blurb:
"Saturday morning, and every stall is fighting for the same square of sunlight. Spices, silk, and a vendor who made us try the cardamom before he let us press the shutter.",
meta: "Marrakesh · 09:15",
},
{
id: "dunes",
img: "/img/gallery/g06.webp",
alt: "Sand dunes shifting to a deep cobalt blue at dusk",
tag: "Desert",
title: "When the Dunes Go Blue",
blurb:
"After the sun drops there is a ten-minute window where the sand loses all its warmth and turns cobalt. Then the cold arrives fast, and you pack the tripod with numb fingers.",
meta: "Wadi Rum · Dusk",
},
];
const DURATION_MS = 6000;
export default function CarAutoplay() {
const prefersReduced = useReducedMotion();
const reduceMotion = prefersReduced ?? false;
const [active, setActive] = useState(0);
const [direction, setDirection] = useState(1);
const [isPlaying, setIsPlaying] = useState(true);
const [progress, setProgress] = useState(0);
const [hovering, setHovering] = useState(false);
const [focusWithin, setFocusWithin] = useState(false);
const progressRef = useRef(0);
const count = SLIDES.length;
const current = SLIDES[active];
const autoplayActive = isPlaying && !hovering && !focusWithin && !reduceMotion;
const goTo = useCallback((index: number, dir: number) => {
const next = ((index % count) + count) % count;
setDirection(dir);
progressRef.current = 0;
setProgress(0);
setActive(next);
}, [count]);
useEffect(() => {
if (!autoplayActive) return;
let frame = 0;
let last = performance.now();
const step = (now: number) => {
const delta = now - last;
last = now;
progressRef.current = Math.min(progressRef.current + delta / DURATION_MS, 1);
if (progressRef.current >= 1) {
goTo(active + 1, 1);
return;
}
setProgress(progressRef.current);
frame = requestAnimationFrame(step);
};
frame = requestAnimationFrame(step);
return () => cancelAnimationFrame(frame);
}, [autoplayActive, active, goTo]);
const onKeyDown = (e: ReactKeyboardEvent<HTMLDivElement>) => {
if (e.key === "ArrowRight") {
e.preventDefault();
goTo(active + 1, 1);
} else if (e.key === "ArrowLeft") {
e.preventDefault();
goTo(active - 1, -1);
} else if (e.key === "Home") {
e.preventDefault();
goTo(0, -1);
} else if (e.key === "End") {
e.preventDefault();
goTo(count - 1, 1);
}
};
const onFocusIn = () => setFocusWithin(true);
const onFocusOut = (e: ReactFocusEvent<HTMLDivElement>) => {
if (!e.currentTarget.contains(e.relatedTarget)) setFocusWithin(false);
};
const fillFor = (i: number) => (i < active ? 1 : i === active ? progress : 0);
const btn =
"inline-flex h-11 w-11 items-center justify-center rounded-full border border-zinc-200 bg-white text-zinc-700 shadow-sm transition hover:bg-zinc-50 hover:text-zinc-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-40 dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-200 dark:hover:bg-zinc-800 dark:hover:text-white dark:focus-visible:ring-offset-zinc-950";
return (
<section className="relative w-full bg-gradient-to-b from-zinc-50 to-white px-4 py-16 sm:px-6 sm:py-24 dark:from-zinc-950 dark:to-black">
<style>{`
@keyframes caz-kenburns {
from { transform: scale(1); }
to { transform: scale(1.08); }
}
.caz-zoom {
animation: caz-kenburns 7000ms ease-out forwards;
}
@media (prefers-reduced-motion: reduce) {
.caz-zoom { animation: none !important; }
}
`}</style>
<div className="mx-auto max-w-5xl">
<header className="max-w-2xl">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-indigo-600 dark:text-indigo-400">
Field Notes
</p>
<h2 className="mt-3 text-3xl font-semibold tracking-tight text-zinc-900 sm:text-4xl dark:text-white">
A slow look at places worth the detour
</h2>
<p className="mt-4 text-base leading-relaxed text-zinc-600 dark:text-zinc-400">
Six frames from the studio’s travel journal. It advances on its own — hover,
focus, or hit pause to hold a frame and read at your own pace.
</p>
</header>
<div
role="group"
aria-roledescription="carousel"
aria-label="Field Notes photo stories"
onKeyDown={onKeyDown}
onMouseEnter={() => setHovering(true)}
onMouseLeave={() => setHovering(false)}
onFocus={onFocusIn}
onBlur={onFocusOut}
className="mt-8 rounded-3xl border border-zinc-200 bg-white p-3 shadow-xl shadow-zinc-900/5 sm:p-4 dark:border-zinc-800 dark:bg-zinc-950 dark:shadow-black/40"
>
<div className="sr-only" aria-live={autoplayActive ? "off" : "polite"} aria-atomic="true">
{`Slide ${active + 1} of ${count}: ${current.title}`}
</div>
<div className="relative overflow-hidden rounded-2xl bg-zinc-900">
{/* Progress segments */}
<div className="absolute inset-x-0 top-0 z-30 flex gap-1.5 p-3 sm:gap-2 sm:p-4">
{SLIDES.map((s, i) => (
<button
key={s.id}
type="button"
onClick={() => goTo(i, i > active ? 1 : -1)}
aria-label={`Go to slide ${i + 1}: ${s.title}`}
aria-current={i === active ? "true" : undefined}
className="group relative flex-1 rounded py-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-offset-2 focus-visible:ring-offset-transparent"
>
<span className="block h-1 w-full overflow-hidden rounded-full bg-white/30 transition-colors group-hover:bg-white/45">
<span
className="block h-full rounded-full bg-white"
style={{ width: `${fillFor(i) * 100}%` }}
/>
</span>
</button>
))}
</div>
<div className="relative aspect-[3/4] w-full sm:aspect-[16/9]">
{SLIDES.map((s, i) => (
<div
key={s.id}
role="group"
aria-roledescription="slide"
aria-label={`${i + 1} of ${count}`}
aria-hidden={i !== active}
className="absolute inset-0 transition-opacity duration-700 ease-out"
style={{ opacity: i === active ? 1 : 0 }}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={s.img}
alt={s.alt}
loading="lazy"
draggable={false}
className={`h-full w-full object-cover ${
i === active && !reduceMotion ? "caz-zoom" : ""
}`}
/>
</div>
))}
<div className="pointer-events-none absolute inset-0 z-10 bg-gradient-to-t from-black/85 via-black/35 to-black/5" />
<div className="pointer-events-none absolute inset-0 z-20 flex items-end">
<AnimatePresence mode="wait">
<motion.div
key={current.id}
initial={reduceMotion ? { opacity: 0 } : { opacity: 0, x: direction * 44 }}
animate={{ opacity: 1, x: 0 }}
exit={reduceMotion ? { opacity: 0 } : { opacity: 0, x: direction * -44 }}
transition={{ duration: reduceMotion ? 0.15 : 0.42, ease: "easeOut" }}
className="p-6 sm:p-10"
>
<span className="inline-flex items-center gap-2 rounded-full bg-white/15 px-3 py-1 text-[0.7rem] font-semibold uppercase tracking-[0.15em] text-amber-200 ring-1 ring-inset ring-white/25 backdrop-blur">
{current.tag}
</span>
<h3 className="mt-4 text-2xl font-semibold tracking-tight text-white sm:text-4xl">
{current.title}
</h3>
<p className="mt-3 max-w-xl text-sm leading-relaxed text-zinc-200 sm:text-base">
{current.blurb}
</p>
<p className="mt-4 text-[0.7rem] uppercase tracking-[0.2em] text-zinc-300/80">
{current.meta}
</p>
</motion.div>
</AnimatePresence>
</div>
</div>
</div>
{/* Controls */}
<div className="mt-3 flex items-center justify-between gap-3 px-1 sm:mt-4">
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => setIsPlaying((p) => !p)}
disabled={reduceMotion}
aria-label={
isPlaying ? "Pause automatic slideshow" : "Start automatic slideshow"
}
title={
reduceMotion
? "Autoplay is off because reduced motion is enabled"
: isPlaying
? "Pause automatic slideshow"
: "Start automatic slideshow"
}
className={btn}
>
{isPlaying && !reduceMotion ? (
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="currentColor" 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" className="h-5 w-5" fill="currentColor" aria-hidden="true">
<path d="M8 5.14v13.72a1 1 0 0 0 1.54.84l10.29-6.86a1 1 0 0 0 0-1.68L9.54 4.3A1 1 0 0 0 8 5.14Z" />
</svg>
)}
</button>
<p className="text-sm font-medium tabular-nums text-zinc-500 dark:text-zinc-400">
<span className="text-zinc-900 dark:text-white">{String(active + 1).padStart(2, "0")}</span>
<span className="mx-1 text-zinc-300 dark:text-zinc-600">/</span>
{String(count).padStart(2, "0")}
</p>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => goTo(active - 1, -1)}
aria-label="Previous slide"
className={btn}
>
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="m15 18-6-6 6-6" />
</svg>
</button>
<button
type="button"
onClick={() => goTo(active + 1, 1)}
aria-label="Next slide"
className={btn}
>
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="m9 18 6-6-6-6" />
</svg>
</button>
</div>
</div>
</div>
<p className="mt-4 text-center text-xs text-zinc-500 dark:text-zinc-500">
Use the arrow keys to step through, or click a bar above to jump. Hovering or focusing the
carousel pauses it automatically.
</p>
</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

Coverflow Carousel
Original3D coverflow carousel
Thumbnails Carousel
Originalcarousel synced with thumbnails

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

