Skip to content
UI Kit
Accent color

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.

ComponentVariantsWhat it does
multi-step-formrail, progress-top, review, accordion, live-summaryLonger requests in steps, with progress, review or a live summary.
request-quotebrief, line-items, by-type, call-or-formQuote requests as a brief, line items or by type of job.
booking-requestevent-venue, flex-dates, packages, date-optionsRequests for a date, a table or a room that someone confirms.
callback-requesttime-slots, now-or-later, inline-bar, topic-agentA call back in a chosen time window or right now.
application-formjob, quick-apply, guided-letter, eligibilityJob and program applications with documents.
registrationattendees, sessions, team, ticketsSign-ups for events, courses and clubs.
file-requestchecklist, single-drop, portal, review-statusCollect documents from clients with a clear checklist.
consent-preferencesemail-topics, unsubscribe, channel-matrix, privacy-requestsConsent and communication preferences people can change.
address-forminternational, shipping-billing, address-book, lookupAddresses for delivery and billing, set for every country.
payment-formmethods, split-summary, direct-debit, installmentsCard, bank and wallet payments in one calm form.
surveyscale, matrix, choice, open, branchingSurveys with one question at a time or all on one page.
success-statesnext-steps, receipt, submitted-summary, status-trackerWhat people see after sending. Next steps, receipt, summary, status.
error-recoverysummary-top, inline-live, submit-failed, session-expiredErrors with a way out, from the summary to an expired session.
save-progressautosave, resume-link, drafts, resume-bannerAutosave, 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.

MultiStepForm, variant reviewView in Components
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)}
/>
PropTypeDefaultWhat it does
namestring·Key of the value in the result. Also the id the error summary jumps to.
labelstring·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.
requiredboolean·The field must be filled. Optional fields carry no mark.
options{ value, label, description?, icon? }[]·Choices for select, choice and chips.
hintstring·Help under the label, linked to the field for screen readers.
errorstring·Your own message. Without it the kit says what is needed and why.
halfboolean·Half the width from tablet up, two half fields share a row.
autoCompletestring·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.

src/components/ContactForm.tsx
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 fieldset with a legend.
  • 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. MultiStepForm with action then 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.

ErrorRecovery, variant summary-topView in Components
<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 }]}
/>

Often searched

Jump to

Copied to the clipboard