Web InnoventixFreeCode

Testimonial Carousel

Original · free

testimonial quote carousel

byWeb InnoventixReact + Tailwind
cartestimonialcarousels
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-testimonial.json
car-testimonial.tsx
"use client";

import { useCallback, useEffect, useRef, useState } from "react";
import { AnimatePresence, motion, useReducedMotion, type PanInfo } from "motion/react";

type Testimonial = {
  quote: string;
  name: string;
  role: string;
  company: string;
  rating: number;
  from: string;
  to: string;
  accentText: string;
};

const TESTIMONIALS: Testimonial[] = [
  {
    quote:
      "We cut our monthly close from nine days to three. The reconciliation view alone paid for the first year inside a single quarter — my team stopped dreading the last week of the month.",
    name: "Priya Nair",
    role: "VP of Finance",
    company: "Meridian Logistics",
    rating: 5,
    from: "from-indigo-500",
    to: "to-violet-500",
    accentText: "text-indigo-500 dark:text-indigo-400",
  },
  {
    quote:
      "Onboarding used to eat a full week of engineering time per client. Now it's a guided flow our success team runs without us. That gap is the difference between shipping features and firefighting.",
    name: "Daniel Okafor",
    role: "Staff Engineer",
    company: "Cavalry Health",
    rating: 5,
    from: "from-emerald-500",
    to: "to-sky-500",
    accentText: "text-emerald-500 dark:text-emerald-400",
  },
  {
    quote:
      "I was bracing for a painful migration of forty thousand records. The importer mapped every field correctly on the first pass, and we did zero manual cleanup afterward. I genuinely didn't believe it at first.",
    name: "Sofia Marchetti",
    role: "Head of Operations",
    company: "Brightline Studios",
    rating: 5,
    from: "from-rose-500",
    to: "to-amber-500",
    accentText: "text-rose-500 dark:text-rose-400",
  },
  {
    quote:
      "When launch traffic spiked twelve-times overnight, nothing broke and nobody had to page me at 3 a.m. Support answers in minutes, not tickets. That reliability is why we renewed for three years.",
    name: "Marcus Bell",
    role: "Chief Technology Officer",
    company: "Tidewater Labs",
    rating: 5,
    from: "from-sky-500",
    to: "to-indigo-500",
    accentText: "text-sky-500 dark:text-sky-400",
  },
  {
    quote:
      "It's the first reporting our board actually reads. I stopped exporting to spreadsheets three months ago and I am never going back. Decisions that used to take a week now happen in the room.",
    name: "Aisha Rahman",
    role: "Founder & CEO",
    company: "Kestrel Commerce",
    rating: 5,
    from: "from-violet-500",
    to: "to-rose-500",
    accentText: "text-violet-500 dark:text-violet-400",
  },
];

const COUNT = TESTIMONIALS.length;
const DURATION = 6500;

function initials(name: string): string {
  return name
    .split(" ")
    .map((word) => word[0])
    .slice(0, 2)
    .join("");
}

export default function CarTestimonial() {
  const reduceMotion = useReducedMotion();

  const [index, setIndex] = useState(0);
  const [direction, setDirection] = useState(1);
  const [userPaused, setUserPaused] = useState(false);
  const [hovering, setHovering] = useState(false);
  const [focusWithin, setFocusWithin] = useState(false);
  const [progress, setProgress] = useState(0);

  const elapsedRef = useRef(0);
  const indexRef = useRef(index);
  indexRef.current = index;

  const playing = !userPaused && !hovering && !focusWithin && !reduceMotion;

  const goTo = useCallback((target: number, dir: number) => {
    setDirection(dir);
    setIndex(((target % COUNT) + COUNT) % COUNT);
    elapsedRef.current = 0;
    setProgress(0);
  }, []);

  const next = useCallback(() => goTo(indexRef.current + 1, 1), [goTo]);
  const prev = useCallback(() => goTo(indexRef.current - 1, -1), [goTo]);

  useEffect(() => {
    if (!playing) return;
    let raf = 0;
    let last = performance.now();
    const tick = (now: number) => {
      const dt = now - last;
      last = now;
      elapsedRef.current += dt;
      if (elapsedRef.current >= DURATION) {
        elapsedRef.current = 0;
        setProgress(0);
        goTo(indexRef.current + 1, 1);
      } else {
        setProgress(elapsedRef.current / DURATION);
      }
      raf = requestAnimationFrame(tick);
    };
    raf = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(raf);
  }, [playing, goTo]);

  const handleDragEnd = (
    _event: MouseEvent | TouchEvent | PointerEvent,
    info: PanInfo,
  ) => {
    const threshold = 64;
    if (info.offset.x < -threshold || info.velocity.x < -420) next();
    else if (info.offset.x > threshold || info.velocity.x > 420) prev();
  };

  const active = TESTIMONIALS[index];
  const distance = reduceMotion ? 0 : 52;

  return (
    <section
      aria-labelledby="car-testimonial-heading"
      className="relative w-full overflow-hidden bg-slate-50 px-6 py-20 text-slate-900 dark:bg-slate-950 dark:text-slate-100 sm:py-28"
    >
      <style>{`
        @keyframes carTestimonial-blob {
          0%, 100% { transform: translate3d(0, 0, 0) scale(1); }
          50% { transform: translate3d(18px, -22px, 0) scale(1.12); }
        }
        @keyframes carTestimonial-sheen {
          0% { background-position: 0% 50%; }
          100% { background-position: 200% 50%; }
        }
        .carTestimonial-blob { animation: carTestimonial-blob 16s ease-in-out infinite; }
        .carTestimonial-sheen {
          background-size: 200% 100%;
          animation: carTestimonial-sheen 3.2s linear infinite;
        }
        @media (prefers-reduced-motion: reduce) {
          .carTestimonial-blob, .carTestimonial-sheen { animation: none !important; }
        }
      `}</style>

      {/* Ambient background */}
      <div aria-hidden="true" className="pointer-events-none absolute inset-0 overflow-hidden">
        <div className="carTestimonial-blob absolute -left-24 -top-24 h-72 w-72 rounded-full bg-indigo-300/40 blur-3xl dark:bg-indigo-600/20" />
        <div className="carTestimonial-blob absolute -bottom-32 right-0 h-80 w-80 rounded-full bg-violet-300/40 blur-3xl dark:bg-violet-700/20" />
        <div
          className="absolute inset-0 opacity-[0.15] dark:opacity-[0.08]"
          style={{
            backgroundImage:
              "radial-gradient(circle at 1px 1px, currentColor 1px, transparent 0)",
            backgroundSize: "28px 28px",
            color: "rgb(100 116 139)",
          }}
        />
      </div>

      <div className="relative mx-auto max-w-3xl">
        {/* Header */}
        <div className="mb-10 flex flex-col items-center text-center">
          <span className="inline-flex items-center gap-2 rounded-full border border-slate-200 bg-white/70 px-3.5 py-1.5 text-xs font-semibold uppercase tracking-[0.18em] text-slate-600 shadow-sm backdrop-blur dark:border-slate-800 dark:bg-slate-900/70 dark:text-slate-300">
            <span className="h-1.5 w-1.5 rounded-full bg-emerald-500" />
            Customer stories
          </span>
          <h2
            id="car-testimonial-heading"
            className="mt-5 text-balance text-3xl font-bold tracking-tight sm:text-4xl"
          >
            Teams that shipped faster after the switch
          </h2>
          <p className="mt-3 max-w-xl text-pretty text-base text-slate-500 dark:text-slate-400">
            Real words from the operators, engineers and founders who run their day-to-day on the platform.
          </p>
        </div>

        {/* Carousel */}
        <div
          role="region"
          aria-roledescription="carousel"
          aria-label="Customer testimonials"
          tabIndex={0}
          onMouseEnter={() => setHovering(true)}
          onMouseLeave={() => setHovering(false)}
          onFocus={() => setFocusWithin(true)}
          onBlur={(e) => {
            if (!e.currentTarget.contains(e.relatedTarget as Node | null)) {
              setFocusWithin(false);
            }
          }}
          onKeyDown={(e) => {
            if (e.key === "ArrowLeft") {
              e.preventDefault();
              prev();
            } else if (e.key === "ArrowRight") {
              e.preventDefault();
              next();
            } else if (e.key === "Home") {
              e.preventDefault();
              goTo(0, -1);
            } else if (e.key === "End") {
              e.preventDefault();
              goTo(COUNT - 1, 1);
            }
          }}
          className="group relative rounded-3xl border border-slate-200 bg-white/80 p-1 shadow-[0_20px_60px_-30px_rgba(15,23,42,0.35)] outline-none backdrop-blur-xl transition focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-50 dark:border-slate-800 dark:bg-slate-900/70 dark:shadow-[0_20px_60px_-30px_rgba(0,0,0,0.8)] dark:focus-visible:ring-offset-slate-950"
        >
          {/* Top accent bar */}
          <div className="mx-1 h-1 overflow-hidden rounded-full">
            <div
              className={`carTestimonial-sheen h-full w-full bg-gradient-to-r ${active.from} ${active.to}`}
            />
          </div>

          <div
            className="relative min-h-[360px] px-6 py-8 sm:min-h-[320px] sm:px-12 sm:py-12"
            aria-live={playing ? "off" : "polite"}
            aria-atomic="true"
          >
            {/* Decorative quote mark */}
            <svg
              aria-hidden="true"
              viewBox="0 0 48 48"
              className="absolute right-6 top-6 h-16 w-16 text-slate-100 dark:text-slate-800 sm:right-10 sm:top-8 sm:h-24 sm:w-24"
              fill="currentColor"
            >
              <path d="M18 10C11.4 12.6 7 18.9 7 26.5V38h14V24h-7c0-3.9 2.4-7.2 6-8.7L18 10Zm23 0c-6.6 2.6-11 8.9-11 16.5V38h14V24h-7c0-3.9 2.4-7.2 6-8.7L41 10Z" />
            </svg>

            <AnimatePresence mode="wait" custom={direction}>
              <motion.blockquote
                key={index}
                custom={direction}
                initial={{ opacity: 0, x: distance * direction }}
                animate={{ opacity: 1, x: 0 }}
                exit={{ opacity: 0, x: -distance * direction }}
                transition={{ duration: reduceMotion ? 0 : 0.42, ease: "easeOut" }}
                drag={reduceMotion ? false : "x"}
                dragConstraints={{ left: 0, right: 0 }}
                dragElastic={0.18}
                dragMomentum={false}
                onDragEnd={handleDragEnd}
                className="relative touch-pan-y select-none"
              >
                {/* Stars */}
                <div className="mb-5 flex items-center gap-1" aria-label={`Rated ${active.rating} out of 5`}>
                  {Array.from({ length: 5 }).map((_, s) => (
                    <svg
                      key={s}
                      aria-hidden="true"
                      viewBox="0 0 20 20"
                      className={`h-4 w-4 ${
                        s < active.rating
                          ? active.accentText
                          : "text-slate-200 dark:text-slate-700"
                      }`}
                      fill="currentColor"
                    >
                      <path d="M10 1.6l2.47 5.01 5.53.8-4 3.9.94 5.5L10 14.2l-4.94 2.6.94-5.5-4-3.9 5.53-.8L10 1.6Z" />
                    </svg>
                  ))}
                </div>

                <p className="text-pretty text-xl font-medium leading-relaxed text-slate-800 dark:text-slate-100 sm:text-2xl sm:leading-relaxed">
                  &ldquo;{active.quote}&rdquo;
                </p>

                <footer className="mt-8 flex items-center gap-4">
                  <span
                    aria-hidden="true"
                    className={`flex h-12 w-12 shrink-0 items-center justify-center rounded-2xl bg-gradient-to-br ${active.from} ${active.to} text-sm font-bold text-white shadow-lg ring-2 ring-white/70 dark:ring-white/10`}
                  >
                    {initials(active.name)}
                  </span>
                  <span className="flex flex-col">
                    <cite className="text-base font-semibold not-italic text-slate-900 dark:text-white">
                      {active.name}
                    </cite>
                    <span className="text-sm text-slate-500 dark:text-slate-400">
                      {active.role}, {active.company}
                    </span>
                  </span>
                </footer>
              </motion.blockquote>
            </AnimatePresence>
          </div>

          {/* Progress track */}
          {!reduceMotion && (
            <div
              aria-hidden="true"
              className="mx-6 mb-1 h-1 overflow-hidden rounded-full bg-slate-200 dark:bg-slate-800 sm:mx-12"
            >
              <div
                className={`h-full origin-left rounded-full bg-gradient-to-r ${active.from} ${active.to}`}
                style={{ transform: `scaleX(${progress})` }}
              />
            </div>
          )}
        </div>

        {/* Controls */}
        <div className="mt-7 flex items-center justify-between gap-4">
          <div className="flex items-center gap-2">
            <button
              type="button"
              onClick={prev}
              aria-label="Previous testimonial"
              className="flex h-11 w-11 items-center justify-center rounded-full border border-slate-200 bg-white text-slate-700 shadow-sm transition hover:-translate-y-0.5 hover:border-slate-300 hover:text-slate-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-50 active:translate-y-0 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300 dark:hover:text-white dark:focus-visible:ring-offset-slate-950"
            >
              <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 18l-6-6 6-6" />
              </svg>
            </button>
            <button
              type="button"
              onClick={next}
              aria-label="Next testimonial"
              className="flex h-11 w-11 items-center justify-center rounded-full border border-slate-200 bg-white text-slate-700 shadow-sm transition hover:-translate-y-0.5 hover:border-slate-300 hover:text-slate-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-50 active:translate-y-0 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300 dark:hover:text-white dark:focus-visible:ring-offset-slate-950"
            >
              <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 18l6-6-6-6" />
              </svg>
            </button>
          </div>

          {/* Dots */}
          <div className="flex items-center gap-2.5" role="group" aria-label="Choose testimonial">
            {TESTIMONIALS.map((t, i) => {
              const isActive = i === index;
              return (
                <button
                  key={t.name}
                  type="button"
                  onClick={() => goTo(i, i > index ? 1 : -1)}
                  aria-label={`Show testimonial from ${t.name}`}
                  aria-current={isActive ? "true" : undefined}
                  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-slate-50 dark:focus-visible:ring-offset-slate-950 ${
                    isActive
                      ? "w-7 bg-slate-900 dark:bg-white"
                      : "w-2.5 bg-slate-300 hover:bg-slate-400 dark:bg-slate-700 dark:hover:bg-slate-600"
                  }`}
                />
              );
            })}
          </div>

          {/* Play / pause + counter */}
          <div className="flex items-center gap-3">
            <span className="hidden font-mono text-sm tabular-nums text-slate-400 sm:inline dark:text-slate-500">
              {String(index + 1).padStart(2, "0")}
              <span className="text-slate-300 dark:text-slate-700"> / {String(COUNT).padStart(2, "0")}</span>
            </span>
            {!reduceMotion && (
              <button
                type="button"
                onClick={() => setUserPaused((p) => !p)}
                aria-label={userPaused ? "Start autoplay" : "Pause autoplay"}
                aria-pressed={userPaused}
                className="flex h-11 w-11 items-center justify-center rounded-full border border-slate-200 bg-white text-slate-700 shadow-sm transition hover:-translate-y-0.5 hover:text-slate-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-50 active:translate-y-0 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300 dark:hover:text-white dark:focus-visible:ring-offset-slate-950"
              >
                {userPaused ? (
                  <svg viewBox="0 0 24 24" className="h-5 w-5 translate-x-px" fill="currentColor" aria-hidden="true">
                    <path d="M8 5v14l11-7L8 5Z" />
                  </svg>
                ) : (
                  <svg viewBox="0 0 24 24" className="h-5 w-5" fill="currentColor" aria-hidden="true">
                    <path d="M7 5h3v14H7V5Zm7 0h3v14h-3V5Z" />
                  </svg>
                )}
              </button>
            )}
          </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 →