Web InnoventixFreeCode

Auto-Rotating Quote Carousel

Original · free

An auto-advancing testimonial carousel that slides and crossfades between quotes with a live progress bar, pause-on-hover, and dot plus arrow navigation.

byWeb InnoventixReact + Tailwind
animated testimonial carouselframer motion carouselauto rotating quotestestimonial slider reactpause on hover carousel
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/atest-auto-rotate-carousel.json
atest-auto-rotate-carousel.tsx
"use client";

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

const testimonials = [
  {
    quote:
      "We shipped our redesign three weeks ahead of schedule and, somehow, the whole team enjoyed the process. It is rare for a tool to change how people feel about their own work, but this one genuinely did.",
    name: "Amara Okafor",
    role: "VP Product, Northwind",
    initials: "AO",
    ring: "from-violet-500 to-indigo-500",
    rating: 5,
  },
  {
    quote:
      "The migration I had been dreading for two years took a single afternoon. Support answered like an actual human, and nothing broke. I keep waiting for the catch.",
    name: "Tobias Reinhardt",
    role: "Head of Engineering, Cadence",
    initials: "TR",
    ring: "from-sky-500 to-cyan-500",
    rating: 5,
  },
  {
    quote:
      "Our reporting used to be a weekly argument. Now the numbers are simply there, trusted by the board and the interns alike. That quiet confidence is worth more than any feature list.",
    name: "Priya Venkatesh",
    role: "CFO, Meridian",
    initials: "PV",
    ring: "from-fuchsia-500 to-pink-500",
    rating: 5,
  },
  {
    quote:
      "I have tried most of the tools in this space and they all fight you eventually. This is the first one that just gets out of the way and lets good people do good work.",
    name: "Daniel Brooks",
    role: "Founder, Palisade",
    initials: "DB",
    ring: "from-emerald-500 to-teal-500",
    rating: 5,
  },
];

const slide = {
  enter: (dir: number) => ({ opacity: 0, x: dir > 0 ? 48 : -48, scale: 0.98 }),
  center: { opacity: 1, x: 0, scale: 1 },
  exit: (dir: number) => ({ opacity: 0, x: dir > 0 ? -48 : 48, scale: 0.98 }),
};

const INTERVAL = 6500;

export default function AtestAutoRotateCarousel() {
  const reduce = useReducedMotion();
  const [index, setIndex] = useState(0);
  const [direction, setDirection] = useState(1);
  const [progress, setProgress] = useState(0);
  const [paused, setPaused] = useState(false);
  const progressRef = useRef(0);
  const count = testimonials.length;

  const goTo = (next: number, dir: number) => {
    setDirection(dir);
    setIndex((next + count) % count);
    progressRef.current = 0;
    setProgress(0);
  };

  useEffect(() => {
    if (paused || reduce) return;
    let raf = 0;
    let last = performance.now();
    const tick = (now: number) => {
      const dt = now - last;
      last = now;
      progressRef.current += (dt / INTERVAL) * 100;
      if (progressRef.current >= 100) {
        progressRef.current = 0;
        setDirection(1);
        setIndex((i) => (i + 1) % count);
      }
      setProgress(progressRef.current);
      raf = requestAnimationFrame(tick);
    };
    raf = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(raf);
  }, [paused, reduce, count]);

  const active = testimonials[index];

  return (
    <section className="relative overflow-hidden bg-zinc-50 px-6 py-20 dark:bg-zinc-950 md:py-28">
      {/* Ambient floating glow */}
      <div aria-hidden="true" className="pointer-events-none absolute inset-0 overflow-hidden">
        <motion.div
          className="absolute -left-24 top-0 h-80 w-80 rounded-full bg-violet-400/20 blur-3xl dark:bg-violet-600/20"
          animate={reduce ? undefined : { x: [0, 40, 0], y: [0, 30, 0] }}
          transition={{ duration: 14, repeat: Infinity, ease: "easeInOut" }}
        />
        <motion.div
          className="absolute -right-24 bottom-0 h-96 w-96 rounded-full bg-sky-400/20 blur-3xl dark:bg-sky-600/20"
          animate={reduce ? undefined : { x: [0, -50, 0], y: [0, -20, 0] }}
          transition={{ duration: 18, repeat: Infinity, ease: "easeInOut" }}
        />
      </div>

      <div className="relative mx-auto max-w-3xl text-center">
        <span className="inline-flex items-center gap-2 rounded-full border border-zinc-200 bg-white/70 px-4 py-1.5 text-xs font-medium tracking-wide text-zinc-600 backdrop-blur dark:border-zinc-800 dark:bg-zinc-900/70 dark:text-zinc-400">
          <span className="relative flex h-2 w-2">
            {!reduce && (
              <span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-violet-500 opacity-75" />
            )}
            <span className="relative inline-flex h-2 w-2 rounded-full bg-violet-500" />
          </span>
          Loved by 4,000+ teams
        </span>
        <h2 className="mt-5 text-3xl font-bold tracking-tight text-balance text-zinc-900 sm:text-4xl dark:text-white">
          What people say when the deadline is over
        </h2>
      </div>

      <div
        className="relative mx-auto mt-12 max-w-3xl"
        onMouseEnter={() => setPaused(true)}
        onMouseLeave={() => setPaused(false)}
        onFocusCapture={() => setPaused(true)}
        onBlurCapture={() => setPaused(false)}
      >
        <div className="relative overflow-hidden rounded-3xl border border-zinc-200 bg-white/80 p-8 shadow-xl shadow-zinc-900/5 backdrop-blur-sm sm:p-12 dark:border-zinc-800 dark:bg-zinc-900/80 dark:shadow-black/20">
          {/* Decorative quote mark */}
          <svg
            aria-hidden="true"
            viewBox="0 0 48 48"
            className="absolute right-8 top-6 h-16 w-16 fill-zinc-900/5 dark:fill-white/5"
          >
            <path d="M18 10c-6 2-10 7-10 15v13h13V25h-7c0-5 2-8 6-9l-2-6zm18 0c-6 2-10 7-10 15v13h13V25h-7c0-5 2-8 6-9l-2-6z" />
          </svg>

          <div className="relative min-h-[16rem]" aria-live="polite">
            <AnimatePresence mode="wait" custom={direction}>
              <motion.figure
                key={index}
                custom={direction}
                variants={slide}
                initial="enter"
                animate="center"
                exit="exit"
                transition={{ duration: 0.45, ease: [0.22, 1, 0.36, 1] }}
              >
                <div className="flex items-center gap-0.5" aria-label={`Rated ${active.rating} out of 5`}>
                  {[0, 1, 2, 3, 4].map((s) => (
                    <motion.svg
                      key={s}
                      viewBox="0 0 24 24"
                      className="h-5 w-5 fill-amber-400"
                      aria-hidden="true"
                      initial={reduce ? false : { opacity: 0, scale: 0.4, rotate: -30 }}
                      animate={{ opacity: 1, scale: 1, rotate: 0 }}
                      transition={{ delay: 0.15 + s * 0.06, type: "spring", stiffness: 400, damping: 18 }}
                    >
                      <path d="M11.48 3.5a.56.56 0 011.04 0l2.12 5.11a.56.56 0 00.48.35l5.52.44c.5.04.7.66.32.99l-4.2 3.6a.56.56 0 00-.19.56l1.29 5.38a.56.56 0 01-.84.61l-4.73-2.88a.56.56 0 00-.58 0l-4.73 2.88a.56.56 0 01-.84-.61l1.29-5.38a.56.56 0 00-.19-.56l-4.2-3.6a.56.56 0 01.32-.99l5.52-.44a.56.56 0 00.48-.35L11.48 3.5z" />
                    </motion.svg>
                  ))}
                </div>

                <blockquote className="mt-6 font-serif text-xl leading-relaxed text-balance text-zinc-800 sm:text-2xl dark:text-zinc-100">
                  {active.quote}
                </blockquote>

                <figcaption className="mt-8 flex items-center gap-4">
                  <span
                    className={`grid h-12 w-12 shrink-0 place-items-center rounded-full bg-gradient-to-br ${active.ring} text-sm font-semibold text-white shadow-lg`}
                    aria-hidden="true"
                  >
                    {active.initials}
                  </span>
                  <span>
                    <span className="block text-sm font-semibold text-zinc-900 dark:text-white">
                      {active.name}
                    </span>
                    <span className="block text-sm text-zinc-500 dark:text-zinc-400">
                      {active.role}
                    </span>
                  </span>
                </figcaption>
              </motion.figure>
            </AnimatePresence>
          </div>

          {/* Progress bar */}
          <div className="mt-10 h-1 w-full overflow-hidden rounded-full bg-zinc-200 dark:bg-zinc-800">
            <div
              className="h-full rounded-full bg-gradient-to-r from-violet-500 to-sky-500"
              style={{ width: `${reduce ? 100 : progress}%` }}
            />
          </div>

          {/* Controls */}
          <div className="mt-6 flex items-center justify-between">
            <div className="flex items-center gap-2" role="tablist" aria-label="Select testimonial">
              {testimonials.map((t, i) => (
                <button
                  key={t.name}
                  type="button"
                  role="tab"
                  aria-selected={i === index}
                  aria-label={`Show testimonial from ${t.name}`}
                  onClick={() => goTo(i, i > index ? 1 : -1)}
                  className="group relative h-2.5 py-2"
                >
                  <span
                    className={`block h-2.5 rounded-full transition-all duration-300 ${
                      i === index
                        ? "w-7 bg-violet-500"
                        : "w-2.5 bg-zinc-300 group-hover:bg-zinc-400 dark:bg-zinc-700 dark:group-hover:bg-zinc-600"
                    }`}
                  />
                </button>
              ))}
            </div>

            <div className="flex items-center gap-2">
              <button
                type="button"
                onClick={() => goTo(index - 1, -1)}
                aria-label="Previous testimonial"
                className="grid h-10 w-10 place-items-center rounded-full border border-zinc-200 bg-white text-zinc-600 transition hover:scale-105 hover:text-zinc-900 active:scale-95 dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-400 dark:hover:text-white"
              >
                <svg viewBox="0 0 24 24" className="h-5 w-5" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
                  <path d="M15 18l-6-6 6-6" strokeLinecap="round" strokeLinejoin="round" />
                </svg>
              </button>
              <button
                type="button"
                onClick={() => goTo(index + 1, 1)}
                aria-label="Next testimonial"
                className="grid h-10 w-10 place-items-center rounded-full border border-zinc-200 bg-white text-zinc-600 transition hover:scale-105 hover:text-zinc-900 active:scale-95 dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-400 dark:hover:text-white"
              >
                <svg viewBox="0 0 24 24" className="h-5 w-5" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
                  <path d="M9 6l6 6-6 6" strokeLinecap="round" strokeLinejoin="round" />
                </svg>
              </button>
            </div>
          </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 →
Three Card Testimonial Grid

Three Card Testimonial Grid

Original

A clean three-column grid of testimonial cards, each with a star rating, quote and an initial avatar with name and role.

Single Featured Quote

Single Featured Quote

Original

A large centred pull quote with a decorative quotation mark, five-star rating and a prominent author avatar for a single standout testimonial.

Logo Wall Marquee

Logo Wall Marquee

Original

A pair of infinitely scrolling, pause-on-hover marquees pairing a customer logo wall with compact quote cards, with reduced-motion support.

Masonry Testimonial Wall

Masonry Testimonial Wall

Original

A CSS-columns masonry wall of testimonial cards in varied lengths, each with a star rating and initial avatar for a natural, organic layout.

Aggregate Rating Spotlight

Aggregate Rating Spotlight

Original

A split layout pairing a large aggregate rating score, star row and overlapping avatar stack with two featured review cards.

Staggered Reveal Testimonial Grid

Staggered Reveal Testimonial Grid

Original

A testimonial grid whose cards spring in with a staggered scroll reveal and light up with an animated gradient border on hover.

Infinite Marquee Testimonial Wall

Infinite Marquee Testimonial Wall

Original

A three-row marquee wall of review cards scrolling in alternating directions that pause on hover, with a shimmering gradient headline and faded edges.

Spotlight Hero

Spotlight Hero

Original

A centred hero with a soft radial spotlight, badge and dual call-to-action.

Split Hero

Split Hero

Original

A two-column hero pairing a headline and CTAs with a product mock and social proof.

Gradient Spotlight Hero

Gradient Spotlight Hero

Original

A minimal centred hero with a soft gradient-mesh backdrop, announcement pill and dual call-to-action buttons.

App Preview Hero

App Preview Hero

Original

A centred hero that pairs headline copy with a realistic product dashboard mock built entirely from markup, complete with browser chrome and a floating notification card.

Waitlist Capture Hero

Waitlist Capture Hero

Original

A dark, focused hero with an inline email capture form and avatar social proof, ready for pre-launch waitlists.