Areas
Forms and form states
Whole forms described as data, an error summary that leads to every field and states for success, failure and saved drafts. Built so people finish what they start.
On this page
Pick the right form#
Requests, applications, sign-ups, surveys and everything after sending come as finished sections in Applications and sign-ups, Request forms, Address, payment and consent, Surveys and feedback and Form states. Single fields such as phone, time or signature live under Special input fields.
| Component | Variants | What it does |
|---|---|---|
| multi-step-form | rail, progress-top, review, accordion, live-summary | Longer requests in steps, with progress, review or a live summary. |
| request-quote | brief, line-items, by-type, call-or-form | Quote requests as a brief, line items or by type of job. |
| booking-request | event-venue, flex-dates, packages, date-options | Requests for a date, a table or a room that someone confirms. |
| callback-request | time-slots, now-or-later, inline-bar, topic-agent | A call back in a chosen time window or right now. |
| application-form | job, quick-apply, guided-letter, eligibility | Job and program applications with documents. |
| registration | attendees, sessions, team, tickets | Sign-ups for events, courses and clubs. |
| file-request | checklist, single-drop, portal, review-status | Collect documents from clients with a clear checklist. |
| consent-preferences | email-topics, unsubscribe, channel-matrix, privacy-requests | Consent and communication preferences people can change. |
| address-form | international, shipping-billing, address-book, lookup | Addresses for delivery and billing, set for every country. |
| payment-form | methods, split-summary, direct-debit, installments | Card, bank and wallet payments in one calm form. |
| survey | scale, matrix, choice, open, branching | Surveys with one question at a time or all on one page. |
| success-states | next-steps, receipt, submitted-summary, status-tracker | What people see after sending. Next steps, receipt, summary, status. |
| error-recovery | summary-top, inline-live, submit-failed, session-expired | Errors with a way out, from the summary to an expired session. |
| save-progress | autosave, resume-link, drafts, resume-banner | Autosave, a link to resume, saved drafts and a resume banner. |
Fields as data#
The form sections describe their fields as data, as a list of FieldSpec. A whole form fits in JSON that way, in the Studio as in your CMS. Checks, error texts and the summary come along.
import { MultiStepForm } from '@/components/ui/multi-step-form';
<MultiStepForm
variant="review"
title="Book a table for your group"
steps={[
{
id: 'event',
title: 'Your evening',
fields: [
{ name: 'date', label: 'Date', kind: 'date', required: true, half: true },
{ name: 'guests', label: 'Guests', kind: 'number', required: true, half: true },
{ name: 'occasion', label: 'Occasion', kind: 'chips', options: [{ value: 'birthday', label: 'Birthday' }, { value: 'team', label: 'Team dinner' }] },
],
},
{
id: 'contact',
title: 'Contact',
fields: [
{ name: 'name', label: 'Name', required: true, autoComplete: 'name' },
{ name: 'email', label: 'Email', kind: 'email', required: true, autoComplete: 'email' },
{ name: 'privacy', label: 'You may use my details for this booking', kind: 'consent', required: true },
],
},
]}
done={{ title: 'Request received', text: 'We confirm your table within one working day.' }}
onSubmit={(values) => send(values)}
/>| Prop | Type | Default | What it does |
|---|---|---|---|
| name | string | · | Key of the value in the result. Also the id the error summary jumps to. |
| label | string | · | The visible label, always shown above the field. |
| kind | 'text' | 'email' | 'tel' | 'number' | 'date' | 'textarea' | 'select' | 'choice' | 'chips' | 'consent' | 'text' | The type of control and its built-in check. |
| required | boolean | · | The field must be filled. Optional fields carry no mark. |
| options | { value, label, description?, icon? }[] | · | Choices for select, choice and chips. |
| hint | string | · | Help under the label, linked to the field for screen readers. |
| error | string | · | Your own message. Without it the kit says what is needed and why. |
| half | boolean | · | Half the width from tablet up, two half fields share a row. |
| autoComplete | string | · | The autocomplete token, so browsers fill names and addresses. |
Error summary#
For your own forms, form-flow holds the parts every form section shares. checkFields checks the values, ErrorSummary names every open field with a link that jumps there, useFocusFirstError moves the focus after each attempt. The texts take the responsibility and say what is needed and why.
import { useRef, useState } from 'react';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { ErrorSummary, checkFields, useFlowTexts, useFocusFirstError, type FieldSpec, type FlowValues } from '@/components/ui/form-flow';
const FIELDS: FieldSpec[] = [
{ name: 'name', label: 'Name', required: true },
{ name: 'email', label: 'Email', kind: 'email', required: true },
];
export function ContactForm() {
const t = useFlowTexts();
const [values, setValues] = useState<FlowValues>({});
const [errors, setErrors] = useState<Record<string, string>>({});
const [attempt, setAttempt] = useState(0);
const form = useRef<HTMLFormElement>(null);
const summary = useRef<HTMLDivElement>(null);
useFocusFirstError(form, attempt, summary);
return (
<form
ref={form}
noValidate
onSubmit={(e) => {
e.preventDefault();
const found = checkFields(FIELDS, values, t);
setErrors(found);
if (Object.keys(found).length) setAttempt((n) => n + 1);
else send(values);
}}
className="grid gap-4"
>
<ErrorSummary ref={summary} errors={Object.entries(errors).map(([field, message]) => ({ field, message }))} />
{FIELDS.map((f) => (
<Input
key={f.name}
id={f.name}
label={f.label}
type={f.kind === 'email' ? 'email' : 'text'}
required={f.required}
error={errors[f.name]}
value={String(values[f.name] ?? '')}
onChange={(e) => setValues({ ...values, [f.name]: e.target.value })}
/>
))}
<Button type="submit">Send request</Button>
</form>
);
}Accessibility#
- Every field has a visible label, and hints and errors are tied to it with
aria-describedby. - Errors are text at the field and in the summary, never only a red line.
- After sending, focus moves to the summary or the first error, and everything else people entered stays.
- Choices as chips, times and days use real radio and checkbox inputs inside a
fieldsetwith alegend. - Tap targets are at least 44 pixels high, and phone, email and address bring the right keyboard and
autocomplete. - Without JavaScript every form stays readable.
MultiStepFormwithactionthen posts to your address as a plain HTML form.
More on checking your pages under accessibility.
States after sending#
A form does not end at the button. The form states show what happens next, catch failures with a way out and keep half finished input safe.
<SuccessStates
variant="next-steps"
title="Request received"
reference="REQ-2048"
steps={[
{ title: 'We check the date', state: 'current', when: 'Today' },
{ title: 'You get a confirmation', state: 'later', when: 'Tomorrow' },
]}
primary={{ label: 'Back to the menu', href: '/menu/' }}
/>
<ErrorRecovery
variant="submit-failed"
fallbackEmail="hello@cafe-mira.example"
onSubmit={async (data) => (await fetch('/api/contact', { method: 'POST', body: JSON.stringify(data) })).ok}
/>
<SaveProgress
variant="autosave"
storageKey="application-2026"
fields={[{ name: 'motivation', label: 'Why this role', long: true }]}
/>