Skip to content
Form Controls primitive

Field

The label, hint, error and aria wiring around one input — including the part that is usually wrong, where the hint has to stay in aria-describedby once an error joins it.

129 lineslucide-reactAdded 13 Sept 2026
  • form
  • label
  • error
  • validation
  • accessibility
  • a11y

What's included

  • components/ui/field.tsx
  • Needs lucide-react

Works with

  • React
  • Next.js
  • Tailwind CSS
  • TypeScript

npx hoverlab add field

Or over MCP, from your editor's agent — no account needed.

License

Free to read, copy and install, for personal and non-commercial projects. Shipping it in client work or a paid product needs Pro ($79 once). The source lands in your repo and stops being ours — no attribution, nothing to upgrade.

Was this useful?

Live

Interactive, in your current theme. Every state below is the real component — type in it, tab through it, switch the theme.

Shown to everyone you invite.

Letters and numbers only.

Source

One file. Create components/ui/field.tsx and paste.

components/ui/field.tsx
'use client'

/**
 * <Field> — the label, hint, error and aria wiring around one input.
 *
 * This is the primitive that is missing from every base library and
 * rebuilt, slightly wrong, in every project. The wiring it owns:
 *
 *   label      `htmlFor` pointing at the control's id
 *   hint       its own id, joined into `aria-describedby`
 *   error      its own id, ALSO joined into `aria-describedby`, plus
 *              `aria-invalid` on the control
 *   required   a visual marker and `aria-required`, which are different
 *              things and both needed
 *
 * The part that is usually wrong is the error. Screen readers announce
 * `aria-describedby`, so an error message that is only visually adjacent is
 * invisible to them; and the hint must stay in the list when an error
 * appears, because "must be 8 characters" is exactly what the person who
 * just failed validation needs to hear.
 *
 * The control is a render prop rather than a cloned child. `cloneElement`
 * reads nicer in a demo and then quietly fails on the first control that is
 * a wrapper — a Radix Select, a react-hook-form Controller, anything that
 * does not forward unknown props to a DOM node. Handing the ids out and
 * letting the caller apply them works for every one of those, and it makes
 * the wiring visible at the call site instead of magic.
 */

import * as React from 'react'
import { AlertCircle } from 'lucide-react'

export interface FieldRenderProps {
  /** Put this on the control. The label's `htmlFor` already points at it. */
  id: string
  /** Spread onto the control: `aria-describedby` and `aria-invalid`. */
  'aria-describedby': string | undefined
  'aria-invalid': boolean | undefined
  'aria-required': boolean | undefined
}

export interface FieldProps {
  label: string
  /** Persistent help text. Stays visible when an error appears. */
  hint?: string
  /** Validation message. Its presence is what makes the field invalid. */
  error?: string
  required?: boolean
  /** Hide the label visually but keep it for screen readers. */
  labelHidden?: boolean
  children: (props: FieldRenderProps) => React.ReactNode
  className?: string
}

export function Field({
  label,
  hint,
  error,
  required = false,
  labelHidden = false,
  children,
  className = '',
}: FieldProps) {
  // `useId` rather than a counter: a server-rendered id has to match the
  // client's or React discards the markup and rerenders the whole subtree.
  const id = React.useId()
  const hintId = `${id}-hint`
  const errorId = `${id}-error`

  const describedBy =
    [hint ? hintId : null, error ? errorId : null].filter(Boolean).join(' ') || undefined

  return (
    <div className={`flex flex-col gap-1.5 ${className}`}>
      <label
        htmlFor={id}
        className={
          labelHidden
            ? 'sr-only'
            : 'text-sm font-medium leading-none text-foreground'
        }
      >
        {label}
        {required ? (
          <>
            {/* The asterisk is decoration; `aria-required` on the control
                is what actually announces it, so this is hidden rather
                than read out as "star". */}
            <span aria-hidden className="ms-1 text-destructive">
              *
            </span>
          </>
        ) : null}
      </label>

      {children({
        id,
        'aria-describedby': describedBy,
        'aria-invalid': error ? true : undefined,
        'aria-required': required || undefined,
      })}

      {hint ? (
        <p id={hintId} className="text-xs text-muted-foreground">
          {hint}
        </p>
      ) : null}

      {error ? (
        <p
          id={errorId}
          /*
           * `role="alert"` announces the message when it appears, which is
           * what you want on submit. It is on the message and not on a
           * permanent wrapper: an empty live region that exists from first
           * paint announces nothing, and a wrapper that is always there
           * re-announces on every keystroke that changes the text.
           */
          role="alert"
          className="flex items-center gap-1.5 text-xs font-medium text-destructive"
        >
          <AlertCircle className="h-3.5 w-3.5 shrink-0" aria-hidden />
          {error}
        </p>
      ) : null}
    </div>
  )
}

Props

Read out of the component’s own type and signature, so this cannot drift from the source below. Every prop has a default — the component renders standalone before you pass it anything.

PropTypeDefault
idrequiredPut this on the control. The label's `htmlFor` already points at it.string—

For AI

The component, its props, the design tokens it expects and the command that installs it — as one prompt. Paste it into Claude, Cursor, v0 or ChatGPT and what they build around it will match the rest of the catalog instead of inventing its own system.

See the prompt

More form controls

View all
Open the full page for this primitive

Input Group

An input with addons, an inline icon or an action button attached, with the padding reserved for the icon and the focus ring on the whole group rather than its middle third.

Form Controls120 linesNo deps