"use client";
import * as React from "react";
import { motion, useMotionValue, useReducedMotion, useTransform, type PanInfo } from "motion/react";
import { ArrowLeft, ArrowRight, Hand, Star } from "lucide-react";
import { cn } from "@/lib/utils";
export type StackTestimonial = {
quote: string;
name: string;
role: string;
company: string;
rating?: number;
};
export interface TestimonialsStackProps {
eyebrow?: string;
title?: string;
description?: string;
/** Aggregate score shown on the left, e.g. 4.9 */
score?: number;
reviewCount?: string;
testimonials?: StackTestimonial[];
className?: string;
}
const DEFAULTS: StackTestimonial[] = [
{ quote: "Our team adopted it in a day. The keyboard shortcuts alone saved each of us an hour a week — and the design is just calm.", name: "Clara Jensen", role: "Product Designer", company: "Loomwork", rating: 5 },
{ quote: "We evaluated nine vendors. This was the only one that handled our 40-country payroll without custom work.", name: "Omar Haddad", role: "Head of People", company: "Brightline", rating: 5 },
{ quote: "Honestly the best onboarding experience I've had with any B2B product. Our churn dropped the quarter we switched.", name: "Beatriz Costa", role: "Growth Lead", company: "Tidewater", rating: 5 },
{ quote: "Integrations just work. We connected our warehouse, CRM and help desk before lunch.", name: "Felix Wagner", role: "RevOps Manager", company: "Kestrel", rating: 4 },
{ quote: "Support feels like an extension of our own team. They fixed an edge case for us in under a day.", name: "Aiko Tanaka", role: "Engineering Lead", company: "Quartz", rating: 5 },
{ quote: "Reporting that finance and marketing both trust. I didn't know that was possible.", name: "Samuel Osei", role: "CFO", company: "Pinecrest", rating: 5 },
];
const CARD_TONES = [
"from-violet-500/15 via-transparent",
"from-sky-500/15 via-transparent",
"from-emerald-500/15 via-transparent",
"from-amber-500/15 via-transparent",
"from-rose-500/15 via-transparent",
"from-cyan-500/15 via-transparent",
];
const AVATARS = [
"from-violet-500 to-fuchsia-500",
"from-sky-500 to-indigo-500",
"from-emerald-500 to-teal-500",
"from-amber-500 to-orange-500",
"from-rose-500 to-pink-500",
"from-cyan-500 to-blue-600",
];
function initials(name: string) {
return name
.split(/\s+/)
.map((p) => p[0])
.slice(0, 2)
.join("")
.toUpperCase();
}
const VISIBLE = 4;
function StackCard({
t,
tone,
position,
total,
onSend,
}: {
t: StackTestimonial;
tone: number;
position: number;
total: number;
onSend: () => void;
}) {
const reduce = useReducedMotion();
const x = useMotionValue(0);
const rotate = useTransform(x, [-220, 220], [-14, 14]);
const isTop = position === 0;
const depth = Math.min(position, VISIBLE - 1);
const hidden = position >= VISIBLE;
const onDragEnd = (_: unknown, info: PanInfo) => {
if (Math.abs(info.offset.x) > 110 || Math.abs(info.velocity.x) > 600 || Math.abs(info.offset.y) > 140) onSend();
};
return (
<motion.div
className="absolute inset-0"
style={{ zIndex: total - position }}
initial={false}
animate={{
y: reduce ? 0 : depth * 26,
x: reduce ? 0 : [0, -10, 12, -4][depth],
scale: 1 - depth * 0.05,
rotate: reduce ? 0 : [0, -4, 3.5, -6.5][depth],
opacity: hidden ? 0 : 1,
}}
transition={{ type: "spring", stiffness: 320, damping: 30, mass: 0.9 }}
>
<motion.figure
drag={isTop}
dragSnapToOrigin
dragElastic={0.6}
onDragEnd={onDragEnd}
onTap={isTop ? onSend : undefined}
whileDrag={{ scale: 1.03, cursor: "grabbing" }}
style={{ x, rotate: isTop ? rotate : 0 }}
aria-hidden={!isTop}
className={cn(
"relative flex h-full flex-col overflow-hidden rounded-3xl border bg-card p-6 text-card-foreground shadow-xl shadow-black/5 sm:p-8 dark:shadow-black/40",
isTop ? "cursor-grab touch-none" : "pointer-events-none",
)}
>
<div aria-hidden className={cn("pointer-events-none absolute inset-0 bg-gradient-to-br to-transparent", CARD_TONES[tone % CARD_TONES.length])} />
<div className="relative flex items-center justify-between">
<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>
<span className="rounded-full border bg-background/70 px-2.5 py-1 text-xs font-semibold tracking-tight text-foreground/80 backdrop-blur">{t.company}</span>
</div>
<blockquote className="relative mt-6 text-pretty text-lg font-medium leading-relaxed tracking-tight text-foreground sm:text-xl">
“{t.quote}”
</blockquote>
<figcaption className="relative mt-auto flex items-center gap-3 pt-6">
<span aria-hidden className={cn("grid size-11 place-items-center rounded-full bg-gradient-to-br text-sm font-semibold text-white", AVATARS[tone % AVATARS.length])}>
{initials(t.name)}
</span>
<span>
<span className="block font-semibold">{t.name}</span>
<span className="block text-sm text-muted-foreground">{t.role}</span>
</span>
</figcaption>
</motion.figure>
</motion.div>
);
}
export function TestimonialsStack({
eyebrow = "Reviews",
title = "The feedback that keeps us up at night (in a good way)",
description = "Real words from teams who switched this year. Drag the top card, tap it, or use the arrows to flip through the stack.",
score = 4.9,
reviewCount = "2,400+ reviews",
testimonials = DEFAULTS,
className,
}: TestimonialsStackProps) {
const [order, setOrder] = React.useState(() => testimonials.map((_, i) => i));
const next = React.useCallback(() => setOrder((o) => [...o.slice(1), o[0]]), []);
const prev = React.useCallback(() => setOrder((o) => [o[o.length - 1], ...o.slice(0, -1)]), []);
const top = order[0];
const pad = (n: number) => String(n).padStart(2, "0");
return (
<section className={cn("relative w-full overflow-hidden bg-background px-4 py-16 sm:px-6 sm:py-20", className)}>
<div aria-hidden className="pointer-events-none absolute -right-40 top-10 size-[520px] rounded-full bg-[radial-gradient(closest-side,color-mix(in_oklch,var(--primary)_16%,transparent),transparent)]" />
<div className="relative mx-auto grid max-w-6xl items-center gap-14 lg:grid-cols-2 lg:gap-16">
<div>
<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-5xl">{title}</h2>
<p className="mt-5 max-w-md text-pretty text-base text-muted-foreground sm:text-lg">{description}</p>
<div className="mt-8 flex items-center gap-5">
<p className="text-5xl font-semibold tracking-tight text-foreground">{score.toFixed(1)}</p>
<div>
<div className="flex gap-0.5" role="img" aria-label={`Average rating ${score} out of 5`}>
{Array.from({ length: 5 }, (_, i) => (
<Star key={i} aria-hidden className="size-5 fill-amber-400 text-amber-400" />
))}
</div>
<p className="mt-1 text-sm text-muted-foreground">{reviewCount}</p>
</div>
</div>
<div className="mt-10 flex items-center gap-3">
<button
type="button"
onClick={prev}
aria-label="Previous review"
className="grid size-12 place-items-center rounded-full border bg-card text-foreground transition hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<ArrowLeft className="size-5" />
</button>
<button
type="button"
onClick={next}
aria-label="Next review"
className="grid size-12 place-items-center rounded-full bg-primary text-primary-foreground shadow-lg shadow-primary/25 transition hover:scale-105 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
>
<ArrowRight className="size-5" />
</button>
<p className="ml-2 font-mono text-sm tabular-nums text-muted-foreground" aria-live="polite">
<span className="font-semibold text-foreground">{pad(top + 1)}</span> / {pad(testimonials.length)}
</p>
</div>
</div>
<div
className="relative mx-auto w-full max-w-md rounded-3xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-8 focus-visible:ring-offset-background"
tabIndex={0}
role="group"
aria-roledescription="card stack"
aria-label={`Review ${top + 1} of ${testimonials.length}. Use left and right arrow keys to browse.`}
onKeyDown={(e) => {
if (e.key === "ArrowRight" || e.key === "Enter" || e.key === " ") {
e.preventDefault();
next();
}
if (e.key === "ArrowLeft") {
e.preventDefault();
prev();
}
}}
>
<div className="relative h-[340px] sm:h-[360px]">
{testimonials.map((t, i) => (
<StackCard key={i} t={t} tone={i} position={order.indexOf(i)} total={testimonials.length} onSend={next} />
))}
</div>
<p className="mt-14 flex items-center justify-center gap-2 text-xs text-muted-foreground">
<Hand aria-hidden className="size-3.5" /> Drag or tap the card
</p>
</div>
</div>
</section>
);
}