Field
The wrapper that turns any control into a labelled form field. Field generates the id, htmlFor and aria-describedby wiring for you and cascades error and disabled state to the control inside.
Installation#
Usage#
Drop a control between FieldLabel and FieldHint. You never touch id, htmlFor, or aria-describedby - Field generates them with useId and hands them to the control through context. Clicking the label focuses the control, and screen readers announce the hint.
import { Field, FieldLabel, FieldHint } from "@/components/ui/field"
import { InputRoot, InputField } from "@/components/ui/input"
export function Example() {
return (
<Field>
<FieldLabel>Email</FieldLabel>
<InputRoot>
<InputField type="email" />
</InputRoot>
<FieldHint>We'll only use it for account notifications.</FieldHint>
</Field>
)
}Required & error#
Set required to append a destructive asterisk to the label. Set hasError on the Field - it cascades to the control’s border, sets aria-invalid, and turns the hint into the destructive error message. You set it in one place, not on every part.
Any control#
Field is control-agnostic. Because Input, Select, and PasswordInput all forward native props, the same wrapper labels any of them - the wiring lands on the right element automatically.
Two fields per row#
FieldRow places sibling fields side by side and stacks them on narrow viewports - the answer to Name · Surname pairs. It is pure layout, so it composes with any number of fields.
A whole form#
Wrap the fields in a Form, which owns the gap so no form has to invent its own, reach for FieldRow where two values belong together, and let each Field own its own wiring. This is a full registration form, name pair, email, country, and a password pair, with nothing wired by hand.
In a Dialog#
Field composes straight into a Dialog. The wiring flows through Radix's Portal because React context follows the component tree, not the DOM - so htmlFor, aria-describedby, and the hasError cascade all still work. The modal's own name comes from DialogTitle / DialogDescription (Radix), independent of the per-field labels. Try submitting with an invalid email to see the error cascade.
Disabled#
disabled on the Field dims the label and disables the control inside.
Field vs FieldGroup#
A Field wraps one control, because a label needs exactly one control to point at. When several controls answer a single question, a radio set or a cluster of checkboxes, there is nothing for the label to name, so the right wrapper is FieldGroup: a real fieldset with a real legend. It ships alongside Field and takes the same hasError, disabled, and required props, and the same FieldHint describes the whole group.
// One control: a Field.
<Field required>
<FieldLabel>Email</FieldLabel>
<InputRoot><InputField type="email" /></InputRoot>
</Field>
// Several controls, one question: a FieldGroup.
<FieldGroup required>
<FieldGroupLabel>Email preferences</FieldGroupLabel>
<RadioGroup>…</RadioGroup>
<FieldHint>Pick at least one.</FieldHint>
</FieldGroup>