Detailed contact
Routing metadata alongside the message, so an enquiry reaches the right team without triage.
Live preview
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.
Related
All formsSimple contact
Three fields with a live character budget and both minimum and maximum length rules.
starterFeatured4 variantsSales enquiry
A qualification form that asks only what changes the conversation — and deliberately omits budget.
starter4 variantsSupport request
Severity, area, reproduction steps and attachments — with the response time shown before submitting.
intermediateFeatured4 variants