Skip to content

Detailed contact

Routing metadata alongside the message, so an enquiry reaches the right team without triage.

Contactstartercontactroutingenquiryurgency

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 { Field, Fieldset } from '@/components/ui/field'
import { Input, Textarea } from '@/components/ui/input'
import { Radio } from '@/components/ui/choice'
import { Select } from '@/components/ui/select'
import { useDemoForm } from '@/hooks/use-demo-form'
import { email, minLength, phone, required } from '@/lib/validation'
import { FormShell } from './_shell'

/**
 * Detailed contact form
 *
 * Routing metadata alongside the message, so the enquiry reaches the right
 * team without a triage step. Two-column pairs collapse to one column below
 * `sm` — a two-column form on a 390px screen is unusable.
 */
export default function DetailedContactForm() {
  const form = useDemoForm({
    schema: {
      firstName: { validators: [required('First name')] },
      lastName: { validators: [required('Last name')] },
      email: { validators: [required('Email'), email()] },
      phone: { validators: [phone()] },
      company: {},
      topic: { initial: 'product', validators: [required('Topic')] },
      urgency: { initial: 'normal' },
      message: { validators: [required('Message'), minLength(20, 'Message')] },
    },
  })

  return (
    <FormShell
      title="Contact the team"
      description="Tell us a little about the enquiry so it reaches the right people."
      formRef={form.formRef}
      onSubmit={form.handleSubmit}
      status={form.status}
      serverError={form.serverError}
      invalidCount={form.invalidCount}
      submitted={form.submitted}
      submitLabel="Send enquiry"
      submittingLabel="Sending"
      onReset={form.reset}
      successTitle="Enquiry received"
      width="lg"
    >
      <div className="grid gap-stack sm:grid-cols-2">
        <Field name="firstName" label="First name" required error={form.error('firstName')}>
          {(field) => <Input {...field} autoComplete="given-name" {...form.field('firstName')} />}
        </Field>
        <Field name="lastName" label="Last name" required error={form.error('lastName')}>
          {(field) => <Input {...field} autoComplete="family-name" {...form.field('lastName')} />}
        </Field>
      </div>

      <div className="grid gap-stack sm:grid-cols-2">
        <Field name="email" label="Email" required error={form.error('email')}>
          {(field) => (
            <Input {...field} type="email" autoComplete="email" {...form.field('email')} />
          )}
        </Field>
        <Field
          name="phone"
          label="Phone"
          showOptional
          error={form.error('phone')}
          hint="Include the country code."
        >
          {(field) => <Input {...field} type="tel" autoComplete="tel" {...form.field('phone')} />}
        </Field>
      </div>

      <Field name="company" label="Company" showOptional>
        {(field) => <Input {...field} autoComplete="organization" {...form.field('company')} />}
      </Field>

      <Field name="topic" label="What is this about?" required error={form.error('topic')}>
        {(field) => (
          <Select
            {...field}
            {...form.field('topic')}
            options={[
              { value: 'product', label: 'Product question' },
              { value: 'sales', label: 'Pricing and plans' },
              { value: 'support', label: 'Something is broken' },
              { value: 'partnership', label: 'Partnership' },
              { value: 'press', label: 'Press' },
            ]}
          />
        )}
      </Field>

      <Fieldset legend="How urgent is it?" name="urgency">
        <Radio
          id="urgency-normal"
          name="urgency"
          value="normal"
          label="Whenever you can"
          checked={form.values.urgency === 'normal'}
          onChange={() => form.setValue('urgency', 'normal')}
        />
        <Radio
          id="urgency-soon"
          name="urgency"
          value="soon"
          label="This week"
          checked={form.values.urgency === 'soon'}
          onChange={() => form.setValue('urgency', 'soon')}
        />
        <Radio
          id="urgency-blocking"
          name="urgency"
          value="blocking"
          label="It is blocking us"
          checked={form.values.urgency === 'blocking'}
          onChange={() => form.setValue('urgency', 'blocking')}
        />
      </Fieldset>

      <Field name="message" label="Message" required error={form.error('message')}>
        {(field) => <Textarea {...field} rows={6} {...form.field('message')} />}
      </Field>
    </FormShell>
  )
}

components/blocks/forms/detailed-contact.tsx

'use client'

import { Field, Fieldset } from '@/components/ui/field'
import { Input, Textarea } from '@/components/ui/input'
import { Radio } from '@/components/ui/choice'
import { Select } from '@/components/ui/select'
import { useDemoForm } from '@/hooks/use-demo-form'
import { email, minLength, phone, required } from '@/lib/validation'
import { FormShell } from './_shell'

/**
 * Detailed contact form
 *
 * Routing metadata alongside the message, so the enquiry reaches the right
 * team without a triage step. Two-column pairs collapse to one column below
 * `sm` — a two-column form on a 390px screen is unusable.
 */
export default function DetailedContactForm() {
  const form = useDemoForm({
    schema: {
      firstName: { validators: [required('First name')] },
      lastName: { validators: [required('Last name')] },
      email: { validators: [required('Email'), email()] },
      phone: { validators: [phone()] },
      company: {},
      topic: { initial: 'product', validators: [required('Topic')] },
      urgency: { initial: 'normal' },
      message: { validators: [required('Message'), minLength(20, 'Message')] },
    },
  })

  return (
    <FormShell
      title="Contact the team"
      description="Tell us a little about the enquiry so it reaches the right people."
      formRef={form.formRef}
      onSubmit={form.handleSubmit}
      status={form.status}
      serverError={form.serverError}
      invalidCount={form.invalidCount}
      submitted={form.submitted}
      submitLabel="Send enquiry"
      submittingLabel="Sending"
      onReset={form.reset}
      successTitle="Enquiry received"
      width="lg"
    >
      <div className="grid gap-stack sm:grid-cols-2">
        <Field name="firstName" label="First name" required error={form.error('firstName')}>
          {(field) => <Input {...field} autoComplete="given-name" {...form.field('firstName')} />}
        </Field>
        <Field name="lastName" label="Last name" required error={form.error('lastName')}>
          {(field) => <Input {...field} autoComplete="family-name" {...form.field('lastName')} />}
        </Field>
      </div>

      <div className="grid gap-stack sm:grid-cols-2">
        <Field name="email" label="Email" required error={form.error('email')}>
          {(field) => (
            <Input {...field} type="email" autoComplete="email" {...form.field('email')} />
          )}
        </Field>
        <Field
          name="phone"
          label="Phone"
          showOptional
          error={form.error('phone')}
          hint="Include the country code."
        >
          {(field) => <Input {...field} type="tel" autoComplete="tel" {...form.field('phone')} />}
        </Field>
      </div>

      <Field name="company" label="Company" showOptional>
        {(field) => <Input {...field} autoComplete="organization" {...form.field('company')} />}
      </Field>

      <Field name="topic" label="What is this about?" required error={form.error('topic')}>
        {(field) => (
          <Select
            {...field}
            {...form.field('topic')}
            options={[
              { value: 'product', label: 'Product question' },
              { value: 'sales', label: 'Pricing and plans' },
              { value: 'support', label: 'Something is broken' },
              { value: 'partnership', label: 'Partnership' },
              { value: 'press', label: 'Press' },
            ]}
          />
        )}
      </Field>

      <Fieldset legend="How urgent is it?" name="urgency">
        <Radio
          id="urgency-normal"
          name="urgency"
          value="normal"
          label="Whenever you can"
          checked={form.values.urgency === 'normal'}
          onChange={() => form.setValue('urgency', 'normal')}
        />
        <Radio
          id="urgency-soon"
          name="urgency"
          value="soon"
          label="This week"
          checked={form.values.urgency === 'soon'}
          onChange={() => form.setValue('urgency', 'soon')}
        />
        <Radio
          id="urgency-blocking"
          name="urgency"
          value="blocking"
          label="It is blocking us"
          checked={form.values.urgency === 'blocking'}
          onChange={() => form.setValue('urgency', 'blocking')}
        />
      </Fieldset>

      <Field name="message" label="Message" required error={form.error('message')}>
        {(field) => <Textarea {...field} rows={6} {...form.field('message')} />}
      </Field>
    </FormShell>
  )
}

components/ui/select.tsx

import type { SelectHTMLAttributes } from 'react'
import { ChevronDown } from 'lucide-react'
import { cn } from '@/lib/cn'
import { controlSurface } from './input'

/**
 * Select
 *
 * A styled native `<select>`, on purpose. The native control brings free
 * keyboard support, free mobile pickers and free assistive-tech semantics; the
 * only thing Foundry adds is the chevron and the shared control chrome.
 *
 * When the interaction genuinely needs filtering or multi-select, reach for the
 * Combobox instead — see `/components/combobox`.
 */
export interface SelectOption {
  value: string
  label: string
  disabled?: boolean
}

export interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
  options: SelectOption[]
  /** Rendered as a disabled, selected-by-default first option. */
  placeholder?: string
  selectSize?: 'sm' | 'md' | 'lg'
  /** Groups options under `<optgroup>` labels. */
  groups?: Array<{ label: string; options: SelectOption[] }>
}

const heights = {
  sm: 'h-control-sm text-xs',
  md: 'h-control text-sm',
  lg: 'h-control-lg text-base',
} as const

export function Select({
  options,
  groups,
  placeholder,
  selectSize = 'md',
  className,
  ...props
}: SelectProps) {
  return (
    <div className="relative flex items-center">
      <select
        className={cn(
          controlSurface,
          heights[selectSize],
          'cursor-pointer appearance-none py-0 pr-9 pl-3',
          className,
        )}
        defaultValue={props.value === undefined && placeholder ? '' : undefined}
        {...props}
      >
        {placeholder ? (
          <option value="" disabled>
            {placeholder}
          </option>
        ) : null}
        {groups
          ? groups.map((group) => (
              <optgroup key={group.label} label={group.label}>
                {group.options.map((option) => (
                  <option key={option.value} value={option.value} disabled={option.disabled}>
                    {option.label}
                  </option>
                ))}
              </optgroup>
            ))
          : options.map((option) => (
              <option key={option.value} value={option.value} disabled={option.disabled}>
                {option.label}
              </option>
            ))}
      </select>
      <ChevronDown
        className="pointer-events-none absolute right-3 size-4 text-ink-subtle"
        aria-hidden="true"
      />
    </div>
  )
}

components/ui/choice.tsx

import type { InputHTMLAttributes, ReactNode } from 'react'
import { Check, Minus } from 'lucide-react'
import { cn } from '@/lib/cn'

/**
 * Checkbox / Radio / Switch
 *
 * All three keep a real, focusable native input in the DOM and paint the
 * visible control with a sibling element. That keeps `aria-checked`, form
 * submission, `:checked`, `:disabled` and keyboard behaviour native, while
 * still allowing a token-driven appearance.
 *
 * The native input is positioned over the visual control rather than hidden
 * with `display:none`, so the tap target is the full 44px row on touch.
 */

const controlBox = cn(
  'pointer-events-none flex shrink-0 items-center justify-center border transition-colors duration-150 ease-standard',
  'border-line-strong bg-surface text-accent-ink',
  'peer-hover:border-accent',
  'peer-checked:border-accent peer-checked:bg-accent',
  'peer-disabled:opacity-50',
  'peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-[var(--color-accent)]',
  'peer-aria-[invalid=true]:border-danger',
  // The tick/dot is a *descendant* of this box, not a sibling of the input, so
  // the peer variant has to reach through with a child selector.
  '[&>*]:opacity-0 peer-checked:[&>*]:opacity-100',
)

const nativeInput = cn(
  'peer absolute inset-0 size-full cursor-pointer opacity-0 disabled:cursor-not-allowed',
)

export interface ChoiceProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'size'> {
  label: ReactNode
  description?: ReactNode
  /** `card` turns the whole row into a bordered, selectable surface. */
  appearance?: 'inline' | 'card'
}

export function Checkbox({
  label,
  description,
  appearance = 'inline',
  className,
  id,
  indeterminate,
  ...props
}: ChoiceProps & { indeterminate?: boolean }) {
  return (
    <label
      className={cn(
        'group relative flex min-h-9 cursor-pointer items-start gap-2.5 text-sm',
        appearance === 'card' &&
          'min-h-11 rounded-md border border-line bg-surface p-3 has-[:checked]:border-accent has-[:checked]:bg-accent-soft has-[:disabled]:cursor-not-allowed has-[:disabled]:opacity-60',
        appearance === 'inline' && 'py-1.5',
        className,
      )}
      htmlFor={id}
    >
      <span className="relative flex size-4.5 shrink-0 items-center justify-center">
        <input
          type="checkbox"
          id={id}
          className={nativeInput}
          aria-checked={indeterminate ? 'mixed' : undefined}
          {...props}
        />
        <span className={cn(controlBox, 'size-4.5 rounded-sm')} aria-hidden="true">
          {indeterminate ? (
            <Minus className="size-3" strokeWidth={3} />
          ) : (
            <Check className="size-3" strokeWidth={3} />
          )}
        </span>
      </span>
      <span className="min-w-0 flex-1 select-none">
        <span className="block leading-snug font-medium text-ink">{label}</span>
        {description ? (
          <span className="mt-0.5 block text-xs leading-normal text-ink-muted">{description}</span>
        ) : null}
      </span>
    </label>
  )
}

export function Radio({
  label,
  description,
  appearance = 'inline',
  className,
  id,
  ...props
}: ChoiceProps) {
  return (
    <label
      className={cn(
        'group relative flex min-h-9 cursor-pointer items-start gap-2.5 text-sm',
        appearance === 'card' &&
          'min-h-11 rounded-md border border-line bg-surface p-3 has-[:checked]:border-accent has-[:checked]:bg-accent-soft has-[:disabled]:cursor-not-allowed has-[:disabled]:opacity-60',
        appearance === 'inline' && 'py-1.5',
        className,
      )}
      htmlFor={id}
    >
      <span className="relative flex size-4.5 shrink-0 items-center justify-center">
        <input type="radio" id={id} className={nativeInput} {...props} />
        <span className={cn(controlBox, 'size-4.5 rounded-full')} aria-hidden="true">
          <span className="size-1.5 rounded-full bg-current" />
        </span>
      </span>
      <span className="min-w-0 flex-1 select-none">
        <span className="block leading-snug font-medium text-ink">{label}</span>
        {description ? (
          <span className="mt-0.5 block text-xs leading-normal text-ink-muted">{description}</span>
        ) : null}
      </span>
    </label>
  )
}

export interface SwitchProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'size' | 'type'> {
  label: ReactNode
  description?: ReactNode
  /** Places the switch on the trailing edge — the settings-row convention. */
  align?: 'leading' | 'trailing'
}

export function Switch({
  label,
  description,
  align = 'leading',
  className,
  id,
  ...props
}: SwitchProps) {
  const control = (
    <span className="relative inline-flex h-5 w-9 shrink-0 items-center">
      <input type="checkbox" role="switch" id={id} className={nativeInput} {...props} />
      <span
        className={cn(
          'pointer-events-none h-5 w-9 rounded-full border border-line-strong bg-surface-sunken transition-colors duration-150 ease-standard',
          'peer-checked:border-accent peer-checked:bg-accent',
          'peer-disabled:opacity-50',
          'peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-[var(--color-accent)]',
        )}
        aria-hidden="true"
      />
      <span
        className={cn(
          'pointer-events-none absolute left-0.5 size-4 rounded-full bg-surface shadow-sm transition-transform duration-150 ease-standard',
          'border border-line peer-checked:translate-x-4 peer-checked:border-transparent',
        )}
        aria-hidden="true"
      />
    </span>
  )

  return (
    <label
      className={cn(
        'flex min-h-9 cursor-pointer items-start gap-3 py-1.5 text-sm has-[:disabled]:cursor-not-allowed has-[:disabled]:opacity-60',
        align === 'trailing' && 'justify-between',
        className,
      )}
      htmlFor={id}
    >
      {align === 'leading' ? control : null}
      <span className="min-w-0 flex-1 select-none">
        <span className="block leading-snug font-medium text-ink">{label}</span>
        {description ? (
          <span className="mt-0.5 block text-xs leading-normal text-ink-muted">{description}</span>
        ) : null}
      </span>
      {align === 'trailing' ? control : null}
    </label>
  )
}

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

Usage

Adds only the fields that change where the message goes. Two-column pairs collapse to one column below the sm breakpoint — a two-column form on a 390px screen is unusable.

  • Group name and email into pairs; group topic and urgency separately, because they are routing, not identity.
  • Optional fields are marked “Optional” rather than starring the required majority.

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 pairs
  • Topic select
  • Urgency radio group
  • Success

Accessibility

Grouped controls
Urgency is a Fieldset with a legend, so the question is announced with the options.
Responsive pairs
Column pairs collapse rather than shrink.

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.

All forms