Form Field
A label + control wrapper that wires id, aria-describedby, invalid, required, disabled and a character counter onto whatever control you put inside it.
Loading preview…
- Registry dependencies
- utils
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/form-field.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
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.
- No "use client" directive: it has no state, effects or handlers (useId, isValidElement,
cloneElement and forwardRef all work on the server), so it renders inside Server
Components too. That is what keeps the function-child form usable in an App Router
server page — a function cannot be passed across a client boundary.
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
- Two blocks — [label + hint] + description, then [control] + [counter/error]. In stack
layout the root itself is grid gap-1.5 and the two blocks simply stack.
- layout="inline" makes the root a container query context (@container/field) and puts
the two blocks in an INNER grid gap-1.5 that at @lg/field (32rem of container width)
becomes grid-cols-[minmax(0,1fr)_minmax(0,16rem)] — label left, control right. The
split is required: a container query matches only an ancestor container, never the
element that declares it, so @lg/field: variants on the root itself would never fire.
Below 32rem 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 grid (the root in stack layout, the inner grid in
inline layout) 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
- One
useIdper 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-describedbychaining — the description, counter and error are joined in that fixed reading order and only when they exist; the control's ownaria-describedbyis 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 (
cloneElementfor a single element, a render function when the control can't take injected props) instead of hoping the consumer remembers to writeidandaria-*by hand. - Additive merge — the field only turns state on: an id you supplied wins, and
aria-invalid/aria-required/disabledalready on the control survive when the field isn't setting them. aria-required, not nativerequired— 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-invalidon the root — an escape hatch for controls whose visible border lives on a wrapper element that never seesaria-invalid; style it withgroup-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. The root is the container and an inner grid carries the@lg/field:columns, because a container query can only match an ancestor. - 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.
Emoji Picker
A full emoji panel — category nav, search, skin tone, recents and a windowed, arrow-key-navigable grid, inline or portalled.
Credit Card Form
Card entry with brand-aware grouping and maxLength, a Luhn check, MM/YY with a real expiry rule, blur-time errors and auto-advance — UI only, real payments belong in Stripe Elements.