# Form Field (/docs/inputs/form-field)



<ComponentShowcase name="form-field" />

## Installation [#installation]

```bash
npx shadcn@latest add https://ui.zyeon.ai/r/form-field.json
```

## Prompt [#prompt]

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

```text
Build a React + TypeScript + Tailwind "FormField" component — a wrapper that owns the
accessibility wiring around someone else's control. No extra dependencies beyond cn().

Contract
- export const FormField = forwardRef<HTMLDivElement, FormFieldProps>, spreading the
  remaining HTMLAttributes onto the root <div>.
- Props: label: ReactNode (required), children (see injection below), description?:
  ReactNode, error?: string, required?: boolean (false), optionalLabel?: ReactNode
  (nothing is shown unless you pass it), hint?: ReactNode (top-right slot next to the
  label), layout?: "stack" | "inline" ("stack"), labelHidden?: boolean (false),
  counter?: { value: number; max: number }, disabled?: boolean (false), className.
- Also export the injected shape so consumers can type their own render function:
    interface FieldControlProps {
      id: string
      "aria-describedby": string | undefined
      "aria-invalid": true | undefined
      "aria-required": true | undefined
      disabled: boolean | undefined
    }
    type FieldControlRender = (control: FieldControlProps) => ReactNode
  children is `ReactElement<Partial<FieldControlProps>> | FieldControlRender`.
- FormField owns no value and runs no validation. It renders the `error` string you
  hand it; deciding when there is an error stays with the consumer's schema.

Behavior — injection
- Two ways in, one wiring object:
  1. A single element child: merge the wiring in with cloneElement. This is the
     ergonomic default — <FormField label="Email"><input /></FormField>.
  2. A function child: call it with the wiring and let the consumer place the props.
     Needed for fragments, two controls sharing one label, or a third-party component
     that drops unknown props.
  Never cast with `as any`: type children as the union above, guard the element branch
  with isValidElement, and if children is neither (a string, an array), render it
  untouched rather than dropping it — the consumer then owns the id themselves.
- The field only ever ADDS state. Merge rules: an `id` already on the control wins and
  becomes the label's htmlFor target; aria-invalid / aria-required / disabled fall back
  to whatever the control declared; the control's own aria-describedby is appended
  after the field's ids instead of being overwritten.

Behavior — a11y wiring (the whole point)
- One useId() seeds every id: `${uid}-control`, -description, -counter, -error.
- <label htmlFor={controlId}> always renders, even when labelHidden (then it is sr-only).
- aria-describedby chains only the ids that actually exist, in reading order:
  description → counter → error → the control's pre-existing describedby.
- error present → aria-invalid="true" on the control, message rendered with role="alert"
  so it is announced when it appears, and the label turns destructive.
- required → aria-required on the control, a visual "*" marked aria-hidden, plus an
  sr-only " (required)" inside the label so screen readers hear it once, not twice.
  Do NOT set the native `required` attribute: the browser's own validation bubble would
  race the error you are rendering.
- disabled → forwarded to the control and dims label + description.
- optionalLabel only renders when the field is not required.
- The root carries data-invalid while there is an error, so controls whose border lives
  on a wrapper element (a search field, a combobox shell) can be styled with
  group-data-[invalid]/field:border-destructive — aria-invalid alone can't reach them.

Behavior — counter
- Clamp both numbers (Number.isFinite, Math.trunc, floor at 0). max <= 0 means "no
  limit": render the bare count and never flag it.
- used >= max * 0.9 → text-destructive; used > max → destructive + font-medium.
- Renders "used/max" plus an sr-only " characters used" so the describedby announcement
  is a sentence, not two numbers. Going over the limit does NOT auto-set the error —
  pass `error` yourself if it should block submission.

Rendering & styling
- Root: grid gap-1.5 with two blocks — [label + hint] + description, then
  [control] + [counter/error]. In stack layout the two blocks simply stack.
- layout="inline" turns the root into a container query context (@container/field) and
  at @lg (32rem of container width) becomes
  grid-cols-[minmax(0,1fr)_minmax(0,16rem)] — label left, control right. Below that it
  falls back to stack on its own, so the same field works in a settings page and in a
  320px sidebar without a media query.
- The counter comes BEFORE the error in the DOM (matching the describedby order) and is
  flipped back with order-2 / ml-auto so the message reads left and the count sits right.
- Semantic tokens only: text-destructive for the error / marker / hot counter,
  text-muted-foreground for description / hint / counter, border + ring tokens come from
  the control itself. No hardcoded colors. cn() merges the consumer className onto the root.
- No animation, so nothing to gate behind prefers-reduced-motion.

Customization levers
- Density: the single `gap-1.5` on the root and both blocks is the vertical rhythm knob;
  label text-sm / description text-xs / error text-xs is the type scale.
- Inline column split: change minmax(0,16rem) for a wider control column, and swap the
  @lg breakpoint for @md / @xl to decide how early it goes two-column. On Tailwind v3
  (no container queries) replace @lg/field: with sm: — it degrades to a viewport rule.
- Which sub-blocks exist is purely prop-driven: drop hint, optionalLabel or counter and
  no markup is emitted. Add a leading icon or a trailing unit by wrapping the control
  in the function-child form.
- Error presentation: swap role="alert" for aria-live="polite" if your form validates on
  every keystroke and the assertive announcements get noisy; keep the id in the
  describedby chain either way.
- Counter threshold: 0.9 is the "getting close" ratio; lower it to 0.75 for short fields
  or make it absolute (max - used <= 10) for long ones.
- Label styling variants (uppercase micro-labels, larger settings labels) are a single
  className on the label element — the wiring is untouched.
```

## Concepts [#concepts]

<Mermaid
  chart="`flowchart TD
A[&#x22;useId() seeds one field id&#x22;] --> B[&#x22;controlId / descriptionId / counterId / errorId&#x22;]
B --> C{&#x22;child already has an id?&#x22;}
C -->|&#x22;yes&#x22;| D[&#x22;keep it — label htmlFor points at it&#x22;]
C -->|&#x22;no&#x22;| E[&#x22;use the generated controlId&#x22;]
D --> F[&#x22;aria-describedby chain&#x22;]
E --> F
F --> G[&#x22;description → counter → error → child's own&#x22;]
G --> H{&#x22;how is the child shaped?&#x22;}
H -->|&#x22;single element&#x22;| I[&#x22;cloneElement merges the wiring&#x22;]
H -->|&#x22;function&#x22;| J[&#x22;call it, consumer places the props&#x22;]
H -->|&#x22;neither&#x22;| K[&#x22;render untouched, consumer owns the id&#x22;]
I --> L[&#x22;control renders with id + aria&#x22;]
J --> L
M[&#x22;error set&#x22;] --> N[&#x22;aria-invalid + role=alert + destructive label + data-invalid on root&#x22;]
N --> L
O[&#x22;required&#x22;] --> P[&#x22;aria-required + hidden '*' + sr-only '(required)'&#x22;]
P --> L`"
/>

* **One `useId` per field** — every id (control, description, counter, error) is derived from a single React id, so the label, the control and the message block can never drift apart, and two copies of the same field on one page stay independent.
* **`aria-describedby` chaining** — the description, counter and error are joined in that fixed reading order and only when they exist; the control's own `aria-describedby` is appended rather than overwritten, so a control that already describes itself doesn't lose that.
* **Injection over convention** — the wiring is pushed into the control (`cloneElement` for a single element, a render function when the control can't take injected props) instead of hoping the consumer remembers to write `id` and `aria-*` by hand.
* **Additive merge** — the field only turns state on: an id you supplied wins, and `aria-invalid` / `aria-required` / `disabled` already on the control survive when the field isn't setting them.
* **`aria-required`, not native `required`** — the marker and the ARIA state describe the field, while browser-native validation stays off so its bubble never competes with the error message you render.
* **Error as `role="alert"`** — the message is announced when it appears, and simultaneously sits in the describedby chain so a user landing on the field later still hears why it's invalid.
* **`data-invalid` on the root** — an escape hatch for controls whose visible border lives on a wrapper element that never sees `aria-invalid`; style it with `group-data-[invalid]/field:…`.
* **Container-query inline layout** — `layout="inline"` measures the field's own width, not the viewport, so the same settings row is two columns in a wide panel and stacked in a narrow drawer without the consumer choosing.
* **Counter as description, not validation** — the count is announced with the field and turns destructive near the limit, but going over never fabricates an error; the consumer's schema decides whether it blocks submission.
