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.enPlayground
Tune the props live — the code snippet updates as you go, so you can dial in the look you want before copying it.
<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.jsonOr, 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
| Prop | Type | Default | Description |
|---|---|---|---|
label | ReactNode | — | Field label |
description | ReactNode | — | Description shown under the label |
hint | ReactNode | — | Grey hint below the field; replaced by error when one appears |
error | ReactNode | — | Error message; any value puts the field in the error state |
required | boolean | false | Adds 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 |
prefix | ReactNode | — | Prefix addon: text (grey segment), an icon or a button (transparent) |
suffix | ReactNode | — | Suffix addon: text (grey segment), an icon or a button (transparent) |
maxLength | number | — | Character limit (counted with Array.from, so CJK and emoji count as one each); built-in input only |
showCount | boolean | false | Shows a "12 / 50" counter at the bottom right; built-in input only |
duration | number | 180 | Duration of the error expand / collapse animation (ms) |
disabled | boolean | false | Disables the field |
inputProps | FormFieldInputProps | — | Props for the built-in input |
children | (field: FormFieldControlProps) => ReactNode | — | Render prop; the built-in input renders when omitted |
className | string | — | Passed through to the outermost container |
FormFieldInputProps
Any other native <input> attributes (type, placeholder, name, onBlur, autoComplete, ...) are passed through.
| Field | Type | Default | Description |
|---|---|---|---|
value | string | — | Controlled value; when omitted the component manages it internally (uncontrolled) |
defaultValue | string | "" | 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
| Field | Type | Default | Description |
|---|---|---|---|
id | string | — | Generated by useId; the label points at it via htmlFor |
aria-describedby | string | undefined | — | Ids of the description, hint / error and counter |
aria-invalid | boolean | — | Whether the field is in the error state |
aria-required | true | undefined | — | true when required |
disabled | boolean | undefined | — | Whether the control is disabled |
className | string | — | 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 → autoplus a fade, timed byduration(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-durationvia style, equal toduration. - Characters are counted with
Array.from(value).length, so CJK and emoji count as one each; nothing is truncated mid-composition — truncation tomaxLengthhappens when the IME composition ends, so Zhuyin / Pinyin input is never interrupted. - The built-in input's
onEnteris IME-safe: Enter during composition (e.g. Chinese input methods) does not fire it, including Safari's keydown that arrives aftercompositionend. - The
classNamepassed 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-describedbychains the description, the hint or error, and the counter - On error the control gets
aria-invalidand the error area isrole="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 getsrequiredandaria-required, and the render prop receivesaria-requiredtoo; 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
Radio Group
A React radio group: the selected dot springs into place, and in the card variant the selection outline slides between options, including on arrow-key changes. Supports controlled/uncontrolled use, vertical or horizontal layout, and form submission.
Combobox
A type-to-filter picker that never filters or selects mid-composition for CJK input, with an anchored list that flips at edges, a sliding highlight, and removable chips in multi-select mode.