blocksection
Testimonials grid
A wall of customer quotes, optionally in masonry columns that still read in document order.
Also ships a motion variant: Testimonials grid (motion). The static one is the default.
Preview
Installation
pnpm dlx shadcn@latest add https://facadeui.dev/r/testimonials-grid.jsonPulls in container, section, section-header, testimonial, types, utils. The CLI installs them for you.
Usage
The exact source of the preview above.
import { TestimonialsGrid } from "@registry/sections/testimonials-grid"
import { QUOTES } from "./content"
export function Demo() {
return (
<TestimonialsGrid
eyebrow="Customers"
title="What teams say"
description="Masonry columns flow in document order, so reading order still matches what you see."
items={QUOTES}
columns="masonry"
/>
)
}Source
What the CLI copies into your project, byte for byte.
/**
* TestimonialsGrid — a wall of customer quotes.
*
* `columns="masonry"` uses CSS multi-column, which keeps ragged quote lengths
* from leaving a row of tall gaps. That is safe here in a way `grid-auto-flow:
* dense` is not: multi-column flows items in document order down each column,
* so reading order and visual order still agree.
*
* a11y: a `<ul>` of `<li>`s, each holding a `Testimonial` — which is a
* `<figure>` with the quote in a `<blockquote>` and the attribution in a
* `<figcaption>` outside it, because the person's name is not part of what they
* said. Ratings are announced as "Rated 5 out of 5", not as five star icons.
*
* Dependencies: react, @/lib/types, @/lib/utils,
* @/components/ui/container, @/components/ui/section, @/components/ui/section-header,
* @/components/ui/testimonial.
*/
import type { ElementType } from "react"
import type { ImageComponent, ListSlotProps, SectionBaseProps } from "@/lib/types"
import { cn, slugId } from "@/lib/utils"
import { Container } from "@/components/ui/container"
import { Section } from "@/components/ui/section"
import { SectionHeader } from "@/components/ui/section-header"
import { Testimonial, type TestimonialItem } from "@/components/ui/testimonial"
export interface TestimonialsGridProps extends SectionBaseProps, ListSlotProps {
items: TestimonialItem[]
title?: string
eyebrow?: string
description?: string
image?: ImageComponent
/** `masonry` flows in columns, so uneven quote lengths do not leave gaps. */
columns?: 2 | 3 | "masonry"
variant?: "card" | "plain"
align?: "start" | "center"
}
export function TestimonialsGrid({
items,
title,
eyebrow,
description,
image,
columns = 3,
variant = "card",
align = "center",
listAs,
itemAs,
headingLevel = 2,
as,
spacing = "md",
className,
id,
}: TestimonialsGridProps) {
const List = (listAs ?? "ul") as ElementType
const Item = (itemAs ?? "li") as ElementType
const titleId = title ? (id ? `${id}-title` : slugId(title)) : undefined
const masonry = columns === "masonry"
return (
<Section
as={as ?? (title ? "section" : "div")}
spacing={spacing}
labelledBy={titleId}
id={id}
className={className}
>
<Container className="flex flex-col gap-14">
{title ? (
<SectionHeader
align={align}
eyebrow={eyebrow}
title={title}
description={description}
headingLevel={headingLevel}
titleId={titleId}
/>
) : null}
<List
className={cn(
masonry
? "gap-6 sm:columns-2 lg:columns-3 [&>*]:mb-6 [&>*]:break-inside-avoid"
: "grid gap-6",
!masonry && columns === 2 && "sm:grid-cols-2",
!masonry && columns === 3 && "sm:grid-cols-2 lg:grid-cols-3",
)}
>
{items.map((item, index) => (
<Item key={item.id ?? `${item.author.name}-${index}`}>
<Testimonial {...item} image={image} variant={variant} />
</Item>
))}
</List>
</Container>
</Section>
)
}Props
TestimonialsGridProps
Extends SectionBaseProps, ListSlotProps.
| Prop | Type | Default |
|---|---|---|
items*Required | TestimonialItem[] | — |
title | string | — |
eyebrow | string | — |
description | string | — |
image | ImageComponent | — |
columns`masonry` flows in columns, so uneven quote lengths do not leave gaps. | 2 | 3 | "masonry" | 3 |
variant | "card" | "plain" | "card" |
align | "start" | "center" | "center" |