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.
Help behind an icon#
When the help is a nice-to-know rather than an instruction, and a form stacks enough fields that a line under each one doubles its height, pass it to info on the label instead of a FieldHint. It shows in a tooltip on an info icon, and screen readers still hear it as the control's description. Keep FieldHint for anything the person needs to read before typing, and for errors.
Validation#
Put the constraint on the control: required, type="email", pattern, minLength. Inside a Form, a failed submit shows the message in the field, focuses the first invalid control and shakes it. No browser bubble. FieldError with match gives each failure its own copy, and takes the hint's place while it shows.
Skip FieldError and the field shows the browser's own message. Leave out match and one FieldError shows whatever failed.
Custom validation#
validate runs once the native constraints pass. It gets the value and every value in the form, and returns a message, a list of them, or null.
Async validation#
Return a promise to check against your API. Pair it with validationDebounceTime so a request waits for a pause in typing. A stale answer never overwrites a newer one, and a submit waits for pending checks. data-validating is on the Field while one runs.
Field states#
The Field root carries its state as data attributes, so you can style anything inside it: data-invalid, data-valid, data-touched, data-dirty, data-filled, data-focused and data-validating.
<Field className="group/field">
<FieldLabel className="group-data-focused/field:text-foreground">Email</FieldLabel>
…
</Field>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.
Shape#
Fields are rounded rectangles, not pills: sm 12, md 14 and lg 16, the same corner as an icon-only Button of that size. Any Button inside a <form> takes that corner too, so a submit button always matches the field beside it. The same Button outside a form stays a pill.
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>API reference#
Field
One labelled control. Renders a div and forwards its props. Generates the ids, wires aria-describedby and aria-invalid, and validates the control inside.
| Prop | Type | Default | Description |
|---|---|---|---|
| hasError | boolean | - | Forces the error state and wins over anything computed. Leave it unset to let native constraints, validate and server errors decide. |
| required | boolean | false | Appends a destructive asterisk to the label. Put required on the control too for the check itself. |
| disabled | boolean | false | Dims the label, disables the control and skips validation. |
| name | string | - | The key for this field's value and server errors. Defaults to the control's name. |
| validate | (value: unknown, formValues: FormValues) => string | string[] | null | Promise<…> | - | Runs after native constraints pass. Return a message, several, or null when valid. |
| validationMode | "onSubmit" | "onBlur" | "onChange" | - | Overrides the Form's mode for this field. |
| validationDebounceTime | number | - | Overrides the Form's debounce for this field, in ms. |
| Data attribute | Description |
|---|---|
| data-invalid | Present while the field is invalid. data-error mirrors it. |
| data-valid | Present once the field has been validated and passed. |
| data-validating | Present while an async validate is pending. |
| data-touched | Present once focus has left the field. |
| data-dirty | Present when the value differs from the initial one. |
| data-filled | Present when the control has a value. |
| data-focused | Present while focus is inside the field. |
| data-disabled | Present when the field is disabled. |
FieldLabel
The shared Label, wired to the field's control through htmlFor.
| Prop | Type | Default | Description |
|---|---|---|---|
| required | boolean | - | Appends the asterisk. Inherited from the Field. |
| disabled | boolean | - | Dims the label. Inherited from the Field. |
| info | ReactNode | - | Help behind an info icon after the text, in a tooltip. Screen readers get it as the control's description. |
FieldHint
The shared Hint: helper text that describes the control. An error takes its place while it shows.
| Prop | Type | Default | Description |
|---|---|---|---|
| hasError | boolean | - | Switches to the destructive tone. Inherited from the Field. |
FieldError
The error message. Without one, the Field renders its own. Describes the control through aria-describedby while it shows.
| Prop | Type | Default | Description |
|---|---|---|---|
| match | "valueMissing" | "typeMismatch" | "patternMismatch" | "tooShort" | "tooLong" | "rangeUnderflow" | "rangeOverflow" | "stepMismatch" | "badInput" | "customError" | boolean | - | Show only for one validity state. customError covers validate and server errors. true always shows. |
| children | ReactNode | - | Your copy. Without it, the field's own message (the browser's, your validator's or the server's). |
FieldGroup
Several controls answering one question: a real fieldset.
| Prop | Type | Default | Description |
|---|---|---|---|
| hasError | boolean | false | Destructive tone on the hint, aria-invalid on the fieldset. |
| disabled | boolean | false | Natively disables every control inside. |
| required | boolean | false | Appends the asterisk to the legend. |
FieldGroupLabel
The group's legend.
| Prop | Type | Default | Description |
|---|---|---|---|
| required | boolean | - | Appends the asterisk. Inherited from the group. |
| info | ReactNode | - | Help behind an info icon after the legend, in a tooltip. Describes the fieldset. |
FieldRow
Sibling fields on one line, stacked below sm.
| Prop | Type | Default | Description |
|---|---|---|---|
| columns | 2 | 3 | 2 | Columns from sm up. |