Testimonial Grid
A masonry grid of quote cards with avatars, roles and star ratings, anchored by one dark featured card.
Live preview and source code for Testimonial Grid
"use client"
import React from "react";
import { motion } from "framer-motion";
import { Star } from "lucide-react";
import Avatar from "../../components/avatar/component";
export interface Testimonial {
quote: string;
name: string;
role: string;
/** Optional avatar image. Falls back to coloured initials */
src?: string;
/** 1–5. Omit to hide the stars on that card */
rating?: number;
/** Renders the card in the dark accent style */
featured?: boolean;
}
export interface TestimonialGridProps {
eyebrow?: string;
heading?: string;
testimonials?: Testimonial[];
}
const DEFAULT_TESTIMONIALS: Testimonial[] = [
{
quote:
"We replaced three half-finished internal components with this in an afternoon. The defaults are opinionated in exactly the right places — nothing to configure before it looks correct.",
name: "Priya Nair",
role: "Design Engineer, Loomcraft",
rating: 5,
featured: true,
},
{
quote: "The animation details are the part I keep coming back to. It feels considered rather than decorated.",
name: "Kai Fischer",
role: "Frontend Lead, Northbeam",
rating: 5,
},
{
quote: "Copy, paste, ship. No build step, no peer-dependency archaeology, no theme provider to wire up first.",
name: "Ana Ruiz",
role: "Founder, Studio Ninetysix",
rating: 5,
},
{
quote: "Accessible by default was the thing that sold my team. Focus states and labels were already handled.",
name: "Omar Haddad",
role: "Staff Engineer, Cartogram",
rating: 4,
},
{
quote: "I used it for a client landing page on a Friday and nobody could tell it wasn't bespoke work.",
name: "Lena Novak",
role: "Independent Designer",
rating: 5,
},
];
/**
* TestimonialGrid — a masonry-ish grid of quote cards with avatars,
* roles and star ratings, with one dark featured card.
* Renders standalone with zero props.
*/
export default function TestimonialGrid({
eyebrow = "Testimonials",
heading = "Loved by people who sweat the details",
testimonials = DEFAULT_TESTIMONIALS,
}: TestimonialGridProps) {
return (
<section className="tg-root">
<header className="tg-head">
<span className="tg-eyebrow">{eyebrow}</span>
<h2 className="tg-heading">{heading}</h2>
</header>
<div className="tg-grid">
{testimonials.map((item, i) => (
<motion.figure
key={`${item.name}-${i}`}
className={`tg-card ${item.featured ? "is-featured" : ""}`}
initial={{ opacity: 0, y: 18 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, amount: 0.3 }}
transition={{ duration: 0.45, ease: "easeOut", delay: Math.min(i * 0.06, 0.3) }}
>
{typeof item.rating === "number" && (
<div className="tg-stars" aria-label={`${item.rating} out of 5`}>
{Array.from({ length: 5 }).map((_, starIndex) => (
<Star
key={starIndex}
size={13}
strokeWidth={0}
fill={starIndex < item.rating! ? "#f59e0b" : item.featured ? "rgba(255,255,255,0.22)" : "#e4e4e7"}
/>
))}
</div>
)}
<blockquote className="tg-quote">{item.quote}</blockquote>
<figcaption className="tg-person">
<Avatar name={item.name} src={item.src} size="md" />
<span className="tg-person-meta">
<span className="tg-name">{item.name}</span>
<span className="tg-role">{item.role}</span>
</span>
</figcaption>
</motion.figure>
))}
</div>
<style>{`
.tg-root {
width: 100%;
padding: 72px 24px;
background: #fbfbfd;
font-family: inherit;
color: #18181b;
}
.tg-head {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
text-align: center;
margin-bottom: 40px;
}
.tg-eyebrow {
font-size: 12.5px;
font-weight: 600;
letter-spacing: 0.09em;
text-transform: uppercase;
color: #7c3aed;
}
.tg-heading {
margin: 0;
max-width: 620px;
font-size: clamp(26px, 4vw, 38px);
letter-spacing: -0.03em;
font-weight: 600;
text-wrap: balance;
}
/* CSS columns give the staggered masonry look without JS measuring */
.tg-grid {
max-width: 1040px;
margin: 0 auto;
column-count: 3;
column-gap: 18px;
}
.tg-card {
break-inside: avoid;
display: flex;
flex-direction: column;
gap: 14px;
margin: 0 0 18px;
padding: 22px;
background: #ffffff;
border: 1px solid rgba(24,24,27,0.09);
border-radius: 18px;
box-shadow: 0 1px 2px rgba(0,0,0,0.04);
}
.tg-card.is-featured {
background: #18181b;
border-color: #18181b;
color: #fafafa;
box-shadow: 0 20px 44px -26px rgba(24,24,27,0.8);
}
.tg-stars { display: flex; gap: 2px; }
.tg-quote {
margin: 0;
font-size: 14.5px;
line-height: 1.62;
color: inherit;
}
.tg-card:not(.is-featured) .tg-quote { color: #3f3f46; }
.tg-person {
display: flex;
align-items: center;
gap: 11px;
margin-top: auto;
padding-top: 4px;
}
.tg-person-meta { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
.tg-name { font-size: 13.5px; font-weight: 600; }
.tg-role { font-size: 12.5px; color: #a1a1aa; }
.tg-card:not(.is-featured) .tg-role { color: #71717a; }
@media (max-width: 900px) { .tg-grid { column-count: 2; } }
@media (max-width: 600px) {
.tg-grid { column-count: 1; max-width: 420px; }
.tg-root { padding: 56px 18px; }
}
`}</style>
</section>
);
}
Loved by people who sweat the details
We replaced three half-finished internal components with this in an afternoon. The defaults are opinionated in exactly the right places — nothing to configure before it looks correct.
The animation details are the part I keep coming back to. It feels considered rather than decorated.
Copy, paste, ship. No build step, no peer-dependency archaeology, no theme provider to wire up first.
Accessible by default was the thing that sold my team. Focus states and labels were already handled.
I used it for a client landing page on a Friday and nobody could tell it wasn't bespoke work.
A social proof section built as a masonry grid of quote cards, each with a star
rating, the quote, an avatar and a role, and one dark card that anchors the
layout. The columns come from CSS column-count rather than a measuring
script, so cards of different lengths pack without a layout library and the
grid reflows on its own. Cards fade upward as they scroll into view, once each.
Installation
Copy the source from the Code tab above into your project as
TestimonialGrid.tsx. Its styles are a <style> block of tg- prefixed
classes that travels with the file, so there is no stylesheet to import.
It imports framer-motion for the scroll-in animation and lucide-react for
the star:
npm install framer-motion lucide-reactIt also renders one item from this collection, so copy that file too and fix the import path at the top to match where you put it: Avatar.
Usage
Pass your own quotes; everything else has a default:
import TestimonialGrid from "./components/TestimonialGrid";
const testimonials = [
{
quote: "We replaced three half-finished internal components with this in an afternoon.",
name: "Priya Nair",
role: "Design Engineer, Loomcraft",
rating: 5,
featured: true,
},
];
export default function Example() {
return <TestimonialGrid heading="Loved by people who sweat the details" testimonials={testimonials} />;
}src on a testimonial sets the avatar image and falls back to coloured
initials when it is missing. Omit rating and the stars disappear on that
card. featured renders the card in the dark style.
When to reach for it
The proof section of a landing page, below the hero and above pricing, where five or six short quotes carry more weight than one long case study.
A launch page collecting early feedback, where the quotes vary in length. The column layout is more forgiving of that than an equal-height grid.
A page where one quote should dominate. Mark the strongest as featured and it
becomes the dark card the eye lands on first.
When not to
If reading order matters. CSS columns fill top to bottom, so the second card sits under the first rather than beside it, and the visual order does not match the order in the array. That is fine for a wall of quotes and wrong for a ranked or narrative sequence.
If you have one testimonial. A single card in a three-column grid looks like a layout that failed rather than a deliberate pull quote.
If the quotes need to be interactive. There is no carousel, no expand, no link to a full story. Cards are static, and a quote long enough to need truncation will simply make its card taller than the rest.
Accessibility
Each card is a <figure> with a <blockquote> and a <figcaption>, which is
the correct structure for an attributed quote, so the name and role are
associated with the quote rather than floating near it.
The star row carries an aria-label giving the rating out of five, so the
score does not depend on counting filled shapes. The heading is an <h2>, and
the eyebrow above it is a plain <span>, so it does not add a level to the
document outline.
The scroll-in animation is not gated on prefers-reduced-motion. Cards start
offset and transparent, so removing the initial and whileInView values in
the copied file is what honours that setting.
Notes
The default testimonials are fiction. Five invented names, roles and companies ship with the file so the block renders on its own. Replace all of them before the page goes anywhere near a user.
rating is compared per star, so a value like 4.5 fills four stars and
nothing partial. Round before passing it.
Ratings are drawn as filled and unfilled stars in the same row, and on the featured card the unfilled ones are a translucent white rather than grey, which keeps a 4 out of 5 readable against the dark background.
The grid drops to two columns at 900px and one at 600px. With fewer cards than columns the layout still fills left to right, so three or more quotes is where it looks intentional.