Skip to content
Design System
Contact

Form

Validation on blur and submit, inline errors that say how to fix them, and a success state.

ComponentsLabelInputSelectCheckbox

Playground

A real form with no form library. Submit it empty to see the error summary and focus jump to the first problem; fix fields and errors clear as you type. A valid submit shows a loading state, then a success panel.

Preview

Project inquiry

Tell me what you're building. Fields marked * are required.

We reply within two working days.

A rough estimate is fine.

Timeline

Goals, users, current tools and anything that's already built.

0/500

No mailing list. Your details are only used to reply.

Props

Layout
Validate on
Size
example.tsxtsx
1import { useInquiryForm } from "./forms/use-inquiry-form"
2import { Field } from "./forms/form-field"
3
4const form = useInquiryForm("blur", successRef)
5
6<form noValidate onSubmit={form.submit} aria-labelledby="inquiry-title">
7 {form.submitted && form.errorList.length > 0 && <ErrorSummary form={form} />}
8 <fieldset className="grid gap-4 @md:grid-cols-2">
9 <Field
10 id="email"
11 label="Work email"
12 required
13 helper="We reply within two working days."
14 error={form.errors.email}
15 >
16 <Input
17 id="email"
18 ref={form.register("email")}
19 type="email"
20 autoComplete="email"
21 required
22 value={form.values.email}
23 onChange={(e) => form.setValue("email", e.target.value)}
24 onBlur={() => form.blur("email")}
25 aria-invalid={form.errors.email ? true : undefined}
26 aria-describedby={form.errors.email ? "email-error" : "email-helper"}
27 />
28 </Field>
29 {/* name, company, budget (Select), timeline (fieldset + RadioGroup), message, NDA, terms */}
30 </fieldset>
31 <Button type="submit" disabled={form.status === "submitting"}>Send inquiry</Button>
32</form>

Field states

The work email field in all seven states. Errors replace the helper text in the same spot, so the layout does not shift and the message sits where the eye already is.

States
  • We reply within two working days.

    Defaultplaceholder
  • We reply within two working days.

    Hover:hover
  • We reply within two working days.

    Focus:focus-visible
  • Checking the domain…

    Loadingaria-busy, async check
  • We reply within two working days.

    Disableddisabled
  • Use your work address rather than a personal inbox.

    Erroraria-invalid
  • Matched to Northwind

    Successvalidated

API

PropTypeDefaultDescription
useInquiryForm(validateOn, successRef)"blur" | "submit" | "change""blur"Returns values, errors, errorList, status and the handlers below. Once a field has an error it re-validates as you type in every mode.
setValue(field, value, commit?)function—Updates a value. commit marks a finished choice (select, radio, checkbox) so it validates like a blur.
blur(field)function—Validates a field on blur when it has been edited, already shows an error, or the form was submitted.
submit(event)function—Validates everything, shows the summary and focuses the first invalid field, or runs the async send.
register(field)(el) => void—Ref callback used to focus a field from the summary or after a failed submit.
Field.requiredbooleanfalseAdds the asterisk; unmarked fields show (optional). Pair it with required on the control.
Field.helperReactNode—Help text. Replaced by the error when there is one, keeping the describedby id valid.
Field.asideReactNode—Right side of the description row, e.g. the 0/500 character counter.

Accessibility

Keyboard

Move to the next field
Tab
Choose a timeline option
↑or↓
Toggle a checkbox or open the budget list
Space
Submit the form
Enter

Semantics and ARIA

  • Every control has a visible <label> tied by htmlFor; placeholders are examples, never labels.
  • Required fields set required or aria-required and show an asterisk that is aria-hidden, so it is not read as "star". The form uses noValidate so our messages replace the browser's.
  • Invalid controls set aria-invalid and point aria-describedby at the error, which says how to fix the value rather than only that it is wrong.
  • On submit, a role="alert" summary lists each problem as a link to its field, and focus moves to the first invalid field.
  • The timeline radios sit in a <fieldset> with a <legend>, so the question is announced with each option.
  • While sending, the fieldset is disabled and the button sets aria-busy; the success panel receives focus and is a status region.