Use Field, Fieldset, SubmitButton and FormErrorSummary inside Form to get accessible labels, hints, errors, pending state and an error summary without hand-written ids.
Since 2.3, Once UI has form building blocks: Field wires labels, hints and errors to a control, Fieldset groups sections, SubmitButton handles pending state, and FormErrorSummary lists what went wrong.
Why hand-built forms break
The Form layout pattern stacks a label, an input and a helper line in a Column. It looks right, but every field needs ids kept in sync by hand: the label points at the input, and the input needs aria-describedby pointing at the hint and the error. When that bookkeeping drifts, nothing looks wrong. The error is on screen and a screen reader never announces it.@once-ui-system/core 2.3 adds four components that do the wiring for you: Field, Fieldset, SubmitButton and FormErrorSummary. They all work inside Form.
Field: label, hint and error in one place
Wrap one control in a Field. It gives the control its id, aria-describedby, aria-invalid and aria-required, merged with whatever the control already has.
<Field label="Email" hint="We only use it for receipts." error={errors.email} required>
<Input id="email" type="email" />
</Field>
If the control already has an id, Field uses it, so you don't repeat it. While error is set the control is marked invalid. Input and Textarea, and the fields built on them such as Select, PasswordInput and NumberInput, pick that up and draw their error styling, so you don't pass a separate error prop to the input. The hint stays visible next to the error, so the instruction is still there when someone goes back to fix the value.For a custom control, pass a function as the child. It receives the same attributes to spread where you need them. Inside your own component, useField() returns the same state.
Form: one decision for spacing
Form lays fields out on a grid. density decides both the gap and how the fields read:
"stacked" fuses the fields into one block. Shared borders collapse to a single hairline and only the outer corners stay round.
"tight" (the default) and "spacious" are ordinary gaps where every field keeps its own border.
columns takes a number or a per-breakpoint map, and span on a Field makes it take more than one column:
You never write corners="top" or corners="bottom" by hand. Form works out the corners from the fields that actually rendered, at every breakpoint, so hiding a field or changing the column count doesn't leave a square edge at the end of the group. size on the form sets the default size for every field inside it, and a field that sets its own size keeps it.
Fieldset: titled sections
A Fieldset is a real <fieldset> with a <legend>, so screen readers announce the section name when you enter it. It takes the same density and columns as Form, and disabled turns off every native control inside at once.
Inside an outer Form, span on the Fieldset sets how many columns the section takes, and the outer form's size carries in.
SubmitButton: no double submits
Render the form as a real <form> with as="form". If onSubmit returns a promise, the form is pending until it settles. While it's pending the form is aria-busy, a second submit from Enter or a second click is dropped, and SubmitButton shows a spinner.
If the submit doesn't go through onSubmit, for example a server action or a mutation hook, pass pending to the Form yourself. While pending, the button uses aria-disabled rather than disabled, so a keyboard user keeps focus on it instead of being dropped back to the top of the page.
FormErrorSummary: after a failed submit
Per-field errors only help someone who scrolls past each field. FormErrorSummary lists them all in one place. It takes a map from control id to message, the shape most validation libraries return, or a list of { id, message }:
It renders nothing while there are no errors, so it can stay in the form permanently. When errors appear or change, it takes focus, so keyboard and screen-reader users land on it instead of staying on the submit button with no idea why nothing happened. Each entry links to its field, and choosing it moves focus into the control. The default heading is "Check the highlighted fields". Change it with title, and turn the focus behaviour off with autoFocus={false}.
Write this / not this
Do
Avoid
`<Field label hint error>` around the input
A `Text` label and hint with ids kept in sync by hand
`Form density="stacked"`
`corners="top"` and `corners="bottom"` written down the stack
`SubmitButton` in `Form as="form"`
A `Button` with a `loading` flag you reset yourself
`FormErrorSummary errors={errors}`
Errors that only show next to fields off screen
Reference
Every prop is documented in the Field page of the Once UI docs, alongside the pages for Form, Fieldset, SubmitButton and FormErrorSummary.