Field

A compound form-field primitive. Field.Root allocates stable ids for the label, control, description, and error parts, broadcasts them via context, and stamps data-invalid / data-disabled hooks for styling. Field.Control injects id, aria-describedby, aria-invalid, and disabled onto its single child — any focusable form element — so screen readers wire the label, helper text, and error message correctly without the consumer touching ARIA attributes.

Preview

We use this only for account recovery.

Installation

npx shadcn@latest add https://craftbits.dev/r/field.json

Usage

Minimal — label + control:

import { Field } from "@craft-bits/core";
 
<Field.Root>
  <Field.Label>Full name</Field.Label>
  <Field.Control>
    <input type="text" />
  </Field.Control>
</Field.Root>

With helper text:

<Field.Root>
  <Field.Label>Email</Field.Label>
  <Field.Control>
    <input type="email" />
  </Field.Control>
  <Field.Description>We use this only for account recovery.</Field.Description>
</Field.Root>

Invalid + error — flip invalid on the Root and the control's aria-invalid toggles, the Root surfaces data-invalid="true" for styling, and the Error renders an aria-live announcement:

<Field.Root invalid={Boolean(emailError)}>
  <Field.Label>Email</Field.Label>
  <Field.Control>
    <input type="email" />
  </Field.Control>
  <Field.Description>We use this only for account recovery.</Field.Description>
  <Field.Error>{emailError}</Field.Error>
</Field.Root>

Structured errors — pass an array of { message } objects and the component dedupes and renders them as a bullet list when more than one is present:

<Field.Error errors={fieldErrors} />

Understanding the component

  1. Compound, not props soup. Each part of the field — label, control, description, error — is its own component. You compose explicitly, so a field can carry custom controls, hint icons, or richer description layouts without growing the Root's prop surface.
  2. One context, every id wired. Field.Root mints controlId, descriptionId, and errorId (seeded by useId() for SSR safety) and shares them via context. Field.Label writes htmlFor, Field.Description and Field.Error write id, and Field.Control clones its child with id, aria-describedby, and aria-invalid injected.
  3. Children-aware ARIA. Field.Root walks its children at render time to detect which slots are present, so the control's aria-describedby only references the description / error ids when those nodes actually rendered — no stale references to unmounted helpers.
  4. Errors fade gracefully. Field.Error returns null when there is no content, so you can leave it mounted and conditionally pass errors or children. When multiple unique error messages flow in, they render as a bullet list; a single one renders inline.
  5. Token-only styling. Label, description, and error read from --cb-fg, --cb-fg-muted, and --cb-error. data-invalid flips the Root's colour cascade, so a single boolean turns the whole field red without per-element class toggles.

Props

Field.Root

PropTypeDefaultDescription
orientation"vertical" | "horizontal""vertical"Stacks label / control / helper vertically (default) or horizontally.
invalidbooleanfalseFlips data-invalid on the Root and aria-invalid on the injected control.
disabledbooleanfalseFlips data-disabled on the Root and propagates disabled to the control.
idstringOptional id seed for the control. Falls back to a useId()-derived id.
classNamestringMerged onto the underlying <div role="group">.

Field.Label

Extends <label> props. htmlFor is wired automatically from the Root's controlId.

Field.Description

Extends <p> props. id is wired automatically and referenced from the control's aria-describedby.

Field.Control

PropTypeDefaultDescription
childrenReactElementA single form control. Receives id, aria-describedby, aria-invalid, and disabled via prop injection.

Field.Error

PropTypeDefaultDescription
errorsReadonlyArray<{ message?: string } | undefined>Structured error list. Duplicates dedupe; multiples render as a bullet list.
childrenReactNodeInline error content. Wins over errors when both are provided.
classNamestringMerged onto the underlying <p role="alert">.

Accessibility

  • Field.Root defaults to role="group" so screen readers announce the label, control, helper, and error as one unit even when no Field.Label is rendered.
  • Field.Label writes htmlFor={controlId}, so clicking the label text focuses the control.
  • Field.Control injects aria-describedby referencing whichever of the description / error ids are present — no stale references when a slot is unmounted.
  • Field.Error renders with role="alert" and aria-live="polite" so the message is announced when it appears.
  • invalid toggles aria-invalid on the injected control and data-invalid="true" on the Root, giving styling and assistive tech the same source of truth.
  • Color contrast: the error colour reads from --cb-error on --cb-bg, passing WCAG AA in the default theme; reduced opacity for the disabled state stops at the AA contrast floor.

Credits

  • Extracted from: algoflashcards (src/platform/ui/field.tsx). The source was a flat module of nine sibling helpers (Field, FieldSet, FieldLegend, FieldGroup, FieldContent, FieldLabel, FieldTitle, FieldDescription, FieldSeparator, FieldError) wired through data-slot attributes and a cva orientation enum. craft-bits collapses the surface to a Radix-style compound — Field.Root, Field.Label, Field.Description, Field.Control, Field.Error — that owns the ARIA id wiring through context so a single <input> slot picks up id, aria-describedby, and aria-invalid without the consumer touching attributes.