Fazekit

Code

"use client";
import * as React from "react";
import { AnimatePresence, motion, useMotionValue, useReducedMotion, type PanInfo } from "motion/react";
import { ChevronLeft, ChevronRight, Pause, Play, Star } from "lucide-react";
import { cn } from "@/lib/utils";

export type CarouselTestimonial = {
  quote: string;
  name: string;
  role: string;
  company: string;
  rating?: number;
  /** Headline result shown on the portrait card, e.g. "3.4×". */
  metric?: string;
  metricLabel?: string;
  /** Tailwind gradient classes for the portrait, e.g. "from-sky-500 to-indigo-600". */
  gradient?: string;
};

export interface TestimonialsCarouselProps {
  eyebrow?: string;
  title?: string;
  testimonials?: CarouselTestimonial[];
  /** Autoplay interval in ms. Set 0 to disable autoplay. */
  interval?: number;
  className?: string;
}

const DEFAULTS: CarouselTestimonial[] = [
  {
    quote: "We moved our entire billing stack over a weekend. Six months later, failed payments are down 41% and nobody on the team remembers the old system.",
    name: "Adaeze Moreau",
    role: "VP Finance",
    company: "Northwind",
    metric: "41%",
    metricLabel: "fewer failed payments",
    gradient: "from-violet-500 via-fuchsia-500 to-rose-500",
  },
  {
    quote: "It's the rare tool that makes engineers faster and makes managers calmer. Our release cadence went from monthly to daily without adding headcount.",
    name: "Rafael Ibarra",
    role: "Director of Engineering",
    company: "Lumen",
    metric: "30×",
    metricLabel: "more releases per month",
    gradient: "from-sky-500 via-cyan-500 to-emerald-500",
  },
  {
    quote: "The analytics are so good our customers asked to see them. We now ship the same dashboards to clients as a premium add-on.",
    name: "Ingrid Solberg",
    role: "Co-founder",
    company: "Orbit Analytics",
    metric: "$1.2M",
    metricLabel: "new add-on revenue",
    gradient: "from-amber-500 via-orange-500 to-rose-500",
  },
  {
    quote: "Security reviews used to stall every enterprise deal. Now we send one link with SSO, audit logs and SOC reports — and the deal keeps moving.",
    name: "Kenji Watanabe",
    role: "Head of Security",
    company: "Halcyon",
    metric: "18 days",
    metricLabel: "shorter sales cycle",
    gradient: "from-emerald-500 via-teal-500 to-sky-600",
  },
];

function initials(name: string) {
  return name
    .split(/\s+/)
    .map((p) => p[0])
    .slice(0, 2)
    .join("")
    .toUpperCase();
}

const SWIPE = 8000; // offset × velocity threshold for a swipe

export function TestimonialsCarousel({
  eyebrow = "Customer stories",
  title = "Results our customers can put in a board deck",
  testimonials = DEFAULTS,
  interval = 6500,
  className,
}: TestimonialsCarouselProps) {
  const reduce = useReducedMotion();
  const count = testimonials.length;
  const [[index, direction], setPage] = React.useState<[number, number]>([0, 0]);
  const [playing, setPlaying] = React.useState(interval > 0);
  const [hovered, setHovered] = React.useState(false);
  const progress = useMotionValue(0);
  const elapsed = React.useRef(0);
  const regionId = React.useId();

  const paginate = React.useCallback(
    (dir: number) => {
      elapsed.current = 0;
      progress.set(0);
      setPage(([i]) => [(i + dir + count) % count, dir]);
    },
    [count, progress],
  );

  const goTo = (i: number) => {
    if (i === index) return;
    elapsed.current = 0;
    progress.set(0);
    setPage([i, i > index ? 1 : -1]);
  };

  // Autoplay clock: pauses while hovered/focused or toggled off, resumes where it left off.
  const running = playing && !hovered && interval > 0 && count > 1;
  React.useEffect(() => {
    if (!running) return;
    let raf = 0;
    let last = performance.now();
    const tick = (now: number) => {
      elapsed.current += now - last;
      last = now;
      progress.set(Math.min(1, elapsed.current / interval));
      if (elapsed.current >= interval) paginate(1);
      raf = requestAnimationFrame(tick);
    };
    raf = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(raf);
  }, [running, interval, paginate, progress]);

  const t = testimonials[index];
  const offset = reduce ? 0 : 60;
  const variants = {
    enter: (d: number) => ({ opacity: 0, x: d * offset, filter: reduce ? "blur(0px)" : "blur(6px)" }),
    center: { opacity: 1, x: 0, filter: "blur(0px)" },
    exit: (d: number) => ({ opacity: 0, x: d * -offset, filter: reduce ? "blur(0px)" : "blur(6px)" }),
  };
  const portraitVariants = {
    enter: (d: number) => ({ opacity: 0, rotate: reduce ? 0 : d * 6, scale: reduce ? 1 : 0.92 }),
    center: { opacity: 1, rotate: 0, scale: 1 },
    exit: (d: number) => ({ opacity: 0, rotate: reduce ? 0 : d * -6, scale: reduce ? 1 : 0.92 }),
  };

  const onDragEnd = (_: unknown, info: PanInfo) => {
    const power = info.offset.x * Math.abs(info.velocity.x);
    if (info.offset.x < -80 || power < -SWIPE) paginate(1);
    else if (info.offset.x > 80 || power > SWIPE) paginate(-1);
  };

  return (
    <section
      aria-roledescription="carousel"
      aria-label={title}
      className={cn("relative w-full overflow-hidden bg-background px-4 py-16 sm:px-6 sm:py-20", className)}
      onMouseEnter={() => setHovered(true)}
      onMouseLeave={() => setHovered(false)}
      onFocus={() => setHovered(true)}
      onBlur={(e) => {
        if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setHovered(false);
      }}
      onKeyDown={(e) => {
        if (e.key === "ArrowRight") paginate(1);
        if (e.key === "ArrowLeft") paginate(-1);
      }}
    >
      <div className="mx-auto max-w-6xl">
        <header className="flex flex-col gap-6 sm:flex-row sm:items-end sm:justify-between">
          <div className="max-w-xl">
            <p className="text-sm font-semibold uppercase tracking-[0.18em] text-primary">{eyebrow}</p>
            <h2 className="mt-3 text-balance text-3xl font-semibold tracking-tight text-foreground sm:text-4xl">{title}</h2>
          </div>
          <div className="flex items-center gap-2">
            {interval > 0 && (
              <button
                type="button"
                onClick={() => setPlaying((p) => !p)}
                aria-label={playing ? "Pause autoplay" : "Start autoplay"}
                className="grid size-11 place-items-center rounded-full border bg-card text-muted-foreground transition hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
              >
                {playing ? <Pause className="size-4" /> : <Play className="size-4" />}
              </button>
            )}
            <button
              type="button"
              onClick={() => paginate(-1)}
              aria-label="Previous testimonial"
              aria-controls={regionId}
              className="grid size-11 place-items-center rounded-full border bg-card text-foreground transition hover:-translate-x-0.5 hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
            >
              <ChevronLeft className="size-5" />
            </button>
            <button
              type="button"
              onClick={() => paginate(1)}
              aria-label="Next testimonial"
              aria-controls={regionId}
              className="grid size-11 place-items-center rounded-full bg-primary text-primary-foreground shadow-lg shadow-primary/25 transition hover:translate-x-0.5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
            >
              <ChevronRight className="size-5" />
            </button>
          </div>
        </header>

        <div
          id={regionId}
          aria-live={running ? "off" : "polite"}
          className="relative mt-10 grid gap-6 lg:mt-12 lg:grid-cols-[minmax(0,5fr)_minmax(0,8fr)] lg:gap-10"
        >
          {/* Portrait / metric card */}
          <div className="relative h-44 sm:h-56 lg:h-auto lg:min-h-[340px]">
            <AnimatePresence initial={false} custom={direction} mode="popLayout">
              <motion.div
                key={index}
                custom={direction}
                variants={portraitVariants}
                initial="enter"
                animate="center"
                exit="exit"
                transition={{ type: "spring", stiffness: 260, damping: 28 }}
                className={cn(
                  "absolute inset-0 flex flex-col justify-between overflow-hidden rounded-3xl bg-gradient-to-br p-6 text-white shadow-2xl sm:p-8",
                  t.gradient ?? "from-violet-500 to-indigo-600",
                )}
              >
                <div aria-hidden className="absolute -right-10 -top-10 size-48 rounded-full bg-white/15 blur-2xl" />
                <div aria-hidden className="absolute -bottom-16 -left-10 size-56 rounded-full bg-black/15 blur-2xl" />
                <div className="relative flex items-center justify-between">
                  <span className="rounded-full bg-white/20 px-3 py-1 text-xs font-medium backdrop-blur">{t.company}</span>
                  <span aria-hidden className="grid size-12 place-items-center rounded-2xl bg-white/20 text-lg font-semibold backdrop-blur sm:size-14">
                    {initials(t.name)}
                  </span>
                </div>
                {t.metric && (
                  <div className="relative">
                    <p className="text-5xl font-semibold tracking-tight sm:text-6xl">{t.metric}</p>
                    <p className="mt-1 text-sm text-white/85">{t.metricLabel}</p>
                  </div>
                )}
              </motion.div>
            </AnimatePresence>
          </div>

          {/* Quote */}
          <motion.div
            drag={count > 1 ? "x" : false}
            dragConstraints={{ left: 0, right: 0 }}
            dragElastic={0.18}
            onDragEnd={onDragEnd}
            className="relative min-h-[300px] cursor-grab touch-pan-y select-none overflow-hidden rounded-3xl border bg-card p-6 active:cursor-grabbing sm:min-h-[320px] sm:p-10"
          >
            <svg aria-hidden viewBox="0 0 64 48" className="absolute right-6 top-6 h-12 w-16 text-primary/15 sm:h-16 sm:w-20">
              <path fill="currentColor" d="M0 48V28C0 12 8 3 24 0l3 7C18 10 14 15 14 22h12v26H0Zm37 0V28c0-16 8-25 24-28l3 7c-9 3-13 8-13 15h12v26H37Z" />
            </svg>
            <AnimatePresence initial={false} custom={direction} mode="popLayout">
              <motion.figure
                key={index}
                custom={direction}
                variants={variants}
                initial="enter"
                animate="center"
                exit="exit"
                transition={{ duration: 0.35, ease: [0.22, 1, 0.36, 1] }}
                className="relative flex h-full flex-col"
                aria-roledescription="slide"
                aria-label={`${index + 1} of ${count}`}
              >
                <div className="flex gap-0.5" role="img" aria-label={`${t.rating ?? 5} out of 5 stars`}>
                  {Array.from({ length: 5 }, (_, i) => (
                    <Star key={i} aria-hidden className={cn("size-4", i < (t.rating ?? 5) ? "fill-amber-400 text-amber-400" : "fill-muted text-muted")} />
                  ))}
                </div>
                <blockquote className="mt-5 text-pretty text-xl font-medium leading-snug tracking-tight text-foreground sm:text-2xl lg:text-[1.7rem] lg:leading-[1.35]">
                  &ldquo;{t.quote}&rdquo;
                </blockquote>
                <figcaption className="mt-8 flex items-center gap-3 sm:mt-auto sm:pt-8">
                  <span aria-hidden className={cn("grid size-11 place-items-center rounded-full bg-gradient-to-br text-sm font-semibold text-white", t.gradient ?? "from-violet-500 to-indigo-600")}>
                    {initials(t.name)}
                  </span>
                  <span>
                    <span className="block font-semibold text-foreground">{t.name}</span>
                    <span className="block text-sm text-muted-foreground">
                      {t.role}, {t.company}
                    </span>
                  </span>
                </figcaption>
              </motion.figure>
            </AnimatePresence>
          </motion.div>
        </div>

        {/* Dots with autoplay progress */}
        <div className="mt-8 flex items-center justify-center gap-2" role="group" aria-label="Choose testimonial">
          {testimonials.map((item, i) => (
            <button
              key={i}
              type="button"
              onClick={() => goTo(i)}
              aria-label={`Show testimonial ${i + 1}: ${item.name}`}
              aria-current={i === index}
              className="group grid h-6 place-items-center rounded-full px-0.5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
            >
              <motion.span
                layout
                className={cn("relative block h-1.5 overflow-hidden rounded-full", i === index ? "w-10 bg-primary/25" : "w-1.5 bg-muted-foreground/30 group-hover:bg-muted-foreground/60")}
                transition={{ type: "spring", stiffness: 400, damping: 32 }}
              >
                {i === index && (
                  <motion.span
                    className="absolute inset-0 origin-left rounded-full bg-primary"
                    style={{ scaleX: interval > 0 ? progress : 1 }}
                  />
                )}
              </motion.span>
            </button>
          ))}
        </div>
      </div>
    </section>
  );
}

More in Testimonials

View all →