Web InnoventixFreeCode

Vertical Carousel

Original · free

vertical carousel

byWeb InnoventixReact + Tailwind
carverticalcarousels
Open

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.json
car-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&apos;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">
                    &ldquo;{current.quote}&rdquo;
                  </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 quote

Similar components

Browse all →