Field
The accessible scaffolding around a single form control — label, description,
and validation error, all wired together. Built on Base UI Field. Input
already composes Field internally; reach for Field directly when you need
to wrap a custom control or lay out the pieces yourself.
Import
import { Field } from "@photon-ai/pho-ui/components/field";
Basic
Field.Label is associated with Field.Control automatically — no manual
htmlFor. Field.Description adds helper text that stays put.
Used in your project URLs. Lowercase letters and dashes only.
<Field.Root>
<Field.Label>Workspace name</Field.Label>
<Field.Control placeholder="acme-inc" />
<Field.Description>Lowercase letters and dashes only.</Field.Description>
</Field.Root>
Validation
Field.Error appears when the control fails validation. Set validationMode on
Field.Root to choose when that runs (onBlur here), and give each Field.Error
a match to write copy per failure reason — otherwise it shows the browser’s
native message. The control picks up the invalid ring automatically via
data-invalid.
We'll send the invite here.
<Field.Root validationMode="onBlur">
<Field.Label>Work email</Field.Label>
<Field.Control type="email" required placeholder="[email protected]" />
<Field.Description>We'll send the invite here.</Field.Description>
<Field.Error match="valueMissing">Enter your work email.</Field.Error>
<Field.Error match="typeMismatch">
That doesn't look like an email address.
</Field.Error>
</Field.Root>
Several controls
One label, several controls: the lines of an address, first and last name,
the parts of a date. Field.Controls stacks them at the gap the system uses
between the parts of one thing, and the description lands under the whole
block, not the first line. Put BasicPage.Inline inside for a side-by-side
row.
The registry doesn't accept PO boxes.
<Field.Root>
<Field.Label>Address</Field.Label>
<Field.Controls>
<Input aria-label="Street address" placeholder="123 Main St" />
<Input
aria-label="Suite, floor, or unit"
placeholder="Suite, floor, or unit (optional)"
/>
<BasicPage.Inline>
<Input aria-label="City" placeholder="City" />
<Input aria-label="ZIP code" placeholder="ZIP" />
</BasicPage.Inline>
</Field.Controls>
<Field.Description>The registry doesn't accept PO boxes.</Field.Description>
</Field.Root>
Anatomy
- Field.Root — owns the field state and the disabled / invalid wiring
- Field.Label — auto-associated
<label>(no manualhtmlFor) - Field.Control — the
<input>; swap the element withrender - Field.Controls — several controls under one label, stacked at the parts gap
- Field.Description — persistent helper text
- Field.Error — validation message, shown only when the control is invalid
- Field.Item — wraps one option’s control + label in a checkbox / radio group
- Field.Validity — render-prop access to the raw
ValidityState
Props
- Field.Root
- disabled —
boolean(defaultfalse) — disable the control; takes precedence overdisabledonField.Control - name —
string— the field’s form name; takes precedence overnameon the control - invalid —
boolean— force the invalid state (for externally controlled validity) - validate —
(value, formValues) => string | string[] | null | Promise<…>— custom validator; return message(s) ornull - validationMode —
onSubmit·onBlur·onChange(defaultonSubmit) — when validation runs - validationDebounceTime —
number(default0) — debounce, in ms, foronChangevalidation - dirty / touched —
boolean— override the tracked interaction state for external control - actionsRef —
RefObject— imperative handle exposingvalidate()
- disabled —
- Field.Label
- required —
boolean(defaultfalse) — draw the brand star after the label, asInput requireddoes; set it on a field built from parts so it matches theInputs beside it - nativeLabel —
boolean(defaulttrue) — setfalsewhenrendering a non-<label>element (e.g. a<button>control) to skip native label behaviors
- required —
- Field.Control
- onValueChange —
(value: string, details) => void— fires on value change when controlled - defaultValue — the uncontrolled initial value
- …native
<input>attributes —type,value,required,placeholder, … forwarded to the element
- onValueChange —
- Field.Error
- match —
true | keyof ValidityState— always show, or show only for a specific validity reason (valueMissing,typeMismatch, …); omit to match any invalid state
- match —
- Field.Item
- disabled —
boolean(defaultfalse) — disable this item’s control;Field.Root’sdisabledwins
- disabled —
- Field.Validity
- children —
(state) => ReactNode— render prop given the field’s validity, error, and value
- children —
All parts forward className and their native element attributes.
Accessibility
- Labelling —
Field.Labelrenders a real<label>and the control getsaria-labelledbypointed at it, so the two are linked without a manualhtmlFor/id; clicking the label focuses the control. - Descriptions —
Field.DescriptionandField.Erroreach register theiridinto the control’saria-describedby, so both the helper text and the active error are announced when the control is read. - Invalid state — when the field is invalid the control receives
aria-invalid="true"anddata-invalid(the ring styling hook). The matchingField.Erroris surfaced througharia-describedbyrather than an assertive live region, so it’s read when the user reaches the control, not shouted mid-typing. - Keyboard / focus —
Fieldadds no interactions of its own; focus and keyboard behavior are whatever the wrapped control provides. The control shows the brand ring onfocus-visible, switching to the error ring while invalid.
Best practices
- Use
Fieldto wrap a non-Inputcontrol (a custom picker, a textarea) so it still gets a label / description / error for free. - Prefer
Field.Descriptionfor always-on guidance andField.Errorfor validation feedback — don’t overload one to do both. - Give each
Field.Erroramatchso the message names the actual problem; fall back to the native message only when a generic one will do. - Pick
validationModedeliberately —onBlurfor most inputs,onChange(with a debounce) only where live feedback earns the noise. - Let Base UI manage the label association; adding your own
htmlFor/idcan desync it. - Mark required fields all or none.
Input requireddraws its star; a field built from parts (aSelect, a pair of inputs under one label) takesField.Label requiredso the form doesn’t show some required fields as optional. The star is only drawn (hidden from assistive tech), so setrequiredon each control too, oraria-required="true"where native validation doesn’t fit, and the field’s state matches what it shows.