Skip to content

Three-column testimonials

Equal cards with attribution structurally attached to each quote.

MarketingstarterFeaturedtestimonialsgridcardssocial-proof

Live preview

full widthLive preview — open it in a new tab for the full-height version.
Open the preview in a new tab

Source

This exact file renders the preview above.

import { Container } from '@/components/ui/layout'
import { Avatar } from '@/components/ui/avatar'
import { testimonials } from '@/content/demo'

/**
 * Three-column testimonials
 *
 * Equal cards, equal weight. Each quote is a `<figure>` with a `<blockquote>`
 * and `<figcaption>`, so attribution is structurally attached to the quote
 * rather than being a paragraph that happens to sit below it.
 */
export default function ThreeColumnTestimonials() {
  return (
    <section className="border-b border-line bg-canvas py-section">
      <Container>
        <div className="max-w-2xl">
          <p className="label-caps text-accent">In their words</p>
          <h2 className="display-type mt-3 text-2xl font-semibold text-ink-strong sm:text-3xl">
            What teams say after the second project.
          </h2>
        </div>

        <ul className="mt-12 grid gap-4 lg:grid-cols-3">
          {testimonials.slice(0, 3).map((quote) => (
            <li key={quote.name}>
              <figure className="flex h-full flex-col rounded-xl border border-line bg-surface p-6">
                <blockquote className="flex-1 text-sm leading-relaxed text-ink">
                  “{quote.quote}”
                </blockquote>
                <figcaption className="mt-6 flex items-center gap-3 border-t border-line-subtle pt-5">
                  <Avatar name={quote.name} size="md" decorative />
                  <div className="min-w-0">
                    <p className="truncate text-sm font-semibold text-ink-strong">{quote.name}</p>
                    <p className="truncate text-xs text-ink-muted">
                      {quote.role}, {quote.company}
                    </p>
                  </div>
                </figcaption>
              </figure>
            </li>
          ))}
        </ul>
      </Container>
    </section>
  )
}

components/blocks/sections/testimonials/three-column.tsx

import { Container } from '@/components/ui/layout'
import { Avatar } from '@/components/ui/avatar'
import { testimonials } from '@/content/demo'

/**
 * Three-column testimonials
 *
 * Equal cards, equal weight. Each quote is a `<figure>` with a `<blockquote>`
 * and `<figcaption>`, so attribution is structurally attached to the quote
 * rather than being a paragraph that happens to sit below it.
 */
export default function ThreeColumnTestimonials() {
  return (
    <section className="border-b border-line bg-canvas py-section">
      <Container>
        <div className="max-w-2xl">
          <p className="label-caps text-accent">In their words</p>
          <h2 className="display-type mt-3 text-2xl font-semibold text-ink-strong sm:text-3xl">
            What teams say after the second project.
          </h2>
        </div>

        <ul className="mt-12 grid gap-4 lg:grid-cols-3">
          {testimonials.slice(0, 3).map((quote) => (
            <li key={quote.name}>
              <figure className="flex h-full flex-col rounded-xl border border-line bg-surface p-6">
                <blockquote className="flex-1 text-sm leading-relaxed text-ink">
                  “{quote.quote}”
                </blockquote>
                <figcaption className="mt-6 flex items-center gap-3 border-t border-line-subtle pt-5">
                  <Avatar name={quote.name} size="md" decorative />
                  <div className="min-w-0">
                    <p className="truncate text-sm font-semibold text-ink-strong">{quote.name}</p>
                    <p className="truncate text-xs text-ink-muted">
                      {quote.role}, {quote.company}
                    </p>
                  </div>
                </figcaption>
              </figure>
            </li>
          ))}
        </ul>
      </Container>
    </section>
  )
}

components/ui/avatar.tsx

import type { ReactNode } from 'react'
import { cn } from '@/lib/cn'
import { initials as toInitials } from '@/lib/format'

/**
 * Avatar / AvatarGroup
 *
 * Foundry ships no photography, so avatars render deterministic initials on a
 * tinted surface. The tint is derived from the name's character codes, which
 * keeps the same person the same colour on every page without a colour field
 * in the data.
 *
 * A decorative avatar next to a visible name is `aria-hidden`; a standalone
 * one exposes the name as its label.
 */
export interface AvatarProps {
  name: string
  size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl'
  /** Suppresses the accessible name when the name is already on screen. */
  decorative?: boolean
  /** Small badge anchored bottom-right, e.g. a presence dot. */
  indicator?: ReactNode
  shape?: 'circle' | 'square'
  className?: string
}

const sizes = {
  xs: 'size-5 text-2xs',
  sm: 'size-7 text-2xs',
  md: 'size-9 text-xs',
  lg: 'size-12 text-sm',
  xl: 'size-16 text-lg',
} as const

const tints = [
  'bg-accent-soft text-accent-soft-ink',
  'bg-success-soft text-success',
  'bg-warning-soft text-warning',
  'bg-info-soft text-info',
  'bg-danger-soft text-danger',
  'bg-surface-sunken text-ink-muted',
] as const

function tintFor(name: string): string {
  let hash = 0
  for (let i = 0; i < name.length; i += 1) hash = (hash * 31 + name.charCodeAt(i)) % 997
  return tints[hash % tints.length] ?? tints[0]
}

export function Avatar({
  name,
  size = 'md',
  decorative = false,
  indicator,
  shape = 'circle',
  className,
}: AvatarProps) {
  return (
    <span className={cn('relative inline-flex shrink-0', className)}>
      <span
        role={decorative ? undefined : 'img'}
        aria-label={decorative ? undefined : name}
        aria-hidden={decorative || undefined}
        className={cn(
          'inline-flex items-center justify-center border border-line font-semibold select-none',
          shape === 'circle' ? 'rounded-full' : 'rounded-md',
          sizes[size],
          tintFor(name),
        )}
      >
        {toInitials(name)}
      </span>
      {indicator ? <span className="absolute -right-0.5 -bottom-0.5">{indicator}</span> : null}
    </span>
  )
}

export interface AvatarGroupProps {
  names: string[]
  size?: AvatarProps['size']
  /** Names beyond this count collapse into a "+n" chip. */
  max?: number
  className?: string
  label?: string
}

export function AvatarGroup({ names, size = 'sm', max = 4, className, label }: AvatarGroupProps) {
  const visible = names.slice(0, max)
  const overflow = names.length - visible.length

  return (
    <span
      className={cn('flex items-center', className)}
      role="group"
      aria-label={label ?? `${names.length} people`}
    >
      {visible.map((name) => (
        <span
          key={name}
          className="-ml-2 first:ml-0 ring-2 ring-[var(--color-surface)] rounded-full"
        >
          <Avatar name={name} size={size} decorative />
        </span>
      ))}
      {overflow > 0 ? (
        <span
          className={cn(
            '-ml-2 inline-flex items-center justify-center rounded-full border border-line bg-surface-sunken font-semibold text-ink-muted ring-2 ring-[var(--color-surface)]',
            sizes[size],
          )}
        >
          +{overflow}
        </span>
      ) : null}
      <span className="sr-only">{names.join(', ')}</span>
    </span>
  )
}

Demo source — adapt to your project. Foundry is not published as a package.

Usage

Each quote is a figure with a blockquote and figcaption, so attribution is attached to the quote rather than being a paragraph that happens to sit below it.

  • Three quotes, three different objections answered — not three versions of the same praise.
  • Cards are equal height with the attribution pinned to the bottom, so the three align.

Variants and states

Every entry below is a genuine difference in behaviour or layout, and every one of them is visible in the preview above.

  • Three columns at lg
  • Equal-height cards
  • Attribution footer

Accessibility

Figure semantics
blockquote plus figcaption on every card.
Truncation
Long names and roles truncate visually but stay complete in the accessible name.

Foundry implements published ARIA patterns and is tested against them. No WCAG certification is claimed — see the accessibility documentation for what is and is not covered.