Vertical Carousel
Original · freevertical carousel
byWeb InnoventixReact + Tailwind
carverticalcarousels
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-vertical.jsoncar-vertical.tsx
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import type {
FocusEvent as ReactFocusEvent,
KeyboardEvent as ReactKeyboardEvent,
} from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import type { Transition, Variants } from "motion/react";
type Story = {
quote: string;
name: string;
role: string;
company: string;
metric: string;
metricLabel: string;
rating: number;
initials: string;
accent: string;
};
const STORIES: Story[] = [
{
quote:
"We replaced three disconnected tools with one workflow and shipped our first customer release in six weeks. The rollout was so quiet our support queue never noticed the switch.",
name: "Priya Nadkarni",
role: "VP Engineering",
company: "Loomwork",
metric: "6 wks",
metricLabel: "to first release",
rating: 5,
initials: "PN",
accent: "from-indigo-500 to-violet-500",
},
{
quote:
"Design-to-dev handoff used to eat a full day per screen. Now the spec travels with the component, and our engineers stopped pinging us for redlines entirely.",
name: "Daniel Okafor",
role: "Head of Design",
company: "Cartography Studio",
metric: "3.4x",
metricLabel: "faster handoffs",
rating: 5,
initials: "DO",
accent: "from-emerald-500 to-sky-500",
},
{
quote:
"We cut two vendor contracts after the first quarter. The team finally trusts a single source of truth, and the finance review that took a week now closes in an afternoon.",
name: "Mara Lindqvist",
role: "Product Lead",
company: "Northtide Labs",
metric: "$210k",
metricLabel: "saved per year",
rating: 5,
initials: "ML",
accent: "from-amber-500 to-rose-500",
},
{
quote:
"Onboarding a regulated workload made me nervous, but the audit trail answered every question our compliance reviewers had before they even finished asking it.",
name: "Sofía Herrera",
role: "CTO",
company: "Bandera Health",
metric: "99.98%",
metricLabel: "uptime in 2025",
rating: 5,
initials: "SH",
accent: "from-sky-500 to-indigo-500",
},
{
quote:
"As a two-person team we needed software that wouldn't fight us. It scaled from a dozen accounts to a few hundred without a single late-night migration.",
name: "Kenji Watanabe",
role: "Founder",
company: "Paperlane",
metric: "12 → 240",
metricLabel: "customers in a year",
rating: 5,
initials: "KW",
accent: "from-rose-500 to-violet-500",
},
];
const AUTOPLAY_MS = 6000;
const TOTAL = STORIES.length;
function pad(n: number): string {
return String(n).padStart(2, "0");
}
function StarRow({ rating }: { rating: number }) {
return (
<div className="flex items-center gap-0.5" aria-label={`Rated ${rating} out of 5`}>
{Array.from({ length: 5 }).map((_, i) => (
<svg
key={i}
viewBox="0 0 20 20"
className={
i < rating
? "h-4 w-4 fill-amber-400"
: "h-4 w-4 fill-slate-300 dark:fill-slate-600"
}
aria-hidden="true"
>
<path d="M10 1.5l2.47 5.01 5.53.8-4 3.9.94 5.51L10 14.98 5.06 16.72 6 11.21l-4-3.9 5.53-.8L10 1.5z" />
</svg>
))}
</div>
);
}
export default function CarVertical() {
const reduce = useReducedMotion() ?? false;
const [[index, direction], setState] = useState<[number, number]>([0, 0]);
const [playing, setPlaying] = useState(true);
const [hover, setHover] = useState(false);
const [focused, setFocused] = useState(false);
const regionRef = useRef<HTMLDivElement | null>(null);
const paused = hover || focused;
const current = STORIES[index];
const paginate = useCallback((dir: number) => {
setState(([i]) => [(i + dir + TOTAL) % TOTAL, dir]);
}, []);
const goTo = useCallback((target: number) => {
setState(([i]) => [target, target >= i ? 1 : -1]);
}, []);
useEffect(() => {
if (!playing || paused || reduce) return;
const id = window.setTimeout(() => paginate(1), AUTOPLAY_MS);
return () => window.clearTimeout(id);
}, [index, playing, paused, reduce, paginate]);
const onKeyDown = (e: ReactKeyboardEvent<HTMLDivElement>) => {
if (e.key === "ArrowDown" || e.key === "ArrowRight") {
e.preventDefault();
paginate(1);
} else if (e.key === "ArrowUp" || e.key === "ArrowLeft") {
e.preventDefault();
paginate(-1);
} else if (e.key === "Home") {
e.preventDefault();
goTo(0);
} else if (e.key === "End") {
e.preventDefault();
goTo(TOTAL - 1);
}
};
const onBlurCapture = (e: ReactFocusEvent<HTMLDivElement>) => {
if (!regionRef.current?.contains(e.relatedTarget as Node | null)) {
setFocused(false);
}
};
const transition: Transition = reduce
? { duration: 0 }
: {
type: "spring",
stiffness: 300,
damping: 32,
mass: 0.9,
opacity: { duration: 0.25 },
};
const variants: Variants = {
enter: (dir: number) => ({ y: reduce ? 0 : dir > 0 ? 72 : -72, opacity: 0 }),
center: { y: 0, opacity: 1 },
exit: (dir: number) => ({ y: reduce ? 0 : dir > 0 ? -72 : 72, opacity: 0 }),
};
const running = playing && !paused && !reduce;
const progressKey = `${index}-${running ? "on" : "off"}`;
const iconBtn =
"inline-flex h-11 w-11 items-center justify-center rounded-full border border-slate-300 bg-white text-slate-700 shadow-sm transition-colors hover:border-slate-400 hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white active:scale-95 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-200 dark:hover:border-slate-500 dark:hover:bg-slate-700 dark:focus-visible:ring-offset-slate-950";
return (
<section className="relative w-full overflow-hidden bg-gradient-to-b from-slate-50 to-white px-6 py-20 sm:py-24 dark:from-slate-950 dark:to-slate-900">
<style>{`
@keyframes carv-live-pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.35; transform: scale(0.7); }
}
.carv-live { animation: carv-live-pulse 1.8s ease-in-out infinite; }
@media (prefers-reduced-motion: reduce) {
.carv-live { animation: none; }
}
`}</style>
<div className="pointer-events-none absolute -top-24 left-1/2 h-72 w-72 -translate-x-1/2 rounded-full bg-indigo-400/20 blur-3xl dark:bg-indigo-500/10" />
<div className="relative mx-auto grid max-w-6xl items-center gap-12 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.1fr)] lg:gap-16">
{/* Left: heading + controls */}
<div className="max-w-md">
<span className="inline-flex items-center gap-2 rounded-full border border-slate-200 bg-white px-3 py-1 text-xs font-medium uppercase tracking-wide text-slate-600 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300">
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500" />
Customer stories
</span>
<h2 className="mt-5 text-3xl font-semibold leading-tight tracking-tight text-slate-900 sm:text-4xl dark:text-white">
Teams don't just adopt it. They stop looking for alternatives.
</h2>
<p className="mt-4 text-base leading-relaxed text-slate-600 dark:text-slate-400">
Real results from the people who moved their daily work onto one
platform. Use the arrows, dots, or your arrow keys to move through
the wall.
</p>
<div className="mt-8 flex flex-wrap items-center gap-3">
<button
type="button"
onClick={() => paginate(-1)}
aria-label="Previous story"
className={iconBtn}
>
<svg viewBox="0 0 20 20" className="h-5 w-5" fill="none" aria-hidden="true">
<path
d="M5 12.5L10 7l5 5.5"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
<button
type="button"
onClick={() => paginate(1)}
aria-label="Next story"
className={iconBtn}
>
<svg viewBox="0 0 20 20" className="h-5 w-5" fill="none" aria-hidden="true">
<path
d="M5 7.5L10 13l5-5.5"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
<button
type="button"
onClick={() => setPlaying((p) => !p)}
aria-pressed={playing}
aria-label={playing ? "Pause autoplay" : "Start autoplay"}
className={iconBtn}
>
{playing ? (
<svg viewBox="0 0 20 20" className="h-4 w-4 fill-current" aria-hidden="true">
<rect x="5" y="4" width="3.5" height="12" rx="1" />
<rect x="11.5" y="4" width="3.5" height="12" rx="1" />
</svg>
) : (
<svg viewBox="0 0 20 20" className="h-4 w-4 fill-current" aria-hidden="true">
<path d="M6 4.5l9 5.5-9 5.5v-11z" />
</svg>
)}
</button>
<div className="ml-1 flex items-center gap-2 text-sm font-medium tabular-nums text-slate-500 dark:text-slate-400">
<span className="text-slate-900 dark:text-white">{pad(index + 1)}</span>
<span aria-hidden="true">/</span>
<span>{pad(TOTAL)}</span>
{running ? (
<span className="carv-live ml-1 h-1.5 w-1.5 rounded-full bg-emerald-500" aria-hidden="true" />
) : null}
</div>
</div>
</div>
{/* Right: carousel */}
<div
ref={regionRef}
role="group"
aria-roledescription="carousel"
aria-label="Customer stories"
tabIndex={0}
onKeyDown={onKeyDown}
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
onFocusCapture={() => setFocused(true)}
onBlurCapture={onBlurCapture}
className="rounded-[2rem] border border-slate-200 bg-white/60 p-4 backdrop-blur focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-4 focus-visible:ring-offset-slate-50 sm:p-5 dark:border-slate-800 dark:bg-slate-900/50 dark:focus-visible:ring-offset-slate-950"
>
<div className="flex items-stretch gap-4 sm:gap-5">
{/* Progress rail */}
<div
className="relative w-1 shrink-0 self-stretch overflow-hidden rounded-full bg-slate-200 dark:bg-slate-700"
aria-hidden="true"
>
<motion.div
key={progressKey}
className="absolute inset-x-0 top-0 rounded-full bg-gradient-to-b from-indigo-500 to-violet-500"
initial={{ height: reduce ? "100%" : "0%" }}
animate={{ height: running ? "100%" : reduce ? "100%" : "0%" }}
transition={{ duration: running ? AUTOPLAY_MS / 1000 : 0, ease: "linear" }}
/>
</div>
{/* Viewport */}
<div className="relative h-96 flex-1 overflow-hidden sm:h-[22rem]">
<AnimatePresence custom={direction} initial={false}>
<motion.figure
key={index}
custom={direction}
variants={variants}
initial="enter"
animate="center"
exit="exit"
transition={transition}
className="absolute inset-0 flex flex-col justify-between rounded-3xl border border-slate-200 bg-white p-6 shadow-sm sm:p-8 dark:border-slate-700/70 dark:bg-slate-900"
>
<div className="flex items-start justify-between gap-4">
<svg
viewBox="0 0 40 40"
className="h-9 w-9 fill-indigo-500/90 dark:fill-indigo-400/90"
aria-hidden="true"
>
<path d="M16 8c-5.5 0-10 4.7-10 10.5V32h12V19h-6c0-3.2 2.2-5.5 4.7-5.9L16 8zm18 0c-5.5 0-10 4.7-10 10.5V32h12V19h-6c0-3.2 2.2-5.5 4.7-5.9L34 8z" />
</svg>
<StarRow rating={current.rating} />
</div>
<blockquote className="mt-5 text-lg font-medium leading-relaxed text-slate-800 sm:text-xl dark:text-slate-100">
“{current.quote}”
</blockquote>
<figcaption className="mt-6 flex items-center gap-4 border-t border-slate-100 pt-5 dark:border-slate-800">
<span
className={`inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-full bg-gradient-to-br ${current.accent} text-sm font-semibold text-white shadow-sm`}
aria-hidden="true"
>
{current.initials}
</span>
<div className="min-w-0">
<div className="truncate text-sm font-semibold text-slate-900 dark:text-white">
{current.name}
</div>
<div className="truncate text-sm text-slate-500 dark:text-slate-400">
{current.role} · {current.company}
</div>
</div>
<div className="ml-auto shrink-0 text-right">
<div className="text-xl font-semibold tabular-nums text-slate-900 sm:text-2xl dark:text-white">
{current.metric}
</div>
<div className="text-xs text-slate-500 dark:text-slate-400">
{current.metricLabel}
</div>
</div>
</figcaption>
</motion.figure>
</AnimatePresence>
</div>
{/* Dots */}
<div className="flex shrink-0 flex-col items-center justify-center gap-2.5">
{STORIES.map((s, i) => {
const active = i === index;
return (
<button
key={s.name}
type="button"
onClick={() => goTo(i)}
aria-label={`Show story ${i + 1}: ${s.name}`}
aria-current={active}
className={`w-2 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-900 ${
active
? "h-7 bg-indigo-500 dark:bg-indigo-400"
: "h-2 bg-slate-300 hover:bg-slate-400 dark:bg-slate-600 dark:hover:bg-slate-500"
}`}
/>
);
})}
</div>
</div>
<div aria-live="polite" className="sr-only">
Slide {index + 1} of {TOTAL}: {current.name}, {current.role} at{" "}
{current.company}
</div>
</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

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

