ComponentsInputs and forms

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.

States
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.

app.ascern.io/
Available
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.

Nguyễn Thị Minh Khai, 王小明 and Zoë O’Brien-Smith all work.

Where CVs and updates go.

With the country code.

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>
  );
}

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.

Notifications
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.

Job type
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.

Draft replies with Flo

You approve every send

Log calls automatically

From your phone bar

Sync contacts

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.

Agency

Shown to clients on CVs and emails.

Flo

Switches save at once; no Save needed.

Draft replies

You approve every send

Summarise meetings

From Echo, after each call

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>
  );
}

On this page