Center Focus Carousel
Original · freecentre-focused scaling carousel
byWeb InnoventixReact + Tailwind
carcenterfocuscarousels
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-center-focus.jsoncar-center-focus.tsx
"use client";
import { useEffect, useId, useRef, useState } from "react";
import { motion, useReducedMotion } from "motion/react";
type Slide = {
id: string;
img: string;
title: string;
place: string;
year: string;
caption: string;
};
const SLIDES: Slide[] = [
{
id: "reykjavik",
img: "/img/gallery/g01.webp",
title: "Blue Hour Over the Harbour",
place: "Reykjavík, Iceland",
year: "2022",
caption:
"Nineteen minutes after sunset the water turns cobalt and the streetlights haven't caught up yet. You get one window a night to shoot it.",
},
{
id: "tegallalang",
img: "/img/gallery/g02.webp",
title: "Terraces Before the Rain",
place: "Tegallalang, Bali",
year: "2023",
caption:
"The farmers flood the upper paddies at first light so the whole hillside mirrors the sky. By nine the reflection is gone.",
},
{
id: "marrakech",
img: "/img/gallery/g03.webp",
title: "Rooftops of the Medina",
place: "Marrakech, Morocco",
year: "2019",
caption:
"You lose the call to prayer down in the alleys, then find it again three storeys up, coming from every direction at once.",
},
{
id: "geiranger",
img: "/img/gallery/g04.webp",
title: "Still Water, Geiranger",
place: "Geirangerfjord, Norway",
year: "2021",
caption:
"No wind for six hours. The fjord held the cliffs like a second sky and the ferry wake took a full minute to reach the shore.",
},
{
id: "wadirum",
img: "/img/gallery/g05.webp",
title: "First Light on the Dunes",
place: "Wadi Rum, Jordan",
year: "2023",
caption:
"The sand stays grey until the exact second the sun clears the eastern ridge. Then, for about ninety seconds, everything is copper.",
},
{
id: "kyoto",
img: "/img/gallery/g06.webp",
title: "Canal in Full Bloom",
place: "Kyoto, Japan",
year: "2024",
caption:
"The petals arrive before you do and leave the day after. Timing is the whole art — a week early is bare, a week late is snow.",
},
{
id: "lofoten",
img: "/img/gallery/g07.webp",
title: "Green Sky Over the Water",
place: "Lofoten, Norway",
year: "2020",
caption:
"We waited three clouded-out nights in the car. On the fourth the aurora came down almost to the waterline and stayed for an hour.",
},
];
function IconChevronLeft() {
return (
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className="h-5 w-5">
<path
d="m15 18-6-6 6-6"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function IconChevronRight() {
return (
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className="h-5 w-5">
<path
d="m9 6 6 6-6 6"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function IconPlay() {
return (
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" className="h-4 w-4">
<path d="M8 5.14v13.72a1 1 0 0 0 1.53.85l10.75-6.86a1 1 0 0 0 0-1.7L9.53 4.29A1 1 0 0 0 8 5.14Z" />
</svg>
);
}
function IconPause() {
return (
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" className="h-4 w-4">
<path d="M7 4.5a1.25 1.25 0 0 0-1.25 1.25v12.5a1.25 1.25 0 0 0 2.5 0V5.75A1.25 1.25 0 0 0 7 4.5Zm10 0a1.25 1.25 0 0 0-1.25 1.25v12.5a1.25 1.25 0 0 0 2.5 0V5.75A1.25 1.25 0 0 0 17 4.5Z" />
</svg>
);
}
function IconAperture() {
return (
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className="h-4 w-4">
<circle cx="12" cy="12" r="9" stroke="currentColor" strokeWidth="1.6" />
<path
d="M12 3v9m6.36-4.5L12 12m6.36 7.5L12 12m-6.36 7.5L12 12M3 12h9m-6.36-4.5L12 12"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
/>
</svg>
);
}
export default function CarCenterFocus() {
const reduce = useReducedMotion();
const uid = useId();
const n = SLIDES.length;
const [active, setActive] = useState(3);
const [playing, setPlaying] = useState(false);
const [hovering, setHovering] = useState(false);
const [focusWithin, setFocusWithin] = useState(false);
const stageRef = useRef<HTMLDivElement>(null);
const [stageWidth, setStageWidth] = useState(0);
useEffect(() => {
const el = stageRef.current;
if (!el) return;
setStageWidth(el.clientWidth);
const ro = new ResizeObserver((entries) => {
for (const entry of entries) setStageWidth(entry.contentRect.width);
});
ro.observe(el);
return () => ro.disconnect();
}, []);
const rotating = playing && !reduce && !hovering && !focusWithin;
useEffect(() => {
if (!rotating) return;
const t = window.setInterval(() => {
setActive((a) => (a + 1) % n);
}, 4200);
return () => window.clearInterval(t);
}, [rotating, n]);
function go(dir: number) {
setActive((a) => (a + dir + n) % n);
}
const cardW = stageWidth
? Math.min(Math.max(stageWidth * 0.52, 236), 460)
: 360;
const cardH = Math.round(cardW * 1.28);
const spacing = cardW * 0.72;
const current = SLIDES[active];
const btnBase =
"inline-flex items-center justify-center rounded-full border transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-50 dark:focus-visible:ring-offset-zinc-950";
const arrowBtn =
btnBase +
" h-12 w-12 border-zinc-300 bg-white/80 text-zinc-700 backdrop-blur hover:bg-zinc-100 hover:text-zinc-950 active:scale-95 dark:border-zinc-700 dark:bg-zinc-900/80 dark:text-zinc-200 dark:hover:bg-zinc-800 dark:hover:text-white";
return (
<section className="relative w-full overflow-hidden bg-zinc-50 px-4 py-20 text-zinc-900 dark:bg-zinc-950 dark:text-zinc-100 sm:px-6 sm:py-28 lg:px-8">
<style>{`
@keyframes ccf-shimmer {
0% { background-position: 0% 50%; }
100% { background-position: 200% 50%; }
}
@keyframes ccf-pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.3; transform: scale(0.65); }
}
@keyframes ccf-float {
0%, 100% { transform: translate3d(0, 0, 0); }
50% { transform: translate3d(0, -10px, 0); }
}
.ccf-anim-shimmer { animation: ccf-shimmer 3s linear infinite; }
.ccf-anim-pulse { animation: ccf-pulse 1.4s ease-in-out infinite; }
.ccf-anim-float { animation: ccf-float 9s ease-in-out infinite; }
@media (prefers-reduced-motion: reduce) {
.ccf-anim-shimmer, .ccf-anim-pulse, .ccf-anim-float { animation: none !important; }
}
`}</style>
{/* decorative atmosphere */}
<div
aria-hidden="true"
className="ccf-anim-float pointer-events-none absolute -left-24 top-10 h-72 w-72 rounded-full bg-indigo-400/20 blur-3xl dark:bg-indigo-500/15"
/>
<div
aria-hidden="true"
className="pointer-events-none absolute -right-20 bottom-0 h-80 w-80 rounded-full bg-sky-400/20 blur-3xl dark:bg-violet-500/15"
/>
<div className="relative mx-auto max-w-6xl">
{/* header */}
<header className="mx-auto max-w-2xl text-center">
<span className="inline-flex items-center gap-2 rounded-full border border-zinc-300 bg-white/70 px-3 py-1 text-xs font-medium uppercase tracking-[0.18em] text-zinc-600 backdrop-blur dark:border-zinc-700 dark:bg-zinc-900/70 dark:text-zinc-300">
<IconAperture />
Field Journal · 2019–2024
</span>
<h2 className="mt-5 text-4xl font-semibold tracking-tight sm:text-5xl">
Latitudes
</h2>
<p className="mt-4 text-base leading-relaxed text-zinc-600 dark:text-zinc-400">
Seven frames from four years of chasing light. Tap a photo to bring
it forward, drag the arrows, or let the reel play.
</p>
</header>
{/* interactive region: hover / focus pauses autoplay, arrows navigate */}
<div
role="group"
aria-roledescription="carousel"
aria-label="Latitudes photography carousel"
className="mt-12"
onMouseEnter={() => setHovering(true)}
onMouseLeave={() => setHovering(false)}
onFocus={() => setFocusWithin(true)}
onBlur={(e) => {
if (!e.currentTarget.contains(e.relatedTarget)) setFocusWithin(false);
}}
onKeyDown={(e) => {
if (e.key === "ArrowLeft") {
e.preventDefault();
go(-1);
} else if (e.key === "ArrowRight") {
e.preventDefault();
go(1);
} else if (e.key === "Home") {
e.preventDefault();
setActive(0);
} else if (e.key === "End") {
e.preventDefault();
setActive(n - 1);
}
}}
>
{/* stage */}
<div
ref={stageRef}
className="relative w-full overflow-hidden"
style={{ height: cardH + 56, perspective: 1300 }}
>
{SLIDES.map((slide, i) => {
let off = i - active;
if (off > n / 2) off -= n;
else if (off < -n / 2) off += n;
const abs = Math.abs(off);
const visible = abs <= 2;
const isActive = off === 0;
const clamp = Math.max(-2, Math.min(2, off));
const x = off * spacing;
const scale = 1 - Math.min(abs, 3) * 0.12;
const opacity = abs > 2 ? 0 : isActive ? 1 : abs === 1 ? 0.72 : 0.4;
const rotateY = clamp * -9;
const zIndex = 30 - abs;
return (
<motion.div
key={slide.id}
className="absolute left-1/2 top-1/2"
style={{
width: cardW,
height: cardH,
marginLeft: -cardW / 2,
marginTop: -cardH / 2,
zIndex,
transformStyle: "preserve-3d",
pointerEvents: visible ? "auto" : "none",
}}
aria-hidden={visible ? undefined : true}
initial={false}
animate={{ x, scale, rotateY, opacity }}
transition={
reduce
? { duration: 0 }
: { type: "spring", stiffness: 210, damping: 26, mass: 0.9 }
}
>
<button
type="button"
tabIndex={visible ? 0 : -1}
onClick={() => setActive(i)}
aria-current={isActive || undefined}
aria-label={`Show frame ${String(i + 1).padStart(2, "0")}: ${slide.title}, ${slide.place}`}
className={
"group relative block h-full w-full select-none overflow-hidden rounded-2xl border text-left transition-shadow duration-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-50 dark:focus-visible:ring-offset-zinc-950 " +
(isActive
? "border-white/60 shadow-2xl shadow-indigo-900/25 ring-1 ring-black/5 dark:border-white/20 dark:shadow-black/60"
: "border-zinc-200/70 shadow-xl shadow-zinc-900/10 dark:border-zinc-800 dark:shadow-black/40")
}
>
{/* image with graceful gradient fallback */}
<span
aria-hidden="true"
className="absolute inset-0 bg-gradient-to-br from-zinc-200 to-zinc-400 dark:from-zinc-800 dark:to-zinc-950"
/>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={slide.img}
alt={`${slide.title} — ${slide.place}`}
loading="lazy"
decoding="async"
draggable={false}
className="absolute inset-0 h-full w-full object-cover"
/>
{/* dimming veil on non-active frames */}
<span
aria-hidden="true"
className={
"absolute inset-0 transition-colors duration-500 " +
(isActive
? "bg-transparent"
: "bg-zinc-950/25 group-hover:bg-zinc-950/10")
}
/>
{/* caption gradient */}
<span
aria-hidden="true"
className="absolute inset-x-0 bottom-0 h-2/5 bg-gradient-to-t from-black/75 via-black/25 to-transparent"
/>
{/* frame index */}
<span className="absolute left-4 top-4 rounded-full bg-black/40 px-2.5 py-1 font-mono text-[11px] font-medium tracking-widest text-white/90 backdrop-blur">
{String(i + 1).padStart(2, "0")} / {String(n).padStart(2, "0")}
</span>
{/* on-card title */}
<span className="absolute inset-x-4 bottom-4 block">
<span className="block text-[11px] font-medium uppercase tracking-[0.16em] text-white/70">
{slide.place}
</span>
<span className="mt-1 block text-lg font-semibold leading-snug text-white drop-shadow">
{slide.title}
</span>
</span>
{/* active accent bar */}
{isActive && (
<span
aria-hidden="true"
className="ccf-anim-shimmer pointer-events-none absolute inset-x-0 top-0 h-1"
style={{
background:
"linear-gradient(90deg,#6366f1,#8b5cf6,#38bdf8,#6366f1)",
backgroundSize: "200% 100%",
}}
/>
)}
</button>
</motion.div>
);
})}
</div>
{/* live caption */}
<div className="mx-auto mt-8 min-h-[132px] max-w-xl text-center sm:min-h-[116px]">
<p className="text-sm font-medium uppercase tracking-[0.16em] text-indigo-600 dark:text-indigo-400">
{current.place} · {current.year}
</p>
<h3 className="mt-2 text-xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-50">
{current.title}
</h3>
<p
aria-live={rotating ? "off" : "polite"}
aria-atomic="true"
className="mt-2 text-[15px] leading-relaxed text-zinc-600 dark:text-zinc-400"
>
<span className="sr-only">{`Frame ${active + 1} of ${n}. `}</span>
{"“"}
{current.caption}
{"”"}
</p>
</div>
{/* controls */}
<div className="mt-6 flex items-center justify-center gap-4">
<button
type="button"
onClick={() => go(-1)}
className={arrowBtn}
aria-label="Previous frame"
>
<IconChevronLeft />
</button>
<div
role="group"
aria-label="Choose a frame"
className="flex items-center gap-2"
>
{SLIDES.map((slide, i) => {
const on = i === active;
return (
<button
key={slide.id}
type="button"
onClick={() => setActive(i)}
aria-label={`Go to frame ${String(i + 1).padStart(2, "0")}: ${slide.title}`}
aria-current={on || undefined}
className="group/dot relative flex h-6 items-center justify-center focus-visible:outline-none"
>
<span
className={
"block rounded-full transition-all duration-300 group-focus-visible/dot:ring-2 group-focus-visible/dot:ring-indigo-500 group-focus-visible/dot:ring-offset-2 group-focus-visible/dot:ring-offset-zinc-50 dark:group-focus-visible/dot:ring-offset-zinc-950 " +
(on
? "h-2.5 w-7 bg-indigo-600 dark:bg-indigo-400"
: "h-2.5 w-2.5 bg-zinc-300 hover:bg-zinc-400 dark:bg-zinc-700 dark:hover:bg-zinc-500")
}
/>
</button>
);
})}
</div>
<button
type="button"
onClick={() => go(1)}
className={arrowBtn}
aria-label="Next frame"
>
<IconChevronRight />
</button>
</div>
{/* playback */}
<div className="mt-6 flex items-center justify-center gap-4">
<button
type="button"
onClick={() => setPlaying((p) => !p)}
aria-pressed={playing}
aria-describedby={`${uid}-hint`}
className={
btnBase +
" gap-2 px-4 py-2 text-sm font-medium border-zinc-300 bg-white/80 text-zinc-700 backdrop-blur hover:bg-zinc-100 hover:text-zinc-950 dark:border-zinc-700 dark:bg-zinc-900/80 dark:text-zinc-200 dark:hover:bg-zinc-800 dark:hover:text-white"
}
>
<span className="relative flex h-2 w-2 items-center justify-center">
<span
className={
"block h-2 w-2 rounded-full " +
(rotating
? "ccf-anim-pulse bg-emerald-500"
: "bg-zinc-400 dark:bg-zinc-500")
}
/>
</span>
{playing ? <IconPause /> : <IconPlay />}
{playing ? "Pause reel" : "Play reel"}
</button>
<span className="font-mono text-sm tabular-nums text-zinc-500 dark:text-zinc-400">
{String(active + 1).padStart(2, "0")}
<span className="text-zinc-400 dark:text-zinc-600"> / </span>
{String(n).padStart(2, "0")}
</span>
</div>
<p id={`${uid}-hint`} className="sr-only">
Use the left and right arrow keys to move between frames. Playback
pauses while you hover or focus the carousel.
</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

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

Progress Carousel
Originalcarousel with a progress bar

Testimonial Carousel
Originaltestimonial quote carousel

Logos Carousel
Originallogo carousel strip

