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
One quote, then the avatar with initials, a name and a role.
A grid of three
TestimonialGrid lays three cards out with shared hairlines in one frame.
With a source
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
| Prop | Type | Default | Description |
|---|---|---|---|
| quote | string | The quote as plain text, no quote marks. | |
| author | { name: string; role?: string; image?: string } | Who said it. Initials show when there is no image. | |
| source | string | One mono line on the right, for example "via X". | |
| columns | 2 | 3 | 3 | On 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