Multi Carousel
Original · freemulti-item responsive carousel
byWeb InnoventixReact + Tailwind
carmulticarousels
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-multi.jsoncar-multi.tsx
"use client";
import { useEffect, useId, useMemo, useRef, useState } from "react";
import type {
FocusEvent as ReactFocusEvent,
KeyboardEvent as ReactKeyboardEvent,
PointerEvent as ReactPointerEvent,
} from "react";
import { motion, useReducedMotion } from "motion/react";
type Slide = {
id: string;
category: string;
discipline: string;
title: string;
blurb: string;
img: string;
};
const SLIDES: Slide[] = [
{
id: "ledgerline",
category: "Fintech",
discipline: "Product design",
title: "Ledgerline dashboard rebuild",
blurb:
"Rebuilt the analytics surface for a payments team drowning in spreadsheets. Reconciliation now runs 40% faster.",
img: "g01.webp",
},
{
id: "intake",
category: "Healthcare",
discipline: "UX & research",
title: "Patient intake, reimagined",
blurb:
"A guided, accessible form flow that cut new-patient onboarding from twenty-two minutes down to six.",
img: "g02.webp",
},
{
id: "aperture",
category: "E-commerce",
discipline: "Web engineering",
title: "Aperture storefront",
blurb:
"A headless storefront that paints in under a second and lifted checkout completion by eighteen percent.",
img: "g03.webp",
},
{
id: "northwind",
category: "SaaS",
discipline: "Growth design",
title: "Northwind onboarding",
blurb:
"An interactive product tour that pushed first-week activation from 31% to 54% in a single quarter.",
img: "g04.webp",
},
{
id: "wander",
category: "Travel",
discipline: "Full-stack build",
title: "Wander trip planner",
blurb:
"A map-first itinerary builder handling twelve thousand daily routes without dropping a frame.",
img: "g05.webp",
},
{
id: "frequency",
category: "Media",
discipline: "Cross-platform",
title: "Frequency reader app",
blurb:
"Distraction-free reading with offline sync that stays in step across web, iOS, and Android.",
img: "g06.webp",
},
{
id: "freightpath",
category: "Logistics",
discipline: "Data & design",
title: "Freightpath control tower",
blurb:
"Live shipment tracking for more than three hundred carriers, all on one calm, real-time board.",
img: "g07.webp",
},
{
id: "coursework",
category: "Education",
discipline: "Design systems",
title: "Coursework studio",
blurb:
"Authoring tools that let an instructor publish a full course module in a single afternoon.",
img: "g08.webp",
},
];
const AUTOPLAY_MS = 5000;
function ChevronLeft({ className }: { className?: string }) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
focusable="false"
>
<path d="M15 18l-6-6 6-6" />
</svg>
);
}
function ChevronRight({ className }: { className?: string }) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
focusable="false"
>
<path d="M9 18l6-6-6-6" />
</svg>
);
}
function PlayIcon({ className }: { className?: string }) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden="true"
focusable="false"
>
<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>
);
}
function PauseIcon({ className }: { className?: string }) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden="true"
focusable="false"
>
<rect x="6" y="5" width="4" height="14" rx="1" />
<rect x="14" y="5" width="4" height="14" rx="1" />
</svg>
);
}
function ArrowUpRight({ className }: { className?: string }) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
focusable="false"
>
<path d="M7 17L17 7" />
<path d="M8 7h9v9" />
</svg>
);
}
export default function CarMulti() {
const prefersReduced = useReducedMotion();
const uid = useId();
const slidesId = `${uid}-slides`;
const [perView, setPerView] = useState(1);
const [index, setIndex] = useState(0);
const [isPlaying, setIsPlaying] = useState(true);
const [paused, setPaused] = useState(false);
const total = SLIDES.length;
const regionRef = useRef<HTMLDivElement | null>(null);
const pointerStartX = useRef<number | null>(null);
const didSwipe = useRef(false);
// Responsive items-per-view via matchMedia (1 / 2 / 3).
useEffect(() => {
const wide = window.matchMedia("(min-width: 1024px)");
const mid = window.matchMedia("(min-width: 640px)");
const update = () => setPerView(wide.matches ? 3 : mid.matches ? 2 : 1);
update();
wide.addEventListener("change", update);
mid.addEventListener("change", update);
return () => {
wide.removeEventListener("change", update);
mid.removeEventListener("change", update);
};
}, []);
const { pages, maxIndex, pageStarts } = useMemo(() => {
const maxI = Math.max(0, total - perView);
const pg = Math.max(1, Math.ceil(total / perView));
const starts = Array.from({ length: pg }, (_, p) => Math.min(p * perView, maxI));
return { pages: pg, maxIndex: maxI, pageStarts: starts };
}, [total, perView]);
// Keep the active index valid when the viewport (and perView) changes.
useEffect(() => {
setIndex((i) => Math.min(i, maxIndex));
}, [maxIndex]);
const activePage = useMemo(() => {
let best = 0;
for (let p = 0; p < pageStarts.length; p += 1) {
if (Math.abs(pageStarts[p] - index) < Math.abs(pageStarts[best] - index)) best = p;
}
return best;
}, [pageStarts, index]);
const atStart = index <= 0;
const atEnd = index >= maxIndex;
const goTo = (i: number) => setIndex(Math.min(Math.max(i, 0), maxIndex));
const goNext = () => setIndex((i) => Math.min(i + perView, maxIndex));
const goPrev = () => setIndex((i) => Math.max(i - perView, 0));
// Autoplay: advance one page, wrapping from the last page back to the first.
useEffect(() => {
if (!isPlaying || paused || prefersReduced) return;
const id = window.setInterval(() => {
setIndex((i) => (i >= maxIndex ? 0 : Math.min(i + perView, maxIndex)));
}, AUTOPLAY_MS);
return () => window.clearInterval(id);
}, [isPlaying, paused, prefersReduced, perView, maxIndex]);
const onKeyDown = (e: ReactKeyboardEvent<HTMLDivElement>) => {
switch (e.key) {
case "ArrowRight":
e.preventDefault();
goNext();
break;
case "ArrowLeft":
e.preventDefault();
goPrev();
break;
case "Home":
e.preventDefault();
goTo(0);
break;
case "End":
e.preventDefault();
goTo(maxIndex);
break;
default:
break;
}
};
const onPointerDown = (e: ReactPointerEvent<HTMLDivElement>) => {
pointerStartX.current = e.clientX;
didSwipe.current = false;
};
const onPointerUp = (e: ReactPointerEvent<HTMLDivElement>) => {
if (pointerStartX.current === null) return;
const dx = e.clientX - pointerStartX.current;
pointerStartX.current = null;
if (Math.abs(dx) > 48) {
didSwipe.current = true;
if (dx < 0) goNext();
else goPrev();
}
};
const cancelSwipeClick = (e: ReactPointerEvent<HTMLDivElement>) => {
if (didSwipe.current) {
e.preventDefault();
e.stopPropagation();
didSwipe.current = false;
}
};
const onRegionFocus = () => setPaused(true);
const onRegionBlur = (e: ReactFocusEvent<HTMLDivElement>) => {
if (regionRef.current && !regionRef.current.contains(e.relatedTarget as Node | null)) {
setPaused(false);
}
};
const offsetPercent = index * (100 / perView);
const liveMode = isPlaying && !paused && !prefersReduced ? "off" : "polite";
const from = total === 0 ? 0 : index + 1;
const to = Math.min(index + perView, total);
const controlBtn =
"inline-flex size-11 items-center justify-center rounded-full border border-slate-300 bg-white text-slate-700 transition-colors hover:bg-slate-100 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-slate-700 dark:bg-slate-900 dark:text-slate-200 dark:hover:bg-slate-800 dark:focus-visible:ring-offset-slate-950";
return (
<section className="relative w-full overflow-hidden bg-white py-20 sm:py-24 dark:bg-slate-950">
<style>{`
@keyframes carmulti-progress { from { transform: scaleX(0); } to { transform: scaleX(1); } }
@keyframes carmulti-rise { from { opacity: 0; transform: translateY(12px); } to { opacity: 1; transform: translateY(0); } }
.carmulti-progress { animation-name: carmulti-progress; animation-timing-function: linear; animation-fill-mode: forwards; transform-origin: left center; }
.carmulti-rise { animation: carmulti-rise 0.6s ease-out both; }
@media (prefers-reduced-motion: reduce) {
.carmulti-progress, .carmulti-rise { animation: none !important; }
}
`}</style>
<div
aria-hidden="true"
className="pointer-events-none absolute inset-x-0 top-0 h-72 bg-gradient-to-b from-indigo-50 to-transparent dark:from-indigo-950/40"
/>
<div className="relative mx-auto max-w-6xl px-6">
<div className="carmulti-rise flex flex-col gap-6 sm:flex-row sm:items-end sm:justify-between">
<div className="max-w-xl">
<p className="text-sm font-semibold uppercase tracking-widest text-indigo-600 dark:text-indigo-400">
Selected work
</p>
<h2 className="mt-3 text-3xl font-bold tracking-tight text-slate-900 sm:text-4xl dark:text-white">
Case studies from the studio floor
</h2>
<p className="mt-4 text-base leading-relaxed text-slate-600 dark:text-slate-300">
Eight recent builds where research, design, and engineering shipped
together. Use the arrows, the dots, your arrow keys, or a swipe to browse.
</p>
</div>
<div className="flex items-center gap-2" aria-label="Carousel controls" role="group">
<button
type="button"
onClick={() => setIsPlaying((p) => !p)}
disabled={Boolean(prefersReduced)}
aria-label={isPlaying ? "Pause automatic slideshow" : "Start automatic slideshow"}
className={controlBtn}
>
{isPlaying ? <PauseIcon className="size-4" /> : <PlayIcon className="size-4" />}
</button>
<button
type="button"
onClick={goPrev}
disabled={atStart}
aria-label="Previous slides"
aria-controls={slidesId}
className={controlBtn}
>
<ChevronLeft className="size-5" />
</button>
<button
type="button"
onClick={goNext}
disabled={atEnd}
aria-label="Next slides"
aria-controls={slidesId}
className={controlBtn}
>
<ChevronRight className="size-5" />
</button>
</div>
</div>
<div
ref={regionRef}
role="group"
aria-roledescription="carousel"
aria-label="Case studies"
onKeyDown={onKeyDown}
onMouseEnter={() => setPaused(true)}
onMouseLeave={() => setPaused(false)}
onFocus={onRegionFocus}
onBlur={onRegionBlur}
className="mt-10"
>
<div
id={slidesId}
className="overflow-hidden rounded-2xl"
aria-atomic="false"
aria-live={liveMode}
onPointerDown={onPointerDown}
onPointerUp={onPointerUp}
onClickCapture={cancelSwipeClick}
>
<motion.ul
role="presentation"
className="flex flex-nowrap"
initial={false}
animate={{ x: `${-offsetPercent}%` }}
transition={
prefersReduced
? { duration: 0 }
: { type: "spring", stiffness: 220, damping: 32, mass: 0.9 }
}
>
{SLIDES.map((slide, i) => (
<li
key={slide.id}
role="group"
aria-roledescription="slide"
aria-label={`${i + 1} of ${total}`}
style={{ flexBasis: `${100 / perView}%` }}
className="box-border shrink-0 grow-0 px-3 py-2"
>
<article className="group flex h-full flex-col overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm transition-shadow duration-300 hover:shadow-xl dark:border-slate-800 dark:bg-slate-900">
<div className="relative aspect-[16/10] overflow-hidden bg-slate-100 dark:bg-slate-800">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={`/img/gallery/${slide.img}`}
alt={`${slide.title} — ${slide.category} case study preview`}
loading="lazy"
draggable={false}
className="h-full w-full object-cover transition-transform duration-500 ease-out group-hover:scale-[1.05]"
/>
<span className="absolute left-3 top-3 rounded-full bg-white/90 px-3 py-1 text-xs font-semibold text-slate-800 backdrop-blur dark:bg-slate-950/80 dark:text-slate-100">
{slide.category}
</span>
</div>
<div className="flex flex-1 flex-col gap-3 p-5">
<p className="text-xs font-semibold uppercase tracking-wider text-indigo-600 dark:text-indigo-400">
{slide.discipline}
</p>
<h3 className="text-lg font-semibold text-slate-900 dark:text-white">
{slide.title}
</h3>
<p className="text-sm leading-relaxed text-slate-600 dark:text-slate-300">
{slide.blurb}
</p>
<a
href={`#${slide.id}`}
aria-label={`View the ${slide.title} case study`}
className="mt-auto inline-flex items-center gap-1.5 pt-2 text-sm font-semibold text-slate-900 transition-colors hover:text-indigo-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:text-white dark:hover:text-indigo-400 dark:focus-visible:ring-offset-slate-900"
>
View case study
<ArrowUpRight className="size-4 transition-transform duration-300 group-hover:translate-x-0.5 group-hover:-translate-y-0.5" />
</a>
</div>
</article>
</li>
))}
</motion.ul>
</div>
{!prefersReduced && (
<div
aria-hidden="true"
className="mt-6 h-1 w-full overflow-hidden rounded-full bg-slate-200 dark:bg-slate-800"
>
{isPlaying && !paused ? (
<div
key={`${activePage}-${isPlaying}`}
className="carmulti-progress h-full w-full rounded-full bg-indigo-500 dark:bg-indigo-400"
style={{ animationDuration: `${AUTOPLAY_MS}ms` }}
/>
) : (
<div
className="h-full rounded-full bg-indigo-400/50 dark:bg-indigo-500/50"
style={{ width: `${((activePage + 1) / pages) * 100}%` }}
/>
)}
</div>
)}
</div>
<div className="mt-8 flex items-center justify-between gap-4">
<div className="flex items-center gap-2" role="group" aria-label="Choose slide group">
{pageStarts.map((start, p) => {
const active = activePage === p;
return (
<button
key={start + "-" + p}
type="button"
onClick={() => goTo(start)}
aria-label={`Go to slide group ${p + 1} of ${pages}`}
aria-current={active ? "true" : undefined}
aria-controls={slidesId}
className={
"h-2.5 rounded-full transition-all duration-300 focus-visible: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 " +
(active
? "w-7 bg-indigo-500 dark:bg-indigo-400"
: "w-2.5 bg-slate-300 hover:bg-slate-400 dark:bg-slate-700 dark:hover:bg-slate-600")
}
/>
);
})}
</div>
<p className="text-sm tabular-nums text-slate-500 dark:text-slate-400" aria-live={liveMode}>
Showing{" "}
<span className="font-semibold text-slate-800 dark:text-slate-200">
{from}–{to}
</span>{" "}
of {total}
</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

Center Focus Carousel
Originalcentre-focused scaling carousel

Progress Carousel
Originalcarousel with a progress bar

Testimonial Carousel
Originaltestimonial quote carousel

Logos Carousel
Originallogo carousel strip

