Skip to content
Folio

Forms

Field

A labelled form row: label, control, and the one message below it — and, in row layout, the settings row.

Usage

When to reach for it

Any labelled control. layout="row" is the settings row — label and description at the inline start, control at the inline end — which is a layout here rather than a second component, because the label wiring, the required marker and the message slot are the same three things either way.
TSX
import { Field } from '@misoto22/design'

Notes

A labelled form row: label, control, and the one message below it.

The visible message is only half of accessible validation — it must also reach the control. This wires aria-describedby, aria-required and aria-invalid onto the single control child so the requirement and the error are announced, not merely drawn (WCAG 1.3.1 / 3.3.1 / 4.1.2).

An earlier version derived the message id from htmlFor, which meant a caller who left htmlFor off got a hint that was rendered and never announced — the failure was invisible in the browser and total for a screen reader. The id is now generated when it is not supplied.

hint and error are one slot, not two stacked messages: when a field is wrong, the thing to read is what is wrong with it. description is a different slot again — it explains the SETTING, not the input, and it is what layout="row" puts under the label to make a settings row.

How the wiring reaches the control. cloneElement puts the four attributes on the single child, and each control forwards them to whatever element carries the role — which is the child itself for Input, Textarea, NativeSelect, Checkbox, Switch and any host element written by hand, and a trigger, a group or a thumb further down for Select, Combobox, DatePicker, Slider, RadioGroup and ToggleGroup. The composites used to drop them on the floor, which drew a hint under a control that never announced it; a wrapper that appears to wire things up and does not is worse than one that never claimed to.

The label's own id travels separately, through context, because a name is the one thing a prop cannot carry: a trigger whose text is its VALUE is named by the label AND by itself, so <Field label="Region"><Select/></Field> announces "Region, Australia" rather than either half.

Three things stay out of reach, and each of them is the control's own markup rather than a gap in this wiring. A <label for> binds only to a labellable element, so the words do not click through to a RadioGroup, a ToggleGroup or a Slider: the first two are a role="radiogroup" named by pointing back at the label instead, exactly as a <legend> is, and the third carries role="slider" on a thumb below a roleless root. required reaches a control as aria-required, which DatePicker's plain <button> trigger and a multiple-value ToggleGroup's role="toolbar" have nowhere to put; there the asterisk is the only marker. And aria-invalid reaches Slider's root rather than its thumb, so an errored slider is drawn wrong without being announced wrong.

Each composite still takes its own label prop — that is what names it standing outside a field, and Select, Combobox and DatePicker warn when it is blank. It is no longer used INSTEAD of this one's.

What no wiring can reach, the field says out loud in development rather than failing silently: FIELD_CONTROL_NOT_LABELLABLE when the child is a host element a label cannot bind to — the <div> wrapper that takes the id and leaves the control inside it with nothing — and FIELD_CONTROL_NOT_WIRED when there is no single element to wire at all.

Anatomy

Field anatomy
ElementDescription
Labellabel, rendered as a Radix <Label> carrying htmlFor and an id. The id is what a trigger names itself from, alongside its own value, and what a group points back at — neither of which htmlFor can do.
Required markThe --danger asterisk after the label when required. aria-hidden, but still inside the label’s text, so the accessible name ends “Email *”.
Control slotrequiredchildren — ONE element, which the field clones to add id, aria-describedby, aria-required and aria-invalid, and which each control forwards to whatever element carries its role: the trigger for Select, Combobox and DatePicker, the root for a group, the thumb for a Slider. This is the whole contract; everything else is layout.
Descriptiondescription, a second line under the LABEL explaining what the setting does — as distinct from hint, which sits under the control and belongs to the input. It has its own id and joins aria-describedby ahead of the message, so a row with both announces both.
MessageA single <p> below the control: error if there is one, hint otherwise, never both. It owns the id that aria-describedby points at, and it is --danger or --ink-3-aa accordingly.
Row layoutlayout="row": the label and description in a column at the inline start, the control at the inline end, the message underneath both. The two columns are TOP-aligned — items-start on the block axis, not items-center — so a two-line description does not drag the switch down to the middle of the paragraph, and a column of settings rows keeps every control on the same line as the words that name it.

Best practices

Do

  • Make children the control itself, not a layout wrapper around it: the wiring is a cloneElement on the single child, so a <div> in between takes the id and the aria-describedby and the label ends up naming a box.
  • Let error carry the invalid state. The field sets aria-invalid on the control, and Input, Textarea, NativeSelect, Select and Combobox all read either spelling through isInvalid, so passing invalid as well states the same fact twice from two places that can disagree.
  • Pass htmlFor, and the same id on the control, whenever something outside the row has to address it — a form library, a scroll-to-first-error, a test. The generated id is a useId value nothing else can predict.
  • Build a settings screen out of layout="row" with description, not out of three hand-rolled divs. The label still reaches the control through the same wiring, so the switch on the far side of the row is named by the words on the near side.

Don’t

  • required here is aria-required and an asterisk, and nothing else: it never reaches the control’s own required attribute, so the browser will not block the submit and the row stays unmarked until you pass error yourself.
  • The words above a RadioGroup or a ToggleGroup name it but do not click through. Both roots are <div role="radiogroup">, which htmlFor does not bind to, so the label is pointed AT by the group instead — a reader who clicks it the way they click "Email" gets nothing, exactly as with a <legend>.
  • Do not reach for description as a second hint. It explains the SETTING and sits under the label; hint explains the INPUT and sits under the control. A row that uses one for the other reads correctly and lands in the wrong place.
  • required is announced on every control here except DatePicker, whose trigger is a plain <button> — a role with nowhere to put aria-required. There the asterisk is the whole of the marking, and a screen reader meets an ordinary optional field.

Examples

states

The four shapes a row takes: required, wrong, a control that has to name itself, and a longer answer. hint and error are one slot rather than two stacked messages — when a field is wrong, the thing to read is what is wrong with it, so the hint steps aside instead of queueing under the error. The Region row says Region twice on purpose: Radix's select root renders no DOM node, so the label binds to nothing and the control's own label prop is the only name it has.

We never share it.

Name is required.

Markdown is fine.

one message slot

hint and error are the same line, and the error wins. Delete the @ and watch the format disappear at the one moment a reader needed it — which is why the error below repeats the shape of a good answer instead of only saying no. Passing error is also all the invalid state this row needs: the field puts aria-invalid on the input, and Input reads either spelling, so adding invalid as well states one fact from two places that can disagree.

The receipt goes here — use the address on the invoice.

naming a group

A Field around a RadioGroup draws the words and binds them to nothing: the group's root is a div, and htmlFor only reaches a labelable element, so clicking the label does nothing and the group would be announced with no name at all. The same words go on the group as aria-label, and that is what a screen reader actually reads. ToggleGroup needs the same treatment, and Select, Combobox, DatePicker and Slider each take a required label of their own for the same reason.

Weekend delivery is metro only.

a stable id

htmlFor, and the same id on the control. Leave it off and the field generates a useId value — a string nothing outside the row can predict — so a form library, a test, or the button below has no way to reach the input that failed. Pass it whenever something outside the row has to address the control; inside the row, the generated id is already doing its job.

The receipt goes here.

Enter a four-digit postcode.

Types

TSX
export type FieldLayout = 'stacked' | 'row'

Accessibility

  • Generates an id when none is given, so the label always points at something.
  • Wires aria-describedby, aria-required and aria-invalid onto the control, so validation is announced and not merely drawn.
  • hint and error are one slot: when a field is wrong, the thing to read is what is wrong with it.
  • description joins aria-describedby ahead of the message, so a settings row announces what the setting does and then what is wrong with it.
  • Every control forwards the wiring to the element that carries its role, so the hint under a Select or a Slider is announced and not merely drawn.
  • The row layout moves the label to the other side of the row and changes nothing about the association, so a settings row with a Switch or a Select in it is named by the words on the near side. Slider is the exception, and the layout cannot fix it: role="slider" is on the THUMB while the field’s label points at the roleless root, so there the label prop on the Slider is still the only name a reader hears.