Field
Combine labels, controls, and help text to compose accessible form fields and grouped inputs.
Usage
Field provides the shell and the accessibility plumbing: FieldLabel,
Input, FieldDescription, and FieldError connect automatically
(htmlFor / id / aria-describedby), so there are no manual ids.
Adornments are documented on InputGroup. For every combination and state side by side, see the Field Matrix.
import {
Field,
FieldDescription,
FieldError,
FieldGroup,
FieldLabel,
FieldLegend,
FieldSet,
} from '@epilot/spark-ui/field'
import { Input } from '@epilot/spark-ui/input'<Field>
<FieldLabel>Device name</FieldLabel>
<Input placeholder="Garage wallbox" />
<FieldDescription>Shown on the dashboard.</FieldDescription>
</Field>Examples
With a hint
Invalid with error message
FieldError renders a role="alert" message. Pass the message as children:
<FieldError>Enter a valid email address.</FieldError>Use the errors prop when you already hold a form library's error array
(react-hook-form's fieldState.errors, TanStack Form's
field.state.meta.errors) — it dedupes by message and renders more than one as
a list:
<FieldError errors={fieldState.errors} />Don't hand-write the [{ message }] shape; that's what children are
for. It renders nothing when there are no errors.
Keep one FieldError per Field. Several messages belong in one element,
via errors or your own markup in children — sibling FieldErrors would each
claim the same id and only the first would be announced.
While it has content it links to the control via aria-describedby, listed
before the description, so tabbing back into the field announces the error
first, and it marks the Field data-invalid plus the control aria-invalid.
Pass data-invalid yourself to override that. Passing your own id opts out of
the wiring entirely — the control then gets no aria-describedby link at all.
Read-only value
FieldValue shows a label–value pair in the field shell (replaces the old
DisplayField). Pass readOnly on Field so the row is named via
aria-labelledby instead of an invalid <label htmlFor> on the value div.
Long values wrap by default; pass truncate for a single-line ellipsis.
String values then get an automatic title so the full value is reachable on
hover.
Loading skeleton
FieldSkeleton composes label and value placeholders inside the Field shell.
Match ECP's gray-soft read-only look with inputColor="gray" and
inputStyle="soft". Pass aria-label on Field so the loading row has an
accessible name.
With an action: lay the Field out as a row. FieldContent stacks the label
and value, the action sits beside it (label it in the app's language).
Field group
FieldGroup stacks related fields with consistent spacing.
Field set
FieldSet + FieldLegend group fields under a semantic <fieldset> /
<legend>.
Complete form
API Reference
Field
All native <div> props, plus:
| Prop | Type | Default |
|---|---|---|
labelPosition | "outset" | "inset" | follows the data-label-position knob (inset) |
inputStyle | "soft" | "surface" | "outlined" | "ghost" | follows the data-input-style knob (soft) |
inputColor | "brand" | "gray" | follows the data-input-color knob (brand) |
readOnly | boolean | false — set for FieldLabel + FieldValue rows |
Each prop follows its theming knob, resolved through the <Theme> provider
(<Theme labelPosition inputStyle inputColor>); the prop overrides per
instance. Raw data-* attributes without the provider don't style
Field/Input.
The box carrier follows the position: inset → the Field shell (its input
keeps the classic accent underline), otherwise the Input or InputGroup.
Old API mapping: variant="underline" → default (inset + soft),
variant="outline" → outset + outlined. The floating-label look
(variant="floating" / labelPosition="overlap") was removed; use inset.
State attributes (pair with the matching prop on the input):
| Attribute | Pair with | Effect |
|---|---|---|
data-invalid | aria-invalid on the input | set automatically by a rendered FieldError; label + text turn error red, filled inset shells tint error-soft |
data-disabled | disabled on the input | control text dims, filled inset shells tint gray-soft; the label keeps full contrast |
Subcomponents
| Component | Renders | Notes |
|---|---|---|
FieldLabel | <label> or <div> | htmlFor wired to the input; renders a <div id={labelId}> when readOnly |
FieldDescription | <div> | linked to the input via aria-describedby automatically |
FieldError | <div role="alert"> | children, or errors?: Array<{ message?: string }> deduped; hidden if empty; links aria-describedby and marks the field invalid |
FieldValue | <div> | read-only value; wraps by default, truncate for ellipsis + auto title |
FieldSkeleton | <div aria-hidden> | loading placeholder for read-only rows; label + value bars via Skeleton |
FieldSet | <fieldset> | groups related fields |
FieldLegend | <legend> | variant: "legend" (default) | "label" |
FieldGroup | <div> | vertical stack of fields |
FieldContent | <div> | flex column for label + description next to a control |
FieldTitle | <div> | label-styled title for non-label contexts |
FieldSeparator | <div> + Separator | optional inline content (e.g. "or") |