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.
Installation
npx shadcn@latest add https://craftbits.dev/r/field.jsonUsage
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
- 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.
- One context, every id wired.
Field.RootmintscontrolId,descriptionId, anderrorId(seeded byuseId()for SSR safety) and shares them via context.Field.LabelwriteshtmlFor,Field.DescriptionandField.Errorwriteid, andField.Controlclones its child withid,aria-describedby, andaria-invalidinjected. - Children-aware ARIA.
Field.Rootwalks its children at render time to detect which slots are present, so the control'saria-describedbyonly references the description / error ids when those nodes actually rendered — no stale references to unmounted helpers. - Errors fade gracefully.
Field.Errorreturnsnullwhen there is no content, so you can leave it mounted and conditionally passerrorsorchildren. When multiple unique error messages flow in, they render as a bullet list; a single one renders inline. - Token-only styling. Label, description, and error read from
--cb-fg,--cb-fg-muted, and--cb-error.data-invalidflips the Root's colour cascade, so a single boolean turns the whole field red without per-element class toggles.
Props
Field.Root
| Prop | Type | Default | Description |
|---|---|---|---|
orientation | "vertical" | "horizontal" | "vertical" | Stacks label / control / helper vertically (default) or horizontally. |
invalid | boolean | false | Flips data-invalid on the Root and aria-invalid on the injected control. |
disabled | boolean | false | Flips data-disabled on the Root and propagates disabled to the control. |
id | string | — | Optional id seed for the control. Falls back to a useId()-derived id. |
className | string | — | Merged 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
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactElement | — | A single form control. Receives id, aria-describedby, aria-invalid, and disabled via prop injection. |
Field.Error
| Prop | Type | Default | Description |
|---|---|---|---|
errors | ReadonlyArray<{ message?: string } | undefined> | — | Structured error list. Duplicates dedupe; multiples render as a bullet list. |
children | ReactNode | — | Inline error content. Wins over errors when both are provided. |
className | string | — | Merged onto the underlying <p role="alert">. |
Accessibility
Field.Rootdefaults torole="group"so screen readers announce the label, control, helper, and error as one unit even when noField.Labelis rendered.Field.LabelwriteshtmlFor={controlId}, so clicking the label text focuses the control.Field.Controlinjectsaria-describedbyreferencing whichever of the description / error ids are present — no stale references when a slot is unmounted.Field.Errorrenders withrole="alert"andaria-live="polite"so the message is announced when it appears.invalidtogglesaria-invalidon the injected control anddata-invalid="true"on the Root, giving styling and assistive tech the same source of truth.- Color contrast: the error colour reads from
--cb-erroron--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 throughdata-slotattributes and acvaorientation 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 upid,aria-describedby, andaria-invalidwithout the consumer touching attributes.