Skip to content

Waitlist hero

A hero whose only job is one email address, with validation and inline social proof.

Marketingintermediateherowaitlistemaillaunch

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.

'use client'

import { useState, type FormEvent } from 'react'
import { CheckCircle2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Container } from '@/components/ui/layout'
import { Field } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { AvatarGroup } from '@/components/ui/avatar'
import { team } from '@/content/demo'

/**
 * Waitlist hero
 *
 * A hero whose only job is one email address. The form is inline rather than
 * below the fold, the field is the largest control on the page, and the social
 * proof sits directly beneath it — the three things that decide whether a
 * waitlist hero works.
 */
export default function WaitlistHero() {
  const [value, setValue] = useState('')
  const [error, setError] = useState<string | null>(null)
  const [joined, setJoined] = useState(false)

  const submit = (event: FormEvent<HTMLFormElement>) => {
    event.preventDefault()
    const trimmed = value.trim()
    if (!trimmed) return setError('Enter an email address.')
    if (!/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(trimmed)) {
      return setError('Enter a valid email address, for example name@company.com.')
    }
    setError(null)
    setJoined(true)
  }

  return (
    <section className="border-b border-line bg-canvas py-section">
      <Container size="narrow" className="text-center">
        <h2 className="display-type text-3xl leading-tight font-semibold text-ink-strong sm:text-4xl lg:text-5xl">
          Early access opens in April.
        </h2>
        <p className="mx-auto mt-4 max-w-lg text-md text-ink-muted">
          We are letting teams in a few at a time so onboarding stays personal. Join the list and we
          will be in touch.
        </p>

        <div className="mx-auto mt-8 max-w-md">
          {joined ? (
            <div
              role="status"
              className="flex items-center justify-center gap-2.5 rounded-lg border border-success-line bg-success-soft px-4 py-4 text-sm"
            >
              <CheckCircle2 className="size-4 shrink-0 text-success" aria-hidden="true" />
              <span className="text-ink">
                You are on the list. This is a template demo — no submission was sent.
              </span>
            </div>
          ) : (
            <form
              onSubmit={submit}
              noValidate
              className="flex flex-col gap-3 sm:flex-row sm:items-start"
            >
              <Field
                name="waitlist-email"
                label="Email address"
                hideLabel
                error={error}
                className="flex-1 text-left"
              >
                {(field) => (
                  <Input
                    {...field}
                    type="email"
                    inputSize="lg"
                    autoComplete="email"
                    placeholder="you@company.com"
                    value={value}
                    onChange={(event) => {
                      setValue(event.target.value)
                      if (error) setError(null)
                    }}
                  />
                )}
              </Field>
              <Button type="submit" size="lg" className="shrink-0">
                Join the list
              </Button>
            </form>
          )}
        </div>

        <div className="mt-8 flex flex-col items-center gap-2.5">
          <AvatarGroup
            names={team.slice(0, 5).map((member) => member.name)}
            size="sm"
            label="People already on the waitlist"
          />
          <p className="text-sm text-ink-muted">1,240 teams waiting</p>
        </div>
      </Container>
    </section>
  )
}

components/blocks/sections/hero/waitlist.tsx

'use client'

import { useState, type FormEvent } from 'react'
import { CheckCircle2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Container } from '@/components/ui/layout'
import { Field } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { AvatarGroup } from '@/components/ui/avatar'
import { team } from '@/content/demo'

/**
 * Waitlist hero
 *
 * A hero whose only job is one email address. The form is inline rather than
 * below the fold, the field is the largest control on the page, and the social
 * proof sits directly beneath it — the three things that decide whether a
 * waitlist hero works.
 */
export default function WaitlistHero() {
  const [value, setValue] = useState('')
  const [error, setError] = useState<string | null>(null)
  const [joined, setJoined] = useState(false)

  const submit = (event: FormEvent<HTMLFormElement>) => {
    event.preventDefault()
    const trimmed = value.trim()
    if (!trimmed) return setError('Enter an email address.')
    if (!/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(trimmed)) {
      return setError('Enter a valid email address, for example name@company.com.')
    }
    setError(null)
    setJoined(true)
  }

  return (
    <section className="border-b border-line bg-canvas py-section">
      <Container size="narrow" className="text-center">
        <h2 className="display-type text-3xl leading-tight font-semibold text-ink-strong sm:text-4xl lg:text-5xl">
          Early access opens in April.
        </h2>
        <p className="mx-auto mt-4 max-w-lg text-md text-ink-muted">
          We are letting teams in a few at a time so onboarding stays personal. Join the list and we
          will be in touch.
        </p>

        <div className="mx-auto mt-8 max-w-md">
          {joined ? (
            <div
              role="status"
              className="flex items-center justify-center gap-2.5 rounded-lg border border-success-line bg-success-soft px-4 py-4 text-sm"
            >
              <CheckCircle2 className="size-4 shrink-0 text-success" aria-hidden="true" />
              <span className="text-ink">
                You are on the list. This is a template demo — no submission was sent.
              </span>
            </div>
          ) : (
            <form
              onSubmit={submit}
              noValidate
              className="flex flex-col gap-3 sm:flex-row sm:items-start"
            >
              <Field
                name="waitlist-email"
                label="Email address"
                hideLabel
                error={error}
                className="flex-1 text-left"
              >
                {(field) => (
                  <Input
                    {...field}
                    type="email"
                    inputSize="lg"
                    autoComplete="email"
                    placeholder="you@company.com"
                    value={value}
                    onChange={(event) => {
                      setValue(event.target.value)
                      if (error) setError(null)
                    }}
                  />
                )}
              </Field>
              <Button type="submit" size="lg" className="shrink-0">
                Join the list
              </Button>
            </form>
          )}
        </div>

        <div className="mt-8 flex flex-col items-center gap-2.5">
          <AvatarGroup
            names={team.slice(0, 5).map((member) => member.name)}
            size="sm"
            label="People already on the waitlist"
          />
          <p className="text-sm text-ink-muted">1,240 teams waiting</p>
        </div>
      </Container>
    </section>
  )
}

components/ui/field.tsx

import type { ReactNode } from 'react'
import { AlertCircle, CheckCircle2 } from 'lucide-react'
import { cn } from '@/lib/cn'

/**
 * Field
 *
 * The accessibility contract for every form control in Foundry lives here, in
 * one place, rather than being re-implemented per input.
 *
 * Ids are derived deterministically from `name` instead of `useId`, for two
 * reasons: the component stays renderable from a Server Component (no hooks),
 * and the markup is byte-identical between server and client, so a form works
 * before hydration — see `/docs/accessibility`.
 *
 * The render prop hands back the exact wiring a control needs, which makes it
 * impossible to forget `aria-describedby` on a field that has help text.
 */
export interface FieldRenderProps {
  id: string
  name: string
  'aria-describedby': string | undefined
  'aria-invalid': true | undefined
  'aria-required': true | undefined
  required: boolean
  invalid: boolean
}

export interface FieldProps {
  name: string
  label: ReactNode
  hint?: ReactNode
  /** Validation failure. Presence flips the field into its invalid state. */
  error?: string | null
  /** Confirmation message shown once a value has validated. */
  success?: string | null
  required?: boolean
  /** Marks the field "Optional" instead of marking required fields. */
  showOptional?: boolean
  /** Visually hides the label while keeping it available to screen readers. */
  hideLabel?: boolean
  className?: string
  /** Disambiguates ids when the same field name appears twice on one page. */
  idPrefix?: string
  children: (props: FieldRenderProps) => ReactNode
}

export function Field({
  name,
  label,
  hint,
  error,
  success,
  required = false,
  showOptional = false,
  hideLabel = false,
  className,
  idPrefix = 'field',
  children,
}: FieldProps) {
  const id = `${idPrefix}-${name}`
  const hintId = hint ? `${id}-hint` : undefined
  const errorId = error ? `${id}-error` : undefined
  const successId = success && !error ? `${id}-success` : undefined
  const describedBy = [hintId, errorId, successId].filter(Boolean).join(' ') || undefined

  return (
    <div className={cn('flex flex-col gap-1.5', className)}>
      <label
        htmlFor={id}
        className={cn(
          'flex items-baseline gap-1.5 text-sm font-medium text-ink',
          hideLabel && 'sr-only',
        )}
      >
        <span>{label}</span>
        {required && !showOptional ? (
          <span className="text-danger" aria-hidden="true">
            *
          </span>
        ) : null}
        {showOptional && !required ? (
          <span className="text-xs font-normal text-ink-subtle">Optional</span>
        ) : null}
      </label>

      {hint ? (
        <p id={hintId} className="text-xs leading-normal text-ink-muted">
          {hint}
        </p>
      ) : null}

      {children({
        id,
        name,
        'aria-describedby': describedBy,
        'aria-invalid': error ? true : undefined,
        'aria-required': required || undefined,
        required,
        invalid: Boolean(error),
      })}

      {error ? (
        <p id={errorId} className="flex items-start gap-1.5 text-xs font-medium text-danger">
          <AlertCircle className="mt-px size-3.5 shrink-0" aria-hidden="true" />
          <span>{error}</span>
        </p>
      ) : null}

      {success && !error ? (
        <p id={successId} className="flex items-start gap-1.5 text-xs font-medium text-success">
          <CheckCircle2 className="mt-px size-3.5 shrink-0" aria-hidden="true" />
          <span>{success}</span>
        </p>
      ) : null}
    </div>
  )
}

/** Groups related controls (radios, checkboxes) with a shared legend. */
export interface FieldsetProps {
  legend: ReactNode
  hint?: ReactNode
  error?: string | null
  name: string
  required?: boolean
  className?: string
  children: ReactNode
  idPrefix?: string
}

export function Fieldset({
  legend,
  hint,
  error,
  name,
  required = false,
  className,
  children,
  idPrefix = 'group',
}: FieldsetProps) {
  const id = `${idPrefix}-${name}`
  const hintId = hint ? `${id}-hint` : undefined
  const errorId = error ? `${id}-error` : undefined
  const describedBy = [hintId, errorId].filter(Boolean).join(' ') || undefined

  return (
    <fieldset
      className={cn('flex min-w-0 flex-col gap-2', className)}
      aria-describedby={describedBy}
      aria-invalid={error ? true : undefined}
      aria-required={required || undefined}
    >
      <legend className="flex items-baseline gap-1.5 text-sm font-medium text-ink">
        <span>{legend}</span>
        {required ? (
          <span className="text-danger" aria-hidden="true">
            *
          </span>
        ) : null}
      </legend>
      {hint ? (
        <p id={hintId} className="text-xs text-ink-muted">
          {hint}
        </p>
      ) : null}
      {children}
      {error ? (
        <p id={errorId} className="flex items-start gap-1.5 text-xs font-medium text-danger">
          <AlertCircle className="mt-px size-3.5 shrink-0" aria-hidden="true" />
          <span>{error}</span>
        </p>
      ) : null}
    </fieldset>
  )
}

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

The form is inline rather than below the fold, the field is the largest control on the page, and the proof sits directly beneath it. Those three decisions are what make a waitlist hero work.

  • Validate on submit and clear the error on input — nagging mid-word costs signups.
  • The success state states plainly that nothing was sent.

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.

  • Inline form
  • Invalid email
  • Joined state

Accessibility

Hidden label
The field has a real label, visually hidden rather than replaced by a placeholder.
Status role
The joined confirmation is announced politely.

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.