Cards

Testimonial Card

A quote card with attribution row of avatar, name, and title on a depth-border surface.

Install

npx shadcn@latest add @paragon/testimonial-card

testimonial-card.tsx

import * as React from "react";
import { cn } from "@/lib/utils";

export interface TestimonialCardProps
  extends Omit<React.ComponentProps<"figure">, "title"> {
  /** The quote body. Curly quotes are added automatically. */
  quote?: React.ReactNode;
  /** Who said it. Also seeds the initials avatar fallback. */
  name?: string;
  /** Their role and company, e.g. "VP of Operations, Northwind". */
  title?: string;
  /** Avatar image URL. Falls back to initials derived from `name`. */
  avatarSrc?: string;
}

/** First letters of the first two words — "Amara Chen" → "AC". */
function initialsOf(name: string) {
  return name
    .trim()
    .split(/\s+/)
    .slice(0, 2)
    .map((word) => word[0])
    .join("")
    .toUpperCase();
}

/**
 * A quote card with an attribution row of avatar, name, and title on a
 * depth-border surface. Static by design — testimonials earn attention
 * with copy, not motion.
 */
export function TestimonialCard({
  quote = "We replaced three internal reporting tools with one dashboard. Finance closes the books two days faster, and nobody has asked me for a CSV export since.",
  name = "Amara Chen",
  title = "VP of Operations, Northwind",
  avatarSrc,
  className,
  ...props
}: TestimonialCardProps) {
  return (
    <figure
      data-slot="testimonial-card"
      className={cn(
        "flex flex-col justify-between gap-6 rounded-xl bg-card p-6 shadow-border dark:bg-secondary/30",
        className,
      )}
      {...props}
    >
      <blockquote className="text-pretty text-[15px] leading-relaxed text-foreground">
        &ldquo;{quote}&rdquo;
      </blockquote>
      <figcaption className="flex min-w-0 items-center gap-3">
        {avatarSrc ? (
          <img
            src={avatarSrc}
            alt=""
            className="size-10 shrink-0 rounded-full object-cover shadow-border"
          />
        ) : (
          <span
            aria-hidden
            className="flex size-10 shrink-0 select-none items-center justify-center rounded-full bg-secondary text-[13px] font-medium text-secondary-foreground shadow-border"
          >
            {initialsOf(name)}
          </span>
        )}
        <div className="min-w-0">
          <div className="truncate text-sm font-medium text-foreground">
            {name}
          </div>
          {title && (
            <div className="truncate text-[13px] text-muted-foreground">
              {title}
            </div>
          )}
        </div>
      </figcaption>
    </figure>
  );
}