PixelDosa UI

Field

A composable form-field wrapper — Field, FieldLabel, FieldControl, FieldDescription and FieldError — that wires ARIA automatically onto whatever control you place inside it.

We'll only use this to send order updates.

Installation

bash
npx shadcn@latest add @pixeldosa/field

FieldControl 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-invalid and a composed aria-describedby pointing at whichever of the description and error are actually present.
  • Bring your own control — FieldControl clones its single child, so it works with a native <input>/<textarea> or any component that forwards id, ref and the ARIA attributes to a real form control.
  • FieldError renders 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.

Props

Field

PropTypeDefaultDescription
idstringgeneratedBase id for the control. Descendants derive -description and -error ids from it.
invalidbooleanfalseMarks 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-describedby combining the error and description ids automatically — you never write that string by hand.
  • FieldError uses role="alert" so a message appearing after the field already has focus (e.g. from async validation) is still announced.
  • aria-invalid is set on the control whenever Field's invalid prop is true, which most input styling (including this system's) hooks with aria-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.