Skip to content

CTA backed by a quote

The final action beside an endorsement rather than above it.

Marketingstarterctatestimonialproofclosing

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 { ArrowRight } from 'lucide-react'
import { ButtonLink } from '@/components/ui/button'
import { Container } from '@/components/ui/layout'
import { Avatar } from '@/components/ui/avatar'
import { testimonials } from '@/content/demo'

/**
 * CTA backed by a quote
 *
 * The last objection answered by someone who is not the vendor. Putting the
 * quote beside the action rather than above it keeps the two visually bound,
 * so the endorsement reads as support for that specific step.
 */
export default function TestimonialBackedCTA() {
  const quote = testimonials[3]

  return (
    <section className="border-b border-line bg-canvas py-section">
      <Container>
        <div className="grid items-center gap-10 rounded-2xl border border-line bg-surface p-8 sm:p-12 lg:grid-cols-2 lg:gap-16">
          <div>
            <h2 className="display-type text-2xl leading-tight font-semibold text-ink-strong sm:text-3xl">
              The first page takes an afternoon.
            </h2>
            <p className="mt-4 max-w-md text-md text-ink-muted">
              After that you are composing rather than building, and the second one takes an hour.
            </p>
            <ButtonLink
              href="/patterns"
              size="lg"
              className="mt-7"
              trailingIcon={<ArrowRight className="size-4" />}
            >
              See a page assembled
            </ButtonLink>
          </div>

          <figure className="border-l-2 border-accent-line pl-6 lg:pl-8">
            <blockquote className="text-md leading-relaxed text-ink">“{quote?.quote}”</blockquote>
            <figcaption className="mt-5 flex items-center gap-3">
              <Avatar name={quote?.name ?? 'Anonymous'} size="md" decorative />
              <div>
                <p className="text-sm font-semibold text-ink-strong">{quote?.name}</p>
                <p className="text-xs text-ink-muted">
                  {quote?.role}, {quote?.company}
                </p>
              </div>
            </figcaption>
          </figure>
        </div>
      </Container>
    </section>
  )
}

components/blocks/sections/cta/testimonial-backed.tsx

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

/**
 * CTA backed by a quote
 *
 * The last objection answered by someone who is not the vendor. Putting the
 * quote beside the action rather than above it keeps the two visually bound,
 * so the endorsement reads as support for that specific step.
 */
export default function TestimonialBackedCTA() {
  const quote = testimonials[3]

  return (
    <section className="border-b border-line bg-canvas py-section">
      <Container>
        <div className="grid items-center gap-10 rounded-2xl border border-line bg-surface p-8 sm:p-12 lg:grid-cols-2 lg:gap-16">
          <div>
            <h2 className="display-type text-2xl leading-tight font-semibold text-ink-strong sm:text-3xl">
              The first page takes an afternoon.
            </h2>
            <p className="mt-4 max-w-md text-md text-ink-muted">
              After that you are composing rather than building, and the second one takes an hour.
            </p>
            <ButtonLink
              href="/patterns"
              size="lg"
              className="mt-7"
              trailingIcon={<ArrowRight className="size-4" />}
            >
              See a page assembled
            </ButtonLink>
          </div>

          <figure className="border-l-2 border-accent-line pl-6 lg:pl-8">
            <blockquote className="text-md leading-relaxed text-ink">“{quote?.quote}”</blockquote>
            <figcaption className="mt-5 flex items-center gap-3">
              <Avatar name={quote?.name ?? 'Anonymous'} size="md" decorative />
              <div>
                <p className="text-sm font-semibold text-ink-strong">{quote?.name}</p>
                <p className="text-xs text-ink-muted">
                  {quote?.role}, {quote?.company}
                </p>
              </div>
            </figcaption>
          </figure>
        </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

Putting the quote beside the action keeps the two visually bound, so the endorsement reads as support for that specific step.

  • The quote should address the objection the action raises, not praise the product generally.
  • One action; the quote is doing the persuading.

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.

  • Two-column panel
  • Quote beside action
  • Single primary action

Accessibility

Figure semantics
The quote is a figure with blockquote and figcaption.
Heading level
The CTA heading is h2, keeping the outline flat.

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.