Skip to content
goodtabs UI
BrowseBrowse components

Testimonial card

A quote with the person behind it, plus a grid that shares hairlines between cards.

npx shadcn@latest add https://ui.goodtabs.co/r/testimonial-card.json

With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/testimonial-card

I dropped three components into a client project in an afternoon and nothing needed restyling.
MO
Mira OkaforDesign engineer, Northwind

One quote, then the avatar with initials, a name and a role.

A grid of three

The agent notes are the part I did not know I needed. Claude installs the right thing first time.
JL
Jonas LindIndie hacker
Calm by default. I stopped fighting the library and started shipping pages.
PR
Priya RamanFrontend lead, Fathom
Every piece reads our tokens, so the light theme just worked. That has never happened before.
TB
Theo BaptisteFounder, Sill

TestimonialGrid lays three cards out with shared hairlines in one frame.

With a source

Finally a shadcn registry that looks like someone with taste built it.
AC
Ana CostaProduct designer
via X
Installed the command palette, changed two lines, done. Keeping this tab open.
SW
Sam WhitakerStaff engineer, Lumen
via GitHub

Two columns, each card with a mono source line on the right.

Usage

import { TestimonialCard, TestimonialGrid } from "@/components/ui/testimonial-card"

<TestimonialGrid>
  <TestimonialCard quote="Nothing needed restyling." author={{ name: "Mira Okafor", role: "Design engineer" }} source="via X" />
</TestimonialGrid>

Props

PropTypeDefaultDescription
quotestringThe quote as plain text, no quote marks.
author{ name: string; role?: string; image?: string }Who said it. Initials show when there is no image.
sourcestringOne mono line on the right, for example "via X".
columns2 | 33On TestimonialGrid: columns from the sm breakpoint up.

Source

testimonial-card/testimonial-card.tsx
import * as React from "react";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Card, CardContent } from "@/components/ui/card";
import { cn } from "@/lib/utils";

export type TestimonialAuthor = {
  name: string;
  role?: string;
  /** Avatar image url. Initials show until it loads or when it is missing. */
  image?: string;
};

type TestimonialCardProps = Omit<React.ComponentProps<typeof Card>, "children"> & {
  /** The quote, as plain text. No quote marks: the layout says it is a quote. */
  quote: string;
  /** Who said it. */
  author: TestimonialAuthor;
  /** One mono line on the right, for example "via X". */
  source?: string;
};

function initials(name: string) {
  return name
    .split(/\s+/)
    .filter(Boolean)
    .slice(0, 2)
    .map((part) => part.charAt(0).toUpperCase())
    .join("");
}

/**
 * One quote with the person behind it: avatar, name, role.
 * Plain text and tokens only, so a grid of them reads as one calm block.
 */
export function TestimonialCard({ quote, author, source, className, ...props }: TestimonialCardProps) {
  return (
    <Card data-slot="testimonial-card" size="sm" className={cn("gap-4", className)} {...props}>
      <CardContent>
        <figure className="m-0 flex h-full flex-col gap-4">
          <blockquote className="m-0 text-sm text-foreground">{quote}</blockquote>
          <figcaption className="mt-auto flex items-center gap-3">
            <Avatar>
              {author.image && <AvatarImage src={author.image} alt="" />}
              <AvatarFallback className="text-xs font-medium text-foreground/70">
                {initials(author.name)}
              </AvatarFallback>
            </Avatar>
            <div className="flex min-w-0 flex-col">
              <span className="truncate text-sm font-medium text-foreground">{author.name}</span>
              {author.role && <span className="truncate text-xs text-muted-foreground">{author.role}</span>}
            </div>
            {source && <span className="ml-auto shrink-0 font-mono text-xs text-muted-foreground">{source}</span>}
          </figcaption>
        </figure>
      </CardContent>
    </Card>
  );
}

type TestimonialGridProps = React.ComponentProps<"div"> & {
  /** Columns from the sm breakpoint up. Three columns need md. */
  columns?: 2 | 3;
};

/**
 * A frame that lays testimonial cards out with shared hairlines between them.
 * One column on phones, two from sm, three from md when columns is 3.
 */
export function TestimonialGrid({ columns = 3, className, ...props }: TestimonialGridProps) {
  return (
    <div
      data-slot="testimonial-grid"
      className={cn(
        "grid gap-px overflow-hidden rounded-lg border bg-border *:data-[slot=card]:rounded-none *:data-[slot=card]:ring-0",
        columns === 3 ? "sm:grid-cols-2 md:grid-cols-3" : "sm:grid-cols-2",
        className,
      )}
      {...props}
    />
  );
}

For AI agents

Hand your agent this page, the markdown at /components/testimonial-card/prompt.md, or the Copy AI prompt button above.

When to use

  • A social proof section on a landing page, three to six quotes
  • A single quote next to a feature or a pricing table
  • Case studies and changelogs where one line from a customer carries the point

When not to use

  • Long reviews with ratings and dates; use a review list or a table
  • Quotes that need a logo or a product shot; build a feature section instead
  • When you only have one or two quotes and a grid would look sparse

Agent notes

  • Server component on shadcn's Card and Avatar; pass author.image for a photo, otherwise initials render
  • No quote marks are drawn: the figure and blockquote carry the meaning for screen readers
  • TestimonialGrid strips the cards' radius and ring and draws gap-px hairlines; put only TestimonialCards in it
  • Keep quotes to two or three lines; the grid does not equalise heights beyond each row