Three-column quote wall with initialed avatars, one featured quote, column-staggered reveal, and soft edge masks.
npx shadcn@latest add @paragon/testimonials-wall"use client";
import * as React from "react";
import { useInView, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";
const REVEAL =
"transition-[opacity,translate,filter] duration-500 ease-out motion-reduce:transition-none";
const HIDDEN = "translate-y-3 opacity-0 blur-[4px]";
const SHOWN = "translate-y-0 opacity-100 blur-[0px]";
type Quote = {
quote: string;
name: string;
role: string;
company: string;
featured?: boolean;
};
/* Three explicit columns so the reveal can stagger column by column and
the masonry rhythm stays deliberate rather than accidental. */
const COLUMNS: Quote[][] = [
[
{
quote:
"Our monthly close went from nine days to three. The controllers stopped dreading the first week of the month.",
name: "Maya Chen",
role: "VP Finance",
company: "Arcline",
},
{
quote:
"Basis flagged a $40k duplicate accrual in our first week. It paid for the year before onboarding finished.",
name: "Tomás Rivera",
role: "Controller",
company: "Fieldnote",
},
{
quote:
"The audit trail alone is worth it. Our auditors asked for samples and we sent them a link.",
name: "Priya Natarajan",
role: "Director of Accounting",
company: "Halcyon Systems",
},
],
[
{
quote:
"We replaced eleven reconciliation spreadsheets with Basis. Close checklist items complete themselves as the ledgers tie out — my team finally works on analysis instead of tie-outs.",
name: "Danielle Okafor",
role: "CFO",
company: "Verdant Labs",
featured: true,
},
{
quote:
"Flux analysis that used to take an analyst two days now lands in my inbox on day one.",
name: "Sam Whitfield",
role: "SVP Finance",
company: "Nimbus Freight",
},
],
[
{
quote:
"Implementation was two weeks, not the two quarters our ERP vendor quoted for the same scope.",
name: "Ingrid Weiss",
role: "Head of Finance Ops",
company: "Polyphase",
},
{
quote:
"Every journal entry carries its own evidence. Quarter-end reviews stopped being archaeology.",
name: "Marcus Bell",
role: "Assistant Controller",
company: "Brightline Health",
},
{
quote:
"We scaled from two entities to nine without adding close headcount. That was the whole business case.",
name: "Aiko Tanaka",
role: "VP Corporate Finance",
company: "Vectora",
},
],
];
function initials(name: string) {
return name
.split(" ")
.map((part) => part[0])
.slice(0, 2)
.join("");
}
function QuoteCard({ quote }: { quote: Quote }) {
return (
<figure
className={cn(
"rounded-xl bg-card p-5 shadow-border",
quote.featured && "p-6",
)}
>
<blockquote>
<p
className={cn(
"text-sm leading-relaxed text-pretty",
quote.featured && "text-base font-medium",
)}
>
“{quote.quote}”
</p>
</blockquote>
<figcaption className="mt-4 flex items-center gap-3">
<span
aria-hidden
className="flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-[11px] font-semibold text-muted-foreground"
>
{initials(quote.name)}
</span>
<span>
<span className="block text-[13px] font-medium">{quote.name}</span>
<span className="block text-xs text-muted-foreground">
{quote.role}, {quote.company}
</span>
</span>
</figcaption>
</figure>
);
}
export interface TestimonialsWallProps extends React.ComponentProps<"section"> {
eyebrow?: string;
headline?: string;
}
/**
* Masonry-style three-column testimonial wall with one featured quote.
* Columns reveal with an 80ms column stagger (plus 60ms per card), and a
* subtle top/bottom mask fades the wall's edges.
*/
export function TestimonialsWall({
eyebrow = "Customers",
headline = "Finance teams close faster on Basis",
className,
...props
}: TestimonialsWallProps) {
const ref = React.useRef<HTMLElement>(null);
const inView = useInView(ref, { once: true, margin: "-80px" });
const reduced = useReducedMotion();
const revealed = inView || !!reduced;
return (
<section
ref={ref}
aria-labelledby="testimonials-wall-headline"
className={cn("w-full bg-background px-4 py-16 sm:px-6 md:py-24", className)}
{...props}
>
<div className="mx-auto w-full max-w-5xl">
<div
className={cn(
"mx-auto max-w-2xl text-center",
REVEAL,
revealed ? SHOWN : HIDDEN,
)}
>
<p className="text-sm font-medium text-muted-foreground">{eyebrow}</p>
<h2
id="testimonials-wall-headline"
className="mt-2 text-3xl font-semibold tracking-tight text-balance sm:text-4xl"
>
{headline}
</h2>
</div>
<div
className="mt-12 grid gap-4 md:grid-cols-3 [mask-image:linear-gradient(to_bottom,transparent,black_6%,black_94%,transparent)]"
>
{COLUMNS.map((column, c) => (
<div key={c} className="flex flex-col gap-4">
{column.map((quote, i) => (
<div
key={quote.name}
className={cn(REVEAL, revealed ? SHOWN : HIDDEN)}
style={{ transitionDelay: `${80 + c * 80 + i * 60}ms` }}
>
<QuoteCard quote={quote} />
</div>
))}
</div>
))}
</div>
</div>
</section>
);
}