"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]">
“{t.quote}”
</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>
);
}