Skip to content
Facade UI
Colour mode
GitHub (opens in a new tab)
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

Open full width (opens in a new tab)
Preview width

Installation

Package manager
pnpm dlx shadcn@latest add https://facadeui.dev/r/testimonials-grid.json

Pulls in container, section, section-header, testimonial, types, utils. The CLI installs them for you.

Usage

The exact source of the preview above.

demos/testimonials-grid.tsx
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.

components/sections/testimonials-grid.tsx
/**
 * 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.

Props for TestimonialsGridProps
PropTypeDefault
items*RequiredTestimonialItem[]
titlestring
eyebrowstring
descriptionstring
imageImageComponent
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"