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.

We'll only use it for account notifications.

Installation#

# One-time setup (tokens + lib helpers)npx koalaui-cli@latest init# Add this component (its dependencies come along)npx koalaui-cli@latest add field

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.

usage.tsxTSX
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.

Enter a valid email address.

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.

A short, descriptive name for the report.

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.

We send the receipt here.

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.

8 characters and a number.

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.

Checked as you type.

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.

At least 8 characters.

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.

We'll send a confirmation link here.

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.

PropTypeDefaultDescription
hasErrorboolean-Forces the error state and wins over anything computed. Leave it unset to let native constraints, validate and server errors decide.
requiredbooleanfalseAppends a destructive asterisk to the label. Put required on the control too for the check itself.
disabledbooleanfalseDims the label, disables the control and skips validation.
namestring-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.
validationDebounceTimenumber-Overrides the Form's debounce for this field, in ms.
Data attributeDescription
data-invalidPresent while the field is invalid. data-error mirrors it.
data-validPresent once the field has been validated and passed.
data-validatingPresent while an async validate is pending.
data-touchedPresent once focus has left the field.
data-dirtyPresent when the value differs from the initial one.
data-filledPresent when the control has a value.
data-focusedPresent while focus is inside the field.
data-disabledPresent when the field is disabled.

FieldLabel

The shared Label, wired to the field's control through htmlFor.

PropTypeDefaultDescription
requiredboolean-Appends the asterisk. Inherited from the Field.
disabledboolean-Dims the label. Inherited from the Field.
infoReactNode-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.

PropTypeDefaultDescription
hasErrorboolean-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.

PropTypeDefaultDescription
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.
childrenReactNode-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.

PropTypeDefaultDescription
hasErrorbooleanfalseDestructive tone on the hint, aria-invalid on the fieldset.
disabledbooleanfalseNatively disables every control inside.
requiredbooleanfalseAppends the asterisk to the legend.

FieldGroupLabel

The group's legend.

PropTypeDefaultDescription
requiredboolean-Appends the asterisk. Inherited from the group.
infoReactNode-Help behind an info icon after the legend, in a tooltip. Describes the fieldset.

FieldRow

Sibling fields on one line, stacked below sm.

PropTypeDefaultDescription
columns2 | 32Columns from sm up.

FAQ#

Field calls useId to mint stable ids and hands them through context to its parts: FieldLabel gets the matching htmlFor, the control gets the id, and FieldHint gets pointed at by aria-describedby. You never set id, htmlFor, or aria-describedby yourself.