Skip to content

Book-a-call section

Length, host and agenda stated before the booking action.

Marketingstartercontactbookingsalescall

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 { CalendarClock, Check } from 'lucide-react'
import { ButtonLink } from '@/components/ui/button'
import { Container } from '@/components/ui/layout'
import { Avatar } from '@/components/ui/avatar'

/**
 * Book-a-call contact section
 *
 * For a sales motion where a conversation converts better than a form. States
 * the length, who you will speak to and what happens on the call — the three
 * unknowns that stop people booking.
 */
const agenda = [
  'What you are building and where the current system leaks',
  'A live walkthrough of the parts relevant to you',
  'An honest answer about whether this is the right fit',
]

export default function BookingCTAContact() {
  return (
    <section className="border-b border-line bg-surface-sunken py-section">
      <Container size="narrow">
        <div className="grid gap-8 rounded-2xl border border-line bg-surface p-6 sm:p-10 lg:grid-cols-[1.2fr_1fr] lg:gap-12">
          <div>
            <span className="flex size-10 items-center justify-center rounded-lg bg-accent-soft text-accent-soft-ink">
              <CalendarClock className="size-5" aria-hidden="true" />
            </span>
            <h2 className="display-type mt-5 text-2xl font-semibold text-ink-strong">
              Thirty minutes, no slides.
            </h2>
            <p className="mt-3 max-w-md text-md text-ink-muted">
              A working session rather than a demo. Bring a screen you are unhappy with.
            </p>
            <ul className="mt-6 flex flex-col gap-2.5">
              {agenda.map((item) => (
                <li key={item} className="flex items-start gap-2.5 text-sm text-ink">
                  <Check className="mt-0.5 size-4 shrink-0 text-success" aria-hidden="true" />
                  {item}
                </li>
              ))}
            </ul>
          </div>

          <div className="flex flex-col justify-center rounded-xl border border-line bg-surface-sunken p-6">
            <div className="flex items-center gap-3">
              <Avatar name="Jun Watanabe" size="lg" decorative />
              <div>
                <p className="text-sm font-semibold text-ink-strong">Jun Watanabe</p>
                <p className="text-xs text-ink-muted">Product Lead</p>
              </div>
            </div>
            <p className="mt-4 text-sm text-ink-muted">
              You will speak to Jun, who will either help or tell you this is not a fit.
            </p>
            <ButtonLink href="/forms/booking" block className="mt-5">
              Book a slot
            </ButtonLink>
            <p className="mt-3 text-center text-xs text-ink-subtle">
              Availability shown is fictional.
            </p>
          </div>
        </div>
      </Container>
    </section>
  )
}

components/blocks/sections/contact/booking-cta.tsx

import { CalendarClock, Check } from 'lucide-react'
import { ButtonLink } from '@/components/ui/button'
import { Container } from '@/components/ui/layout'
import { Avatar } from '@/components/ui/avatar'

/**
 * Book-a-call contact section
 *
 * For a sales motion where a conversation converts better than a form. States
 * the length, who you will speak to and what happens on the call — the three
 * unknowns that stop people booking.
 */
const agenda = [
  'What you are building and where the current system leaks',
  'A live walkthrough of the parts relevant to you',
  'An honest answer about whether this is the right fit',
]

export default function BookingCTAContact() {
  return (
    <section className="border-b border-line bg-surface-sunken py-section">
      <Container size="narrow">
        <div className="grid gap-8 rounded-2xl border border-line bg-surface p-6 sm:p-10 lg:grid-cols-[1.2fr_1fr] lg:gap-12">
          <div>
            <span className="flex size-10 items-center justify-center rounded-lg bg-accent-soft text-accent-soft-ink">
              <CalendarClock className="size-5" aria-hidden="true" />
            </span>
            <h2 className="display-type mt-5 text-2xl font-semibold text-ink-strong">
              Thirty minutes, no slides.
            </h2>
            <p className="mt-3 max-w-md text-md text-ink-muted">
              A working session rather than a demo. Bring a screen you are unhappy with.
            </p>
            <ul className="mt-6 flex flex-col gap-2.5">
              {agenda.map((item) => (
                <li key={item} className="flex items-start gap-2.5 text-sm text-ink">
                  <Check className="mt-0.5 size-4 shrink-0 text-success" aria-hidden="true" />
                  {item}
                </li>
              ))}
            </ul>
          </div>

          <div className="flex flex-col justify-center rounded-xl border border-line bg-surface-sunken p-6">
            <div className="flex items-center gap-3">
              <Avatar name="Jun Watanabe" size="lg" decorative />
              <div>
                <p className="text-sm font-semibold text-ink-strong">Jun Watanabe</p>
                <p className="text-xs text-ink-muted">Product Lead</p>
              </div>
            </div>
            <p className="mt-4 text-sm text-ink-muted">
              You will speak to Jun, who will either help or tell you this is not a fit.
            </p>
            <ButtonLink href="/forms/booking" block className="mt-5">
              Book a slot
            </ButtonLink>
            <p className="mt-3 text-center text-xs text-ink-subtle">
              Availability shown is fictional.
            </p>
          </div>
        </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

Length, who you will speak to and what happens on the call are the three unknowns that stop people booking. All three are answered above the button.

  • Name a person, not a team. A named host converts better than 'our team'.
  • Include 'we will tell you if this is not a fit' — it removes the main hesitation.

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.

  • Agenda list
  • Named host
  • Booking action

Accessibility

List semantics
The agenda is a real list.
Decorative avatar
The host avatar is hidden because the name is beside it.

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.