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