Photon Design System

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="you@company.com" />
  <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 manual htmlFor)
  • Field.Control — the <input>; swap the element with render
  • 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 (default false) — disable the control; takes precedence over disabled on Field.Control
    • name — string — the field’s form name; takes precedence over name on the control
    • invalid — boolean — force the invalid state (for externally controlled validity)
    • validate — (value, formValues) => string | string[] | null | Promise<…> — custom validator; return message(s) or null
    • validationMode — onSubmit · onBlur · onChange (default onSubmit) — when validation runs
    • validationDebounceTime — number (default 0) — debounce, in ms, for onChange validation
    • dirty / touched — boolean — override the tracked interaction state for external control
    • actionsRef — RefObject — imperative handle exposing validate()
  • Field.Label
    • required — boolean (default false) — draw the brand star after the label, as Input required does; set it on a field built from parts so it matches the Inputs beside it
    • nativeLabel — boolean (default true) — set false when rendering a non-<label> element (e.g. a <button> control) to skip native label behaviors
  • 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
  • Field.Error
    • match — true | keyof ValidityState — always show, or show only for a specific validity reason (valueMissing, typeMismatch, …); omit to match any invalid state
  • Field.Item
    • disabled — boolean (default false) — disable this item’s control; Field.Root’s disabled wins
  • Field.Validity
    • children — (state) => ReactNode — render prop given the field’s validity, error, and value

All parts forward className and their native element attributes.

Accessibility

  • Labelling — Field.Label renders a real <label> and the control gets aria-labelledby pointed at it, so the two are linked without a manual htmlFor / id; clicking the label focuses the control.
  • Descriptions — Field.Description and Field.Error each register their id into the control’s aria-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" and data-invalid (the ring styling hook). The matching Field.Error is surfaced through aria-describedby rather than an assertive live region, so it’s read when the user reaches the control, not shouted mid-typing.
  • Keyboard / focus — Field adds no interactions of its own; focus and keyboard behavior are whatever the wrapped control provides. The control shows the brand ring on focus-visible, switching to the error ring while invalid.

Best practices

  • Use Field to wrap a non-Input control (a custom picker, a textarea) so it still gets a label / description / error for free.
  • Prefer Field.Description for always-on guidance and Field.Error for validation feedback — don’t overload one to do both.
  • Give each Field.Error a match so the message names the actual problem; fall back to the native message only when a generic one will do.
  • Pick validationMode deliberately — onBlur for most inputs, onChange (with a debounce) only where live feedback earns the noise.
  • Let Base UI manage the label association; adding your own htmlFor / id can desync it.
  • Mark required fields all or none. Input required draws its star; a field built from parts (a Select, a pair of inputs under one label) takes Field.Label required so the form doesn’t show some required fields as optional. The star is only drawn (hidden from assistive tech), so set required on each control too, or aria-required="true" where native validation doesn’t fit, and the field’s state matches what it shows.