Inputs

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.

Preview in your theme

Loading preview…

Registry dependencies
utils
import * as React from "react"
import { cn } from "@/lib/utils"

/**
 * The wiring FormField hands to its control. Spread it onto a real form element
 * (`input` / `textarea` / `select` / `button`) and the whole accessibility contract
 * — name, description order, invalid state, required state, disabled — lands with it.
 */
export interface FieldControlProps {
  /** Target of the `<label htmlFor>`. An `id` you put on the control yourself wins. */
  id: string
  /** Ids in reading order: description → counter → error (only the ones that exist). */
  "aria-describedby": string | undefined
  /** Present only while `error` is set. */

Installation

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

Prompt

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 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. 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.

On This Page