Skip to content

Testimonial Carousel

An auto-advancing quote carousel with directional slide transitions.

Freeeditorialminimal
Category
Patterns
Added
2026-08-24
Deps
2
Updated
2026-09-01
Preview

“Shipped our landing page in an afternoon.”

Dana K. · Indie hacker

Props

Auto-advance
3200
Requires framer-motion, lucide-react
"use client";

import { useEffect, useState } from "react";
import { AnimatePresence, motion } from "framer-motion";
import { ChevronLeft, ChevronRight, Quote } from "lucide-react";

type Testimonial = { quote: string; name: string; role: string };

export function TestimonialCarousel({
  testimonials,
  autoAdvance = true,
  interval = 3200,
}: {
  testimonials: Testimonial[];
  autoAdvance?: boolean;
  interval?: number;
}) {
  const [[index, direction], setIndex] = useState<[number, number]>([0, 0]);

  function go(delta: number) {
    setIndex(([i]) => [(i + delta + testimonials.length) % testimonials.length, delta]);
  }

  useEffect(() => {
    if (!autoAdvance) return;
    const id = setInterval(() => go(1), interval);
    return () => clearInterval(id);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [autoAdvance, interval]);

  const current = testimonials[index];

  return (
    <div className="w-full max-w-sm overflow-hidden rounded-2xl border border-neutral-200 px-6 py-8">
      <Quote size={22} className="mb-3 text-red-500" />
      <div className="relative h-24">
        <AnimatePresence initial={false} custom={direction} mode="popLayout">
          <motion.div
            key={index}
            initial={{ x: direction >= 0 ? 40 : -40, opacity: 0 }}
            animate={{ x: 0, opacity: 1 }}
            exit={{ x: direction >= 0 ? -40 : 40, opacity: 0 }}
            transition={{ duration: 0.35, ease: [0.16, 1, 0.3, 1] }}
            className="absolute inset-0"
          >
            <p className="text-base font-bold leading-snug">“{current.quote}”</p>
            <p className="mt-3 text-xs text-neutral-500">
              {current.name} · {current.role}
            </p>
          </motion.div>
        </AnimatePresence>
      </div>

      <div className="mt-4 flex items-center justify-between">
        <div className="flex gap-1.5">
          {testimonials.map((_, i) => (
            <span key={i} className={`h-1.5 w-1.5 rounded-full ${i === index ? "bg-red-500" : "bg-neutral-200"}`} />
          ))}
        </div>
        <div className="flex gap-1.5">
          <button onClick={() => go(-1)} className="flex h-7 w-7 items-center justify-center rounded-full border border-neutral-200 text-neutral-400 hover:text-red-500">
            <ChevronLeft size={14} />
          </button>
          <button onClick={() => go(1)} className="flex h-7 w-7 items-center justify-center rounded-full border border-neutral-200 text-neutral-400 hover:text-red-500">
            <ChevronRight size={14} />
          </button>
        </div>
      </div>
    </div>
  );
}
Notes

About this pattern

An auto-advancing quote carousel needs to know not just which slide is current but which direction it just moved, since a plain index can't express that — going from slide two to slide zero by wrapping forward looks numerically identical to going from two to zero backward. This component tracks index and direction as a paired state specifically so the exit and enter animation can slide the new quote in from the correct side and the old one out the opposite way, keyed on index. Reach for this over a plain crossfade when the direction of travel is part of the effect, and over a full carousel library when there's no need for thumbnails, autoplay pause states, or multi-item views — this is roughly sixty lines. One gap worth knowing about: the auto-advance timer keeps running while hovering. There's no pause-on-hover wired in, so a testimonial can slide away mid-read if a visitor lingers on it.

Curator’s note

State tracks [index, direction] rather than index alone — the exit/enter animation needs to know which way the slide is travelling, and that can't be derived from the index difference alone once the sequence wraps around.

Related

Pairs well with

Matched on shared tags and category — the entries most likely to be used alongside this one.

A draggable before/after divider that reveals one image over another.

imageslidercompare
Pro

A live estimator with real marginal-tier overage pricing and an annual discount toggle.

pricingcalculatorslider
Featured
New

A rail you can throw — it projects the momentum and settles on the nearest slide.

dragcarouselinertia