Fazekit

Code

"use client";
import * as React from "react";
import { motion, useMotionValue, useReducedMotion } from "motion/react";
import { Star } from "lucide-react";
import { cn } from "@/lib/utils";

export type WallTestimonial = {
  quote: string;
  name: string;
  role: string;
  company: string;
  /** 1–5, defaults to 5. */
  rating?: number;
};

export interface TestimonialsWallProps {
  eyebrow?: string;
  title?: React.ReactNode;
  subtitle?: string;
  testimonials?: WallTestimonial[];
  /** Scroll speed of each desktop column in px/second. Odd columns scroll downwards. */
  speeds?: [number, number, number];
  /** Height of the scrolling wall in px. */
  height?: number;
  className?: string;
}

const DEFAULT_TESTIMONIALS: WallTestimonial[] = [
  { quote: "We replaced three internal tools in a single sprint. The onboarding was so smooth our ops team asked why we hadn't switched sooner.", name: "Maya Okafor", role: "Head of Operations", company: "Northwind", rating: 5 },
  { quote: "Finally a product that treats performance as a feature. Our dashboards load in under a second, even on hotel Wi-Fi.", name: "Jonas Lindqvist", role: "Staff Engineer", company: "Lumen Labs", rating: 5 },
  { quote: "Support answered in four minutes on a Sunday. That alone sold the rest of the leadership team.", name: "Priya Raman", role: "VP Customer Success", company: "Orbit", rating: 5 },
  { quote: "The API is the cleanest I've integrated this year. Typed SDKs, honest docs and zero surprises in production.", name: "Diego Alvarez", role: "CTO", company: "Fieldnote", rating: 5 },
  { quote: "Our designers and engineers finally speak the same language. Handoff went from days to minutes.", name: "Hana Sato", role: "Design Lead", company: "Acme Studio", rating: 4 },
  { quote: "Revenue reporting used to be a Friday-night ritual. Now it's a link I send the board every Monday morning.", name: "Theo Brandt", role: "Finance Director", company: "Kestrel", rating: 5 },
  { quote: "It scaled with us from 12 to 400 people without a single migration. That is rare.", name: "Amara Nwosu", role: "COO", company: "Brightline", rating: 5 },
  { quote: "The automation builder is genuinely fun. Our support queue dropped by a third in the first month.", name: "Luca Moretti", role: "Support Manager", company: "Pinecrest", rating: 5 },
  { quote: "Security review took one call. SSO, audit logs and data residency were already there.", name: "Sofia Petrova", role: "CISO", company: "Halcyon", rating: 5 },
  { quote: "I've tried every tool in this space. This is the first one my team opens without being reminded.", name: "Kwame Mensah", role: "Product Manager", company: "Tidewater", rating: 4 },
  { quote: "Beautiful defaults, sensible settings, and an export button that actually works. Chef's kiss.", name: "Elena Ruiz", role: "Founder", company: "Loomwork", rating: 5 },
  { quote: "We shipped our biggest launch of the year on it. Not one incident, not one late night.", name: "Noah Fischer", role: "Engineering Manager", company: "Quartz", rating: 5 },
];

const GRADIENTS = [
  "from-violet-500 to-fuchsia-500",
  "from-sky-500 to-indigo-500",
  "from-emerald-500 to-teal-500",
  "from-amber-500 to-rose-500",
  "from-rose-500 to-pink-500",
  "from-cyan-500 to-blue-600",
];

function hash(s: string) {
  let h = 0;
  for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0;
  return Math.abs(h);
}

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

function Stars({ rating = 5 }: { rating?: number }) {
  return (
    <div className="flex gap-0.5" role="img" aria-label={`${rating} out of 5 stars`}>
      {Array.from({ length: 5 }, (_, i) => (
        <Star
          key={i}
          aria-hidden
          className={cn("size-3.5", i < rating ? "fill-amber-400 text-amber-400" : "fill-muted text-muted")}
        />
      ))}
    </div>
  );
}

function Card({ t, hidden }: { t: WallTestimonial; hidden?: boolean }) {
  return (
    <figure
      aria-hidden={hidden || undefined}
      className="group/card rounded-2xl border bg-card p-5 text-card-foreground shadow-sm transition-[box-shadow,transform,border-color] duration-300 hover:-translate-y-0.5 hover:border-primary/30 hover:shadow-lg hover:shadow-primary/5"
    >
      <Stars rating={t.rating} />
      <blockquote className="mt-3 text-[15px] leading-relaxed text-foreground/90">&ldquo;{t.quote}&rdquo;</blockquote>
      <figcaption className="mt-5 flex items-center gap-3">
        <span
          aria-hidden
          className={cn(
            "grid size-10 shrink-0 place-items-center rounded-full bg-gradient-to-br text-sm font-semibold text-white ring-2 ring-background",
            GRADIENTS[hash(t.name) % GRADIENTS.length],
          )}
        >
          {initials(t.name)}
        </span>
        <span className="min-w-0">
          <span className="block truncate text-sm font-semibold">{t.name}</span>
          <span className="block truncate text-xs text-muted-foreground">
            {t.role} · <span className="font-medium text-foreground/70">{t.company}</span>
          </span>
        </span>
      </figcaption>
    </figure>
  );
}

function Column({ items, speed, reverse, className }: { items: WallTestimonial[]; speed: number; reverse?: boolean; className?: string }) {
  const reduce = useReducedMotion();
  const y = useMotionValue(0);
  const track = React.useRef<HTMLDivElement>(null);
  const paused = React.useRef(false);

  React.useEffect(() => {
    if (reduce) {
      y.set(0);
      return;
    }
    let raf = 0;
    let last = performance.now();
    let factor = 1; // eases towards 0 on hover for a soft stop instead of a jolt
    const tick = (now: number) => {
      const dt = Math.min(0.05, (now - last) / 1000);
      last = now;
      factor += ((paused.current ? 0 : 1) - factor) * Math.min(1, dt * 5);
      if (paused.current && factor < 0.01) factor = 0;
      const period = track.current ? track.current.scrollHeight / 2 : 0;
      if (period > 0) {
        let next = y.get() + (reverse ? 1 : -1) * speed * dt * factor;
        if (next <= -period) next += period;
        if (next > 0) next -= period;
        y.set(next);
      }
      raf = requestAnimationFrame(tick);
    };
    raf = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(raf);
  }, [reduce, reverse, speed, y]);

  const pause = () => (paused.current = true);
  const resume = () => (paused.current = false);

  return (
    <div
      className={cn("min-w-0", className)}
      onMouseEnter={pause}
      onMouseLeave={resume}
      onFocus={pause}
      onBlur={resume}
    >
      <motion.div ref={track} style={{ y }} className="will-change-transform">
        {[0, 1].map((copy) =>
          items.map((t, i) => (
            <div key={`${copy}-${i}`} className="pb-4">
              <Card t={t} hidden={copy === 1} />
            </div>
          )),
        )}
      </motion.div>
    </div>
  );
}

export function TestimonialsWall({
  eyebrow = "Wall of love",
  title = (
    <>
      Loved by teams who{" "}
      <span className="bg-gradient-to-r from-violet-600 via-fuchsia-500 to-amber-500 bg-clip-text text-transparent dark:from-violet-400 dark:via-fuchsia-400 dark:to-amber-300">
        ship every day
      </span>
    </>
  ),
  subtitle = "Over 4,000 companies run their operations on our platform. Here's what a few of them have to say.",
  testimonials = DEFAULT_TESTIMONIALS,
  speeds = [26, 34, 22],
  height = 420,
  className,
}: TestimonialsWallProps) {
  const columns = React.useMemo(() => {
    const cols: WallTestimonial[][] = [[], [], []];
    testimonials.forEach((t, i) => cols[i % 3].push(t));
    return cols;
  }, [testimonials]);

  const mask = "[mask-image:linear-gradient(to_bottom,transparent,#000_14%,#000_86%,transparent)]";

  return (
    <section className={cn("relative w-full overflow-hidden bg-background px-4 py-14 sm:px-6 sm:py-16", className)}>
      <div aria-hidden className="pointer-events-none absolute inset-x-0 top-0 h-72 bg-[radial-gradient(60%_100%_at_50%_0%,color-mix(in_oklch,var(--primary)_14%,transparent),transparent)]" />
      <div className="relative mx-auto max-w-6xl">
        <header className="mx-auto max-w-3xl text-center">
          <p className="inline-flex items-center gap-2 rounded-full border bg-card/60 px-3 py-1 text-xs font-medium text-muted-foreground backdrop-blur">
            <span className="size-1.5 rounded-full bg-primary" />
            {eyebrow}
          </p>
          <h2 className="mt-4 text-balance text-3xl font-semibold tracking-tight text-foreground sm:text-5xl">{title}</h2>
          {subtitle && <p className="mx-auto mt-4 max-w-2xl text-pretty text-base text-muted-foreground sm:text-lg">{subtitle}</p>}
        </header>

        {/* Mobile: one column with everything */}
        <div className={cn("mt-10 overflow-hidden md:hidden", mask)} style={{ height }}>
          <Column items={testimonials} speed={speeds[0]} />
        </div>

        {/* Tablet & desktop: three columns at different speeds */}
        <div className={cn("mt-10 hidden grid-cols-3 gap-4 overflow-hidden md:grid lg:gap-5", mask)} style={{ height }}>
          {columns.map((items, i) => (
            <Column key={i} items={items} speed={speeds[i]} reverse={i % 2 === 1} />
          ))}
        </div>
      </div>
    </section>
  );
}

More in Testimonials

View all →