Form
The parts every form is built from: field, label, messages, choice controls, larger inputs, and the settings layout that holds them.
Playground
Change the controls to try each prop of the text field. The code under the preview updates as you go and lists only the props you changed; copy it into your page.
import { Input } from '@/ui/input';<Input placeholder="Senior Data Engineer" />Field
Required
A label above, the control, then one helper line. In a form where most fields are optional, required puts a red asterisk on the label; set required on the input too, so screen readers hear it.
As the client writes it; shown on CVs you send.
import { Field, FieldDescription, FieldLabel } from '@/ui/field';
import { Input } from '@/ui/input';
export default function TextField() {
return (
<Field className="max-w-70">
<FieldLabel htmlFor="job-title" required>
Job title
</FieldLabel>
<Input id="job-title" required defaultValue="Senior Data Engineer" aria-describedby="job-title-help" />
<FieldDescription id="job-title-help">As the client writes it; shown on CVs you send.</FieldDescription>
</Field>
);
}Optional
In a form where most fields are required, such as a Placement, mark the few optional ones with optional instead. Never both marks in one form.
import { Field, FieldLabel } from '@/ui/field';
import { Input } from '@/ui/input';
// A Placement: most fields are required, so only the optional one is marked (Decided 3).
export default function OptionalField() {
return (
<div className="flex w-full max-w-70 flex-col gap-200">
<Field>
<FieldLabel htmlFor="placement-fee">Fee</FieldLabel>
<Input id="placement-fee" required prefix="£" defaultValue="24,000" />
</Field>
<Field>
<FieldLabel htmlFor="placement-po" optional>
PO number
</FieldLabel>
<Input id="placement-po" placeholder="HB-2026-0412" />
</Field>
</div>
);
}Error
The edge turns danger red and the error replaces the helper line. Check on blur or submit, never while the person types. Clear the domain and tab away to try it.
import { useState } from 'react';
import { Field, FieldDescription, FieldError, FieldLabel } from '@/ui/field';
import { Input } from '@/ui/input';
export default function ErrorField() {
const [email, setEmail] = useState('sara@halcyon');
const [error, setError] = useState('Add the domain, like sara@halcyon.com');
return (
<Field invalid={!!error} className="max-w-70">
<FieldLabel htmlFor="client-email">Client email</FieldLabel>
<Input
id="client-email"
type="email"
autoComplete="email"
value={email}
aria-invalid={!!error}
aria-describedby="client-email-message"
onChange={(event) => setEmail(event.target.value)}
// Checked on blur, never while typing.
onBlur={() => setError(/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email) ? '' : 'Add the domain, like sara@halcyon.com')}
/>
{error ? (
<FieldError id="client-email-message">{error}</FieldError>
) : (
<FieldDescription id="client-email-message">Where CVs and updates go.</FieldDescription>
)}
</Field>
);
}Valid
A success line only where a server check ran. A fixed part of the value goes in prefix, on a grey add-on.
import { Field, FieldLabel, FieldValid } from '@/ui/field';
import { Input } from '@/ui/input';
export default function ValidField() {
return (
<Field className="max-w-70">
<FieldLabel htmlFor="workspace-url">Workspace URL</FieldLabel>
<Input id="workspace-url" prefix="app.ascern.io/" defaultValue="northbridge" aria-describedby="workspace-url-status" />
<FieldValid id="workspace-url-status">Available</FieldValid>
</Field>
);
}Validation and a failed save
Errors show when a field loses focus or on submit, never while typing. A submit with errors puts a summary above the form, with a link to each field, and moves focus to it. A save that fails keeps everything typed and says what to do next, with Try again; the button shows its spinner and a second Enter does nothing while it saves. Press Add candidate to try both.
import { useRef, useState } from 'react';
import { Button } from '@/ui/button';
import { ErrorSummary } from '@/ui/error-summary';
import { Field, FieldDescription, FieldError, FieldLabel, Form, FormFooter } from '@/ui/field';
import { Input } from '@/ui/input';
import { SectionMessage } from '@/ui/section-message';
import { toast } from '@/ui/toast';
type Values = { name: string; email: string; phone: string };
const labels: Record<keyof Values, string> = { name: 'Full name', email: 'Email', phone: 'Phone' };
// What is wrong and how to fix it, never "Invalid".
function check(values: Values) {
const errors: Partial<Record<keyof Values, string>> = {};
if (!values.name.trim()) errors.name = 'Add their name as it appears on the CV.';
if (values.email && !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(values.email)) errors.email = 'Add the domain, like maya@northwind.io';
if (values.phone && values.phone.replace(/\D/g, '').length < 7) errors.phone = 'Add the full number with its country code, like +44 20 7946 0958';
return errors;
}
export default function Validation() {
const [values, setValues] = useState<Values>({ name: '', email: 'maya@northwind', phone: '' });
// A field shows its error after it loses focus once, or after a submit; never while the person is typing.
const [touched, setTouched] = useState<Partial<Record<keyof Values, boolean>>>({});
const [submitted, setSubmitted] = useState(false);
const [saving, setSaving] = useState(false);
const [saveFailed, setSaveFailed] = useState(false);
const attempts = useRef(0);
const errors = check(values);
const shown = (key: keyof Values) => (submitted || touched[key] ? errors[key] : undefined);
const wrong = Object.keys(errors) as (keyof Values)[];
function field(key: keyof Values, props: { required?: boolean; type?: string; autoComplete?: string; help: string }) {
const error = shown(key);
return (
<Field invalid={!!error}>
<FieldLabel htmlFor={`candidate-${key}`} required={props.required}>
{labels[key]}
</FieldLabel>
<Input
id={`candidate-${key}`}
type={props.type}
required={props.required}
autoComplete={props.autoComplete}
value={values[key]}
aria-invalid={!!error}
aria-describedby={`candidate-${key}-message`}
onChange={(event) => setValues({ ...values, [key]: event.target.value })}
onBlur={() => setTouched({ ...touched, [key]: true })}
/>
{error ? <FieldError id={`candidate-${key}-message`}>{error}</FieldError> : <FieldDescription id={`candidate-${key}-message`}>{props.help}</FieldDescription>}
</Field>
);
}
async function save() {
if (saving) return; // Enter pressed twice saves once.
setSubmitted(true);
setSaveFailed(false);
if (wrong.length) {
return;
}
setSaving(true);
await new Promise((resolve) => setTimeout(resolve, 900));
setSaving(false);
// The first save fails here, to show the failure; Try again succeeds.
if (attempts.current++ === 0) return setSaveFailed(true);
toast.add({ type: 'success', title: `${values.name} added` });
}
return (
<Form
className="max-w-90"
unsavedChanges={Boolean(values.name || values.phone) || saveFailed}
onSubmit={(event) => {
event.preventDefault();
void save();
}}
>
{submitted && <ErrorSummary errors={wrong.map((key) => ({ id: `candidate-${key}`, message: errors[key]! }))} />}
{field('name', { required: true, autoComplete: 'name', help: 'Nguyễn Thị Minh Khai, 王小明 and Zoë O’Brien-Smith all work.' })}
{field('email', { type: 'email', autoComplete: 'email', help: 'Where CVs and updates go.' })}
{field('phone', { type: 'tel', autoComplete: 'tel', help: 'With the country code.' })}
{saveFailed && (
<SectionMessage role="alert" appearance="error" title="Couldn’t add this candidate" actions={<Button size="sm" type="submit">Try again</Button>}>
Check your connection and try again. Nothing you typed is lost.
</SectionMessage>
)}
<FormFooter align="start">
<Button type="submit" variant="primary" loading={saving} loadingLabel="Adding">
Add candidate
</Button>
</FormFooter>
</Form>
);
}Error summary
A form of more than about four fields puts an error summary at the top after a submit with errors, in GOV.UK's pattern: "There is a problem", then each error as a link that focuses its field. Focus moves to the summary, and each field keeps its own error. Press Add job with the fields empty to try it.
import { useState } from 'react';
import { Button } from '@/ui/button';
import { ErrorSummary } from '@/ui/error-summary';
import { Field, FieldError, FieldLabel, Form, FormFooter } from '@/ui/field';
import { Input } from '@/ui/input';
const fields = [
{ id: 'job-title-long', label: 'Job title', message: 'Add the job title as the client writes it' },
{ id: 'job-company-long', label: 'Company', message: 'Add the company this job is for' },
{ id: 'job-salary-long', label: 'Salary', message: 'Add the salary as a whole amount, like 85,000' },
{ id: 'job-location-long', label: 'Location', message: 'Add a city, or Remote' },
{ id: 'job-start-long', label: 'Start date', message: 'Add a start date, like 3 Nov 2026' },
];
// A long form: a submit with errors puts the summary at the top and moves focus to it. Each link focuses its field.
export default function ErrorSummaryForm() {
const [values, setValues] = useState<Record<string, string>>({});
const [submitted, setSubmitted] = useState(false);
const errors = fields.filter((f) => !values[f.id]?.trim()).map(({ id, message }) => ({ id, message }));
const errorFor = (id: string) => (submitted ? errors.find((e) => e.id === id)?.message : undefined);
return (
<Form
className="max-w-90"
unsavedChanges={Object.values(values).some(Boolean)}
onSubmit={(event) => {
event.preventDefault();
setSubmitted(true);
}}
>
{submitted && <ErrorSummary errors={errors} />}
{fields.map((f) => (
<Field key={f.id} invalid={!!errorFor(f.id)}>
<FieldLabel htmlFor={f.id} required>
{f.label}
</FieldLabel>
<Input
id={f.id}
required
aria-invalid={!!errorFor(f.id)}
aria-describedby={errorFor(f.id) ? `${f.id}-error` : undefined}
value={values[f.id] ?? ''}
onChange={(event) => setValues({ ...values, [f.id]: event.target.value })}
/>
<FieldError id={`${f.id}-error`}>{errorFor(f.id)}</FieldError>
</Field>
))}
<FormFooter align="start">
<Button type="submit" variant="primary">
Add job
</Button>
</FormFooter>
</Form>
);
}Text field
States
Empty shows the placeholder, an example of the answer. Read only sits on the sunken surface; disabled fades to 50%.
import { Field, FieldLabel } from '@/ui/field';
import { Input } from '@/ui/input';
export default function InputStates() {
return (
<div className="grid w-full max-w-140 gap-200 sm:grid-cols-2">
<Field>
<FieldLabel htmlFor="state-empty">Empty</FieldLabel>
<Input id="state-empty" placeholder="Senior Data Engineer" />
</Field>
<Field>
<FieldLabel htmlFor="state-filled">Filled</FieldLabel>
<Input id="state-filled" defaultValue="Northbridge Talent" />
</Field>
<Field>
<FieldLabel htmlFor="state-read-only">Read only</FieldLabel>
<Input id="state-read-only" readOnly defaultValue="joseph@northbridge.com" />
</Field>
<Field disabled>
<FieldLabel htmlFor="state-disabled">Disabled</FieldLabel>
<Input id="state-disabled" disabled defaultValue="Priya at Northbridge" />
</Field>
</div>
);
}Long, short and non-Latin values
A value longer than the field scrolls inside it, and a long option ends in an ellipsis in the select. Names in any script keep their accents and characters. At the limit the counter turns red; paste more and the paste is refused with a message that gives both counts.
139 / 139
import { Field, FieldLabel } from '@/ui/field';
import { Input } from '@/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/ui/select';
import { Textarea } from '@/ui/textarea';
const owners = {
minh: 'Nguyễn Thị Minh Khai',
wang: '王小明',
zoe: 'Zoë O’Brien-Smith',
long: 'Maximiliane Alexandra von Hohenzollern-Sigmaringen',
};
// A value longer than the field scrolls inside it; the select shows the start and an ellipsis.
// A pasted text past the limit stops at it, and the counter turns red.
export default function ContentExtremes() {
return (
<div className="flex w-full max-w-70 flex-col gap-200">
<Field>
<FieldLabel htmlFor="long-company">Company</FieldLabel>
<Input id="long-company" defaultValue="Halcyon Bank International Holdings and Trust Company plc" />
</Field>
<Field>
<FieldLabel htmlFor="short-initial">Middle initial</FieldLabel>
<Input id="short-initial" defaultValue="J" className="max-w-20" />
</Field>
<Field>
<FieldLabel id="owner-label">Owner</FieldLabel>
<Select items={owners} defaultValue="long">
<SelectTrigger aria-labelledby="owner-label">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(owners).map(([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel htmlFor="flo-rule">Flo rule</FieldLabel>
<Textarea
id="flo-rule"
maxLength={139}
limitLabel="Flo rules"
showCount
defaultValue="Archive newsletters from recruiting platforms unless they mention a candidate we are placing, a client we work with, or a job that is open."
/>
</Field>
</div>
);
}Search
elemBefore takes the 16px search icon and elemAfter the shortcut key.
import { MagnifyingGlassIcon } from '@phosphor-icons/react';
import { Input } from '@/ui/input';
import { Kbd } from '@/ui/kbd';
export default function Search() {
return (
<Input
type="search"
aria-label="Search candidates"
placeholder="Search candidates"
aria-keyshortcuts="/"
elemBefore={<MagnifyingGlassIcon aria-hidden />}
elemAfter={<Kbd>/</Kbd>}
className="max-w-75"
/>
);
}Select
A short fixed list with no search: job type, contract length. The picked option sits on the selected fill with a check.
import { Field, FieldLabel } from '@/ui/field';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/ui/select';
const types = { permanent: 'Permanent', contract: 'Contract', retained: 'Retained search' };
export default function SelectField() {
return (
<Field className="max-w-70">
<FieldLabel id="job-type-label">Job type</FieldLabel>
<Select items={types} defaultValue="permanent">
<SelectTrigger aria-labelledby="job-type-label">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(types).map(([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
);
}Text area
Prose that runs past one line. A maxLength is always counted: the counter shows from 80% of the limit (always, with showCount) and turns red at it. A paste that would pass the limit is refused with both counts, never cut short.
68 / 500
Shown under the CV summary.
import { Field, FieldDescription, FieldLabel } from '@/ui/field';
import { Textarea } from '@/ui/textarea';
export default function TextareaField() {
return (
<Field className="max-w-95">
<FieldLabel htmlFor="client-note">Note to client</FieldLabel>
<Textarea
id="client-note"
maxLength={500}
limitLabel="Notes to clients"
showCount
aria-describedby="client-note-help"
defaultValue="Maya leads the data platform at Northwind and has run a team of six."
/>
<FieldDescription id="client-note-help">Shown under the CV summary.</FieldDescription>
</Field>
);
}Choice controls
Checkbox
Ticks one setting, or picks several from a set. indeterminate draws a dash when only some of the set is ticked.
import { useState } from 'react';
import { Checkbox } from '@/ui/checkbox';
import { FieldLegend, FieldSet } from '@/ui/field';
const kinds = ['Email updates', 'Calendar invites', 'Weekly digest'];
export default function Checkboxes() {
const [picked, setPicked] = useState(['Calendar invites']);
const all = picked.length === kinds.length;
return (
<FieldSet>
<FieldLegend>Notifications</FieldLegend>
<div className="flex flex-col gap-2.5">
<Checkbox
label="Select all"
checked={all}
indeterminate={picked.length > 0 && !all}
onCheckedChange={() => setPicked(all ? [] : kinds)}
/>
{kinds.map((kind) => (
<Checkbox
key={kind}
label={kind}
checked={picked.includes(kind)}
onCheckedChange={(on) => setPicked((prev) => (on ? [...prev, kind] : prev.filter((k) => k !== kind)))}
/>
))}
<Checkbox label="SMS alerts" disabled />
</div>
</FieldSet>
);
}Radio group
One choice from two to five short options, all visible.
import { FieldLegend, FieldSet } from '@/ui/field';
import { Radio, RadioGroup } from '@/ui/radio-group';
export default function Radios() {
return (
<FieldSet>
<FieldLegend id="job-kind">Job type</FieldLegend>
<RadioGroup aria-labelledby="job-kind" defaultValue="permanent">
<Radio value="permanent" label="Permanent" />
<Radio value="contract" label="Contract" />
<Radio value="retained" label="Retained search" />
<Radio value="interim" label="Interim" disabled />
</RadioGroup>
</FieldSet>
);
}Switch
A setting that applies at once, with no Save. loading holds a spinner in the thumb while the change saves.
You approve every send
From your phone bar
Connect a mailbox first
import { useState } from 'react';
import { Field, FieldContent, FieldDescription, FieldTitle } from '@/ui/field';
import { Switch } from '@/ui/switch';
export default function Switches() {
const [draft, setDraft] = useState(true);
const [log, setLog] = useState(false);
const [saving, setSaving] = useState(false);
return (
<div className="flex w-full max-w-75 flex-col">
<Field orientation="horizontal" className="justify-between py-2.5">
<FieldContent>
<FieldTitle id="draft-replies">Draft replies with Flo</FieldTitle>
<FieldDescription>You approve every send</FieldDescription>
</FieldContent>
<Switch aria-labelledby="draft-replies" checked={draft} onCheckedChange={setDraft} />
</Field>
<Field orientation="horizontal" className="justify-between py-2.5">
<FieldContent>
<FieldTitle id="log-calls">Log calls automatically</FieldTitle>
<FieldDescription>From your phone bar</FieldDescription>
</FieldContent>
<Switch
aria-labelledby="log-calls"
checked={log}
loading={saving}
onCheckedChange={(on) => {
// A switch saves at once; the spinner shows while the save is out.
setLog(on);
setSaving(true);
setTimeout(() => setSaving(false), 1200);
}}
/>
</Field>
<Field orientation="horizontal" disabled className="justify-between py-2.5">
<FieldContent>
<FieldTitle id="sync-contacts">Sync contacts</FieldTitle>
<FieldDescription>Connect a mailbox first</FieldDescription>
</FieldContent>
<Switch aria-labelledby="sync-contacts" disabled />
</Field>
</div>
);
}Segmented control
Switches a view between two to four options. Never a form value.
import { SegmentedControl, SegmentedControlItem } from '@/ui/segmented-control';
export default function Segmented() {
return (
<SegmentedControl aria-label="View" defaultValue="list">
<SegmentedControlItem value="list">List</SegmentedControlItem>
<SegmentedControlItem value="board">Board</SegmentedControlItem>
<SegmentedControlItem value="calendar">Calendar</SegmentedControlItem>
</SegmentedControl>
);
}Dropzone
Drop a file or click to choose one. The edge turns 2px brand green while a file is over it. A file of the wrong type or over the size limit gets an error that names the file and says what to do.
import { useState } from 'react';
import { Dropzone } from '@/ui/dropzone';
import { Field, FieldDescription, FieldError, FieldLabel } from '@/ui/field';
export default function DropzoneField() {
const [file, setFile] = useState<string>();
const [error, setError] = useState('');
return (
<Field invalid={!!error} className="max-w-95">
<FieldLabel htmlFor="cv-file">CV</FieldLabel>
<Dropzone
id="cv-file"
label="Drop a CV or choose a file"
description="PDF or Word, up to 10 MB"
accept=".pdf,.doc,.docx"
onFiles={([cv]) => {
if (!/\.(pdf|docx?)$/i.test(cv.name)) return setError(`${cv.name} isn't a PDF or Word file. Save the CV as a PDF and drop it again.`);
if (cv.size > 10 * 1024 * 1024) return setError(`${cv.name} is over 10 MB. Save a smaller copy and try again.`);
setError('');
setFile(cv.name);
}}
/>
{error ? <FieldError>{error}</FieldError> : file && <FieldDescription>{file} is ready to upload.</FieldDescription>}
</Field>
);
}Settings form
Each section puts its title and description in a 260px column with the fields beside it. Text fields save with the footer; switches save on change. Save stays off until something changes.
import { useState } from 'react';
import { Button } from '@/ui/button';
import { Field, FieldContent, FieldDescription, FieldLabel, FieldTitle, FormFooter, SettingsForm, SettingsSection } from '@/ui/field';
import { Input } from '@/ui/input';
import { Switch } from '@/ui/switch';
const saved = { agency: 'Northbridge Talent', sender: 'Priya at Northbridge' };
export default function Settings() {
const [values, setValues] = useState(saved);
const changed = values.agency !== saved.agency || values.sender !== saved.sender;
return (
<SettingsForm unsavedChanges={changed} onSubmit={(event) => event.preventDefault()}>
<SettingsSection title="Agency" description="Shown to clients on CVs and emails.">
<Field className="max-w-80">
<FieldLabel htmlFor="agency-name" required>
Agency name
</FieldLabel>
<Input id="agency-name" required value={values.agency} onChange={(e) => setValues({ ...values, agency: e.target.value })} />
</Field>
<Field className="max-w-80">
<FieldLabel htmlFor="sender-name">Sender name</FieldLabel>
<Input id="sender-name" value={values.sender} onChange={(e) => setValues({ ...values, sender: e.target.value })} />
</Field>
</SettingsSection>
<SettingsSection title="Flo" description="Switches save at once; no Save needed.">
<Field orientation="horizontal" className="justify-between">
<FieldContent>
<FieldTitle id="settings-draft">Draft replies</FieldTitle>
<FieldDescription>You approve every send</FieldDescription>
</FieldContent>
<Switch aria-labelledby="settings-draft" defaultChecked />
</Field>
<Field orientation="horizontal" className="justify-between">
<FieldContent>
<FieldTitle id="settings-summaries">Summarise meetings</FieldTitle>
<FieldDescription>From Echo, after each call</FieldDescription>
</FieldContent>
<Switch aria-labelledby="settings-summaries" />
</Field>
</SettingsSection>
<FormFooter>
<Button type="button" disabled={!changed} onClick={() => setValues(saved)}>
Cancel
</Button>
<Button type="submit" variant="primary" disabled={!changed}>
Save changes
</Button>
</FormFooter>
</SettingsForm>
);
}Field types
Every value type a record can hold, as it reads in a 36px table cell, in the details rail and in a form. A type looks the same everywhere (C463). Each opens one of six shared editors, drawn below.
Import wizard
Adds records from a spreadsheet in one dialog: choose the file, pick the header row, match columns, preview each row, then import with Undo for 14 days.