Inputs

Form Field

A form field wrapper React component that ties together label, hint, error and prefix/suffix addons; errors expand and collapse by height without layout jumps, addons share the input's focus ring, and a render prop wraps any control.

Everything a form field needs, in one wrapper: a label, a description under it, a hint below the field, an error message, a required asterisk, prefix/suffix addons and a character counter. Errors expand from zero height when they appear and collapse in reverse when they go, so the fields below glide out of the way instead of jumping; when the error text changes it only cross-fades, without re-running the height animation. Prefix, input and suffix sit in a single frame that lights up one focus ring together, with a 2px bottom line that expands from the center. With no children it renders a built-in input (Enter is IME-safe, characters are counted correctly); pass a render prop to wrap your own select, textarea or any control, with id, aria-describedby and aria-invalid wired up for you.

Bring this into your project

Run this command in your project folder. The CLI adds the source and dependencies; then follow the usage example below.

npx webberui@latest add form-field.en

New project or installation trouble? Start here →

Playground

Tune the props live — the code snippet updates as you go, so you can dial in the look you want before copying it.

180
Active edited usageDefault props
<FormField/>

The complete example includes the preview layout and data. Install the component first, then paste this file into your project.

Installation

npx webberui@latest add https://webberui.com/r/form-field.en.json

Or, once registries are configured in components.json, install it as @webberui/form-field.

Usage

import { Mail } from "lucide-react";
import { FormField } from "@/components/ui/form-field";

// Built-in input
<FormField
  label="Email"
  required
  prefix={<Mail aria-hidden />}
  hint="We never show your email publicly"
  error={emailError}
  inputProps={{
    type: "email",
    value: email,
    onChange: (value) => setEmail(value),
    onEnter: () => submit(),
  }}
/>

// Render prop: wrap your own control
<FormField label="City" required error={cityError}>
  {(field) => (
    <select {...field} value={city} onChange={(e) => setCity(e.target.value)}>
      <option value="">Choose one</option>
      <option value="Taipei City">Taipei City</option>
    </select>
  )}
</FormField>

Props

PropTypeDefaultDescription
labelReactNode—Field label
descriptionReactNode—Description shown under the label
hintReactNode—Grey hint below the field; replaced by error when one appears
errorReactNode—Error message; any value puts the field in the error state
requiredbooleanfalseAdds a red "*" after the label, plus required and aria-required on the built-in input
orientation"vertical" | "horizontal""vertical"Layout; horizontal gives the label a fixed-width right-aligned column and falls back to vertical on phones
prefixReactNode—Prefix addon: text (grey segment), an icon or a button (transparent)
suffixReactNode—Suffix addon: text (grey segment), an icon or a button (transparent)
maxLengthnumber—Character limit (counted with Array.from, so CJK and emoji count as one each); built-in input only
showCountbooleanfalseShows a "12 / 50" counter at the bottom right; built-in input only
durationnumber180Duration of the error expand / collapse animation (ms)
disabledbooleanfalseDisables the field
inputPropsFormFieldInputProps—Props for the built-in input
children(field: FormFieldControlProps) => ReactNode—Render prop; the built-in input renders when omitted
classNamestring—Passed through to the outermost container

FormFieldInputProps

Any other native <input> attributes (type, placeholder, name, onBlur, autoComplete, ...) are passed through.

FieldTypeDefaultDescription
valuestring—Controlled value; when omitted the component manages it internally (uncontrolled)
defaultValuestring""Initial value in uncontrolled mode
onChange(value: string, event) => void—Called when the value changes (already truncated to maxLength characters)
onEnter(value: string, event) => void—Called on Enter; never fires while an IME is composing

FormFieldControlProps

FieldTypeDefaultDescription
idstring—Generated by useId; the label points at it via htmlFor
aria-describedbystring | undefined—Ids of the description, hint / error and counter
aria-invalidboolean—Whether the field is in the error state
aria-requiredtrue | undefined—true when required
disabledboolean | undefined—Whether the control is disabled
classNamestring—Borderless, transparent control styles so it shares the frame with the addons

How it works

  • Jump-free errors: the error area expands with height: 0 → auto plus a fade, timed by duration (180ms, easeOut by default); when a hint and an error swap, one collapses while the other expands, so the total height changes continuously. When the error text changes, old and new are stacked in one cell and cross-fade, without re-running the height animation.
  • Shared frame: prefix, control and suffix live in one flex container that shows the ring and a darker border via :focus-within, switching to a red border on error. Plain-text addons (such as "https://" or ".example.tw") render as grey segments; icons and buttons stay transparent.
  • Focus feedback: the label text goes from neutral-600 to neutral-950 (reversed in dark mode) and a 2px line along the frame's bottom edge expands from the center; both are timed by --wb-form-field-focus-duration (150ms by default). The wrapper also writes --wb-form-field-duration via style, equal to duration.
  • Characters are counted with Array.from(value).length, so CJK and emoji count as one each; nothing is truncated mid-composition — truncation to maxLength happens when the IME composition ends, so Zhuyin / Pinyin input is never interrupted.
  • The built-in input's onEnter is IME-safe: Enter during composition (e.g. Chinese input methods) does not fire it, including Safari's keydown that arrives after compositionend.
  • The className passed to the render prop is a borderless, transparent control style; spreading {...field} blends the control into the frame, and you can append your own classes.

Accessibility

  • The label is tied to the control via htmlFor; aria-describedby chains the description, the hint or error, and the counter
  • On error the control gets aria-invalid and the error area is role="alert", so screen readers announce it when it appears and when it changes; errors show a CircleAlert icon plus text, not color alone
  • With required, the built-in input gets required and aria-required, and the render prop receives aria-required too; the red asterisk is hidden from assistive tech
  • The focus frame is driven by :focus-within, so it is visible for both keyboard and pointer focus
  • When the user has reduce motion enabled, errors simply appear / disappear (a 100ms fade only) and the bottom line changes color instead of expanding