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.

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.

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>

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.