Fazekit

Code

"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">
          &ldquo;{t.quote}&rdquo;
        </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>
  );
}

More in Testimonials

View all →