Sections

Testimonials Wall

Three-column quote wall with initialed avatars, one featured quote, column-staggered reveal, and soft edge masks.

Install

npx shadcn@latest add @paragon/testimonials-wall

testimonials-wall.tsx

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