What people said after their first month.
4.9from 2,000 teamsWe moved the whole site in a week. Nobody asked where anything had gone, which is the best review a tool can get.
Set up before lunch, shipping by the afternoon.
The first tool our engineers and designers both open in the morning, and the only one neither has asked to replace.
It looks like ours now, not like a template.
Support answered in minutes, with the fix. I have stopped keeping a list of workarounds.
The code we took out was the code that ran.
Three launches this quarter, and none of them waited on a page.
I changed one colour and the whole product followed. That used to be a sprint.
New people are useful in their first week now.
We moved the whole site in a week. Nobody asked where anything had gone, which is the best review a tool can get.
It looks like ours now, not like a template.
Three launches this quarter, and none of them waited on a page.
Set up before lunch, shipping by the afternoon.
Support answered in minutes, with the fix. I have stopped keeping a list of workarounds.
I changed one colour and the whole product followed. That used to be a sprint.
The first tool our engineers and designers both open in the morning, and the only one neither has asked to replace.
The code we took out was the code that ran.
New people are useful in their first week now.
import type { ReactNode } from "react";
export interface Testimonial {
quote: string;
name: string;
role: string;
/** How many stars out of five. Defaults to five. */
stars?: number;
/** Lit from above, with its mark in the accent. */
featured?: boolean;
}
export interface TestimonialsProps {
eyebrow?: ReactNode;
title?: ReactNode;
description?: ReactNode;
/** A score in a capsule under the heading, and a few words beside it. */
rating?: { score: string; text: ReactNode };
items: Testimonial[];
className?: string;
}
// A mark with a little depth: a highlight along the top edge, a shade along the bottom, and a soft cast in its own colour.
const raised =
"bg-(--tui-testimonials-accent) text-(--tui-testimonials-accent-fg) [background-image:linear-gradient(to_bottom,rgb(255_255_255_/_0.18),rgb(255_255_255_/_0)_55%,rgb(0_0_0_/_0.14))] [box-shadow:inset_0_1px_0_rgb(255_255_255_/_0.24),inset_0_-1px_0_rgb(0_0_0_/_0.2),0_1px_2px_rgb(0_0_0_/_0.2),0_6px_12px_-6px_var(--tui-testimonials-accent)]";
const plain = "bg-(--tui-testimonials-card-bg) [box-shadow:0_0_0_1px_var(--tui-testimonials-border),0_1px_2px_rgb(0_0_0_/_0.08)]";
/** How long each column takes against the set speed, and which way it goes, so no two move together. */
const PACE = [
{ factor: 1, reverse: false },
{ factor: 1.3, reverse: true },
{ factor: 0.85, reverse: false },
];
const initials = (name: string) =>
name
.split(/\s+/)
.map((part) => part[0])
.slice(0, 2)
.join("")
.toUpperCase();
function Stars({ count = 5 }: { count?: number }) {
return (
<span role="img" aria-label={`${count} out of 5`} className="flex gap-0.5 text-(--tui-testimonials-accent)">
{[0, 1, 2, 3, 4].map((star) => (
<svg key={star} aria-hidden viewBox="0 0 16 16" fill="currentColor" className={`size-3.5 ${star < count ? "" : "opacity-20"}`}>
<path d="M8 1.300l2.050 4.250 4.650.6-3.400 3.250.85 4.600L8 11.750 3.850 14l.85-4.600L1.300 6.150l4.650-.6z" />
</svg>
))}
</span>
);
}
function Card({ item }: { item: Testimonial }) {
return (
<figure data-slot="testimonial" data-featured={item.featured || undefined} className="m-0 flex flex-col rounded-(--tui-testimonials-radius) bg-(--tui-testimonials-shell-bg) p-1.5 [box-shadow:inset_0_0_0_1px_var(--tui-testimonials-border)]">
<div data-slot="testimonial-panel" className="relative flex flex-col gap-4 overflow-hidden rounded-[calc(var(--tui-testimonials-radius)_-_6px)] bg-(--tui-testimonials-card-bg) p-(--tui-testimonials-padding) [box-shadow:0_0_0_1px_var(--tui-testimonials-border),0_1px_2px_rgb(0_0_0_/_0.05),0_8px_20px_-12px_rgb(0_0_0_/_0.18)]">
{item.featured && <span aria-hidden className="pointer-events-none absolute -top-24 left-1/2 size-64 -translate-x-1/2 rounded-full bg-(--tui-testimonials-glow) blur-3xl" />}
<span className="relative">
<Stars count={item.stars} />
</span>
<blockquote className="relative m-0 text-(length:--tui-testimonials-quote-size) leading-relaxed text-pretty">{item.quote}</blockquote>
</div>
<figcaption className="flex items-center gap-3 px-(--tui-testimonials-padding) py-3.5">
<span aria-hidden className={`flex size-9 shrink-0 items-center justify-center rounded-full text-[12px] font-semibold ${item.featured ? raised : plain}`}>
{initials(item.name)}
</span>
<span className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-[14px] font-semibold">{item.name}</span>
<span className="truncate text-[13px] text-(--tui-testimonials-muted)">{item.role}</span>
</span>
<svg aria-hidden viewBox="0 0 32 24" fill="currentColor" className="h-3.5 w-5 shrink-0 text-(--tui-testimonials-accent) opacity-30">
<path d="M0 24V13.500C0 5.800 4.200 1.200 12 0v4.800C8.400 5.700 6.600 8 6.600 11.400H12V24zm18 0V13.500C18 5.800 22.200 1.200 30 0v4.800c-3.600.900-5.400 3.200-5.400 6.600H30V24z" />
</svg>
</figcaption>
</figure>
);
}
/** One column of the wall. Its cards are drawn twice, one set after the other, so it can come round without a seam. */
function Column({ items, pace, className }: { items: Testimonial[]; pace: (typeof PACE)[number]; className: string }) {
return (
<div className={className}>
<div
data-slot="testimonials-track"
style={{ animationDuration: `calc(var(--tui-testimonials-duration) * ${pace.factor})`, animationDirection: pace.reverse ? "reverse" : undefined }}
className="flex flex-col [animation:tui-wall_var(--tui-testimonials-duration)_linear_infinite] group-hover/wall:[animation-play-state:paused] motion-reduce:[animation:none]"
>
{[false, true].map((copy) => (
<ul key={String(copy)} aria-hidden={copy || undefined} className={`m-0 flex list-none flex-col gap-(--tui-testimonials-card-gap) p-0 pb-(--tui-testimonials-card-gap) ${copy ? "motion-reduce:hidden" : ""}`}>
{items.map((item) => (
<li key={item.name}>
<Card item={item} />
</li>
))}
</ul>
))}
</div>
</div>
);
}
/**
* What people said, as a wall. The quotes stand in columns that drift past
* one another and fade out at the top and bottom, and stop under the pointer
* so one can be read. Each is a raised panel with the words in it, set in a
* shell that says who they came from.
*/
export function Testimonials({ eyebrow, title, description, rating, items, className }: TestimonialsProps) {
return (
<section data-slot="testimonials" className={["flex w-full flex-col items-center gap-(--tui-testimonials-gap) bg-(--tui-testimonials-bg) px-6 py-(--tui-testimonials-padding-y) font-(family-name:--tui-font) text-(--tui-testimonials-fg)", className].filter(Boolean).join(" ")}>
{(eyebrow || title || description || rating) && (
<header className="flex max-w-[42rem] flex-col items-center gap-4 text-center">
{eyebrow && <span className="inline-flex h-7 items-center rounded-full px-3 text-[12px] font-semibold tracking-[0.08em] text-(--tui-testimonials-accent) uppercase [box-shadow:inset_0_0_0_1px_var(--tui-testimonials-border)]">{eyebrow}</span>}
{title && <h2 className="m-0 text-(length:--tui-testimonials-title-size) leading-[1.08] font-semibold tracking-[-0.03em] text-balance">{title}</h2>}
{description && <p className="m-0 text-[17px] leading-relaxed text-pretty text-(--tui-testimonials-muted)">{description}</p>}
{rating && (
<span data-slot="testimonials-rating" className="mt-1 inline-flex h-9 items-center gap-2.5 rounded-full bg-(--tui-testimonials-card-bg) pr-4 pl-1 text-[13px] text-(--tui-testimonials-muted) [box-shadow:inset_0_0_0_1px_var(--tui-testimonials-border),0_1px_2px_rgb(0_0_0_/_0.05)]">
<span className={`inline-flex h-7 items-center rounded-full px-2.5 text-[12px] font-semibold tabular-nums ${raised}`}>{rating.score}</span>
<Stars />
{rating.text}
</span>
)}
</header>
)}
<div
data-slot="testimonials-wall"
className="group/wall grid h-(--tui-testimonials-height) w-full max-w-(--tui-testimonials-width) grid-cols-1 gap-(--tui-testimonials-card-gap) overflow-hidden [mask-image:linear-gradient(to_bottom,transparent,black_14%,black_86%,transparent)] motion-reduce:h-auto motion-reduce:[mask-image:none] lg:grid-cols-3"
>
{/* Narrow screens get every quote in one column. Wide ones deal them out across three. */}
<Column items={items} pace={PACE[0]!} className="mx-auto w-full max-w-[28rem] lg:hidden" />
{PACE.map((pace, column) => (
<Column key={column} items={items.filter((_, i) => i % PACE.length === column)} pace={pace} className="hidden lg:block" />
))}
</div>
</section>
);
}:root {
color-scheme: light;
--tui-bg: #ffffff;
--tui-fg: #0a0a0a;
--tui-muted: #f5f5f5;
--tui-muted-fg: #737373;
--tui-border: #e5e5e5;
--tui-primary: #171717;
--tui-primary-fg: #fafafa;
--tui-ring: #a3a3a3;
--tui-success: #16a34a;
--tui-warning: #d97706;
--tui-danger: #dc2626;
--tui-radius: 8px;
--tui-border-width: 1px;
--tui-space: 4px;
--tui-shadow: none;
--tui-font: ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
--tui-text: 14px;
--tui-duration: 150ms;
/* Testimonials */
--tui-testimonials-duration: 60s;
--tui-testimonials-height: 620px;
--tui-testimonials-padding-y: calc(var(--tui-space) * 20);
--tui-testimonials-gap: calc(var(--tui-space) * 12);
--tui-testimonials-card-gap: calc(var(--tui-space) * 5);
--tui-testimonials-width: 1040px;
--tui-testimonials-padding: calc(var(--tui-space) * 6);
--tui-testimonials-radius: calc(var(--tui-radius) * 2.5);
--tui-testimonials-title-size: 40px;
--tui-testimonials-quote-size: 15px;
--tui-testimonials-bg: var(--tui-bg);
--tui-testimonials-card-bg: var(--tui-bg);
--tui-testimonials-shell-bg: var(--tui-muted);
--tui-testimonials-fg: var(--tui-fg);
--tui-testimonials-muted: var(--tui-muted-fg);
--tui-testimonials-border: var(--tui-border);
--tui-testimonials-glow: color-mix(in srgb, var(--tui-primary) 12%, transparent);
--tui-testimonials-accent: var(--tui-primary);
--tui-testimonials-accent-fg: var(--tui-primary-fg);
}
.dark,
[data-theme="dark"] {
color-scheme: dark;
--tui-bg: #0a0a0a;
--tui-fg: #fafafa;
--tui-muted: #1c1c1c;
--tui-muted-fg: #a3a3a3;
--tui-border: #2a2a2a;
--tui-primary: #fafafa;
--tui-primary-fg: #171717;
--tui-ring: #737373;
--tui-success: #22c55e;
--tui-warning: #f59e0b;
--tui-danger: #ef4444;
}
@keyframes tui-wall {
to { transform: translateY(-50%); }
}Needs Tailwind CSS v4. Import the stylesheet once, after Tailwind.