Skip to content

Testimonial-led hero

A hero where the customer quote is the headline.

Marketingstarterherotestimonialtrustquote

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 { ButtonLink } from '@/components/ui/button'
import { testimonials } from '@/content/demo'

/**
 * Testimonial-led hero
 *
 * The quote *is* the headline. Appropriate when the product is well understood
 * and the remaining objection is trust — a hero that explains what the product
 * does is wasted on an audience that already knows.
 */
export default function TestimonialLedHero() {
  const quote = testimonials[0]

  return (
    <section className="border-b border-line bg-surface-sunken py-section">
      <Container size="narrow">
        <figure className="text-center">
          <blockquote className="display-type text-2xl leading-snug font-medium text-ink-strong text-balance sm:text-3xl lg:text-4xl">
            “{quote?.quote}”
          </blockquote>
          <figcaption className="mt-8 flex flex-col items-center gap-3">
            <Avatar name={quote?.name ?? 'Anonymous'} size="lg" decorative />
            <div>
              <p className="text-sm font-semibold text-ink-strong">{quote?.name}</p>
              <p className="text-sm text-ink-muted">
                {quote?.role}, {quote?.company}
              </p>
            </div>
          </figcaption>
        </figure>

        <div className="mt-10 flex flex-wrap items-center justify-center gap-3">
          <ButtonLink href="/starters">Browse starters</ButtonLink>
          <ButtonLink href="/patterns" variant="outline">
            See a full page
          </ButtonLink>
        </div>
      </Container>
    </section>
  )
}

components/blocks/sections/hero/testimonial-led.tsx

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

/**
 * Testimonial-led hero
 *
 * The quote *is* the headline. Appropriate when the product is well understood
 * and the remaining objection is trust — a hero that explains what the product
 * does is wasted on an audience that already knows.
 */
export default function TestimonialLedHero() {
  const quote = testimonials[0]

  return (
    <section className="border-b border-line bg-surface-sunken py-section">
      <Container size="narrow">
        <figure className="text-center">
          <blockquote className="display-type text-2xl leading-snug font-medium text-ink-strong text-balance sm:text-3xl lg:text-4xl">
            “{quote?.quote}”
          </blockquote>
          <figcaption className="mt-8 flex flex-col items-center gap-3">
            <Avatar name={quote?.name ?? 'Anonymous'} size="lg" decorative />
            <div>
              <p className="text-sm font-semibold text-ink-strong">{quote?.name}</p>
              <p className="text-sm text-ink-muted">
                {quote?.role}, {quote?.company}
              </p>
            </div>
          </figcaption>
        </figure>

        <div className="mt-10 flex flex-wrap items-center justify-center gap-3">
          <ButtonLink href="/starters">Browse starters</ButtonLink>
          <ButtonLink href="/patterns" variant="outline">
            See a full page
          </ButtonLink>
        </div>
      </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

Appropriate when the product is well understood and the remaining objection is trust. A hero explaining what the product does is wasted on an audience that already knows.

  • Use the strongest quote you have; a hero cannot carry a weak one.
  • Keep the attribution close to the quote so the two read as one unit.

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.

  • Oversized quote
  • Attribution block
  • Dual action

Accessibility

Figure semantics
A figure with blockquote and figcaption, so attribution is structurally attached.
Decorative avatar
The avatar is hidden because the name is already visible.

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.