Installation
npx shadcn@latest add @pixeldosa/fieldFieldControl clones its single child rather than rendering a wrapper element, so pass exactly one React element (an <input>, <textarea>, or a component that forwards id/aria-invalid/aria-describedby/ref to a real form control) as its child.
Usage
import { Field, FieldControl, FieldDescription, FieldError, FieldLabel } from "@/components/ui/field";
export function EmailField({ value, onChange, error }: Props) {
return (
<Field invalid={Boolean(error)}>
<FieldLabel>Email</FieldLabel>
<FieldControl>
<input type="email" value={value} onChange={onChange} className="…" />
</FieldControl>
<FieldDescription>We'll only use this to send order updates.</FieldDescription>
<FieldError>{error}</FieldError>
</Field>
);
}
FieldControl clones its single child, so it accepts exactly one React element — a
native <input>/<textarea>, or any component that forwards id, aria-invalid,
aria-describedby and ref to a real form control.
FieldError renders nothing when its children are falsy, so you can pass a validation
message straight through without a conditional wrapper.
Features
- ARIA wiring you never write by hand: the control receives
id,aria-invalidand a composedaria-describedbypointing at whichever of the description and error are actually present. - Bring your own control —
FieldControlclones its single child, so it works with a native<input>/<textarea>or any component that forwardsid,refand the ARIA attributes to a real form control. FieldErrorrenders nothing when falsy, so a validation message can be passed straight through without a conditional wrapper around it.- Announced late errors:
role="alert"means an async validation message is read out even when the field already has focus.
Variants
The default state — label, control, and a supporting description.
We'll only use this to send order updates.
Invalid
invalid flows into aria-invalid on the control, which the input styling hooks with
aria-invalid: variants — the ring is a consequence of the ARIA state, not a parallel
visual flag that can drift out of sync with it.
Enter a complete email address.
Props
Field
| Prop | Type | Default | Description |
|---|---|---|---|
id | string | generated | Base id for the control. Descendants derive -description and -error ids from it. |
invalid | boolean | false | Marks the field invalid — flows into aria-invalid on the control. |
FieldControl
Takes exactly one child element. No other props.
FieldLabel, FieldDescription, FieldError
Standard label / p / p props. Must be rendered inside a Field.
Accessibility
- The control receives
aria-describedbycombining the error and description ids automatically — you never write that string by hand. FieldErrorusesrole="alert"so a message appearing after the field already has focus (e.g. from async validation) is still announced.aria-invalidis set on the control wheneverField'sinvalidprop is true, which most input styling (including this system's) hooks witharia-invalid:variants.
why it's built this way
Engineering Notes
Built as a compound-component set sharing React context rather than a single Field component with many props, because form fields have a genuinely variable shape (some have descriptions, some don't; some controls are custom comboboxes, not <input>) and a large prop-driven API would force every consumer through the same limited combinations. FieldControl clones its child instead of rendering its own DOM node specifically so that input+icon or input+button compositions keep their existing layout — a wrapper div would break those. FieldDescription and FieldError register their presence into context via an effect so FieldControl's aria-describedby is always accurate even when a description or error mounts after the control, which happens constantly with async validation.
how it moves, and when it doesn't
Motion Notes
No motion. This is a compound primitive other components render inside of, not a state that changes visibly on its own — anything Field itself animated would be inherited (and likely duplicated) by every component built on top of it. Error text appearing or disappearing is the one plausible candidate for a transition; it is left as a plain instantaneous swap here so that a consumer building a specific validation UX (e.g. GhostInput's inline error) chooses its own timing rather than inheriting one that might not fit.