Field React component for Tailwind CSS v4
v1.0.1Updated in 1.7.0
A React form field wrapper every control shares: label, hint, animated error and counter, fieldsets, and the box recipe all inputs use.
Click or tap a label and its control takes focus; a click on the box's padding, an icon or a suffix like USD also lands in the input without moving the caret. Tab moves from field to field and the whole box takes a soft ring; Enter in a field submits the form like a native one. Press Continue with a title under 10 characters and the error slides in under the field while the rest of the form glides down; type again and it slides away, and a hint swaps for the error with a crossfade instead of a jump. With a limit set, the counter turns amber at 90% and red at the limit, and screen readers hear how many characters are left. A read-only field keeps its text selectable but ignores typing, a disabled one dims with its label. Horizontal fields put the label beside the control once the field is 448px wide and stack it on top below that, so on a phone every label sits above its field and every input is 16px so iOS never zooms.
Field is a free React component in the React form components category, written in TypeScript for React 19 and Tailwind CSS v4. It installs as source with npx wingo-ui@latest add field, together with Button, Motion presets and UIProvider and the npm packages @radix-ui/react-slot, motion and lucide-react.
Add to a project
Requirements: React 19 and Tailwind CSS v4, in Next.js (App Router or Pages), Vite or React Router. Installation guide · Next.js setup · Vite setup
npx wingo-ui@latest add fieldUsage
Import Field from @/components/ui/field.
import type { ComponentProps } from "react"
import { FIELD_INPUT, Field, FieldAddon, FieldBox, FieldControl, useFieldControl } from "@/components/ui/field"
export function PriceField({ error }: { error?: string }) {
return (
<Field label="Price" description="The final price, tax included." error={error} required>
<FieldBox>
<FieldControl>
<input name="price" inputMode="decimal" placeholder="0" autoComplete="off" className={FIELD_INPUT} />
</FieldControl>
<FieldAddon>USD</FieldAddon>
</FieldBox>
</Field>
)
}
// your own control: one hook, and it works alone or inside a <Field>
export function PlainInput({ label, error, ...props }: ComponentProps<"input"> & { label?: string; error?: string }) {
const field = useFieldControl({ label, error })
return field.wrap(
<FieldBox invalid={field.invalid} disabled={field.disabled}>
<input {...props} {...field.controlProps} className={FIELD_INPUT} />
</FieldBox>,
)
}Props
labeldefault""ReactNodeThe label, linked to the control with htmlFor (or aria-labelledby for groups). Leave it empty for a bare control.
descriptiondefault""ReactNodeHelper text under the control, linked with aria-describedby. The error takes its place while it shows.
errordefault""ReactNodeReplaces the description with an alert icon and the text, slides in and marks the field invalid. "", false and null are no error; true marks it invalid without text (prefer invalid for that).
requireddefaultfalsebooleanShows the required mark after the label (hidden from screen readers) and tells the control to set required.
optionaldefaultfalsebooleanShows optionalLabel after the label, for forms that mark the few optional fields instead of the many required ones.
optionalLabeldefault"Optional"stringThe word after an optional label.
requiredIndicatordefault"*"ReactNodeThe mark after a required label.
labelActionReactNodeA link or small button at the end of the label row, like Forgot your password?. It sits outside the <label> and gets a 44px hit area on touch.
hideLabeldefaultfalsebooleanKeeps the label for screen readers only, e.g. a search field whose icon already says what it is.
invaliddefaultfalsebooleanThe red ring and aria-invalid without a message, for forms that show errors elsewhere. Passed to the control through context.
disableddefaultfalsebooleanDims the label and, through context, disables the control. A disabled FieldGroup disables every field inside.
readOnlydefaultfalsebooleanThe value stays visible and selectable but cannot change; the box drops its hover tint. Passed to the control through context.
sizedefault"md""sm" | "md" | "lg"Label size (13 / 14 / 15px) and the box height horizontal labels align to. Controls take their own size; the demo passes the same to every box.
orientationdefault"vertical""vertical" | "horizontal"horizontal puts the label in a column beside the control once the field itself is 448px wide (a container query, not the viewport) and stacks below that. Inherits from a FieldGroup.
labelWidthdefault160numberWidth of the label column in horizontal fields, in px. Inherits from a FieldGroup.
countdefault0numberThe current length, for the counter. Controls with showCount pass it for you. In the playground 0 follows what you type; any other value previews a counter state.
maxLengthdefault0numberShows count / max at the end of the message row, amber from 90% and red at the limit. 0 hides the counter. The demo also sets it on the input.
counterLabeldefault"(remaining) => `${remaining} characters left`"(remaining: number) => stringWhat screen readers hear once 10% or less is left, 500ms after typing stops (negative past the limit). The visible counter is hidden from them.
idstringThe control's id (not the wrapper's). Defaults to useId(); children read it from context.
labelBehaviordefault"native""native" | "focus"focus: a label click focuses the control instead of clicking it, so a label never opens a picker. Button triggers set it through useFieldControl. The label carries it as data-behavior.
labelAsdefault"label""label" | "span"span for group controls (radio group, checkbox group, rating, a two-thumb slider), which point at it with aria-labelledby (FieldControl adds it). Clicking it focuses the group's current stop.
childrenReactNodeThe control. Inside a Field, library controls read the id, flags and messages from context; do not give them their own label (the Field's wins, with a dev warning).
classNamesPartial<Record<"labelRow" | "label" | "required" | "optional" | "labelAction" | "message" | "description" | "error" | "counter", string>>Extra classes for the inner parts.
classNamestringClasses for the root. The rest of the props (style, data-*, aria-*) land there too.
Parts
FieldGroup
componentA real <fieldset> with a <legend>, so screen readers announce the section before its fields. legend, description, error, columns (1 | 2 | 3 | 4: one column on phones, 2 from sm, 3 and 4 from md; span with sm:col-span-2), variant ("plain" | "card"), orientation and labelWidth (inherited by every field inside), gap ("sm" | "md" | "lg"), disabled (the native attribute plus context), required and requiredIndicator (a mark after the legend), classNames (legend, description, error, content). Props: FieldGroupProps.
FieldBox
componentThe box every text field and trigger uses: variant ("soft" | "outline" | "underline" | "ghost"), size (inherits the Field's), radius, multiline, invalid / disabled / readOnly (default to the Field's), state ("open" | "closed" for pickers), asChild (a trigger button or a native select), focusOnPointerDown (default true: a press on the padding or an addon focuses the input inside), data-slot. Props: FieldBoxProps.
FieldControl
componentWraps any single child (a native input or select, a range, a third-party control) and puts the Field's id, aria-describedby, aria-invalid and aria-required on it, plus disabled and readOnly while the Field has them. The child's own props win. Props: SlotProps.
FieldAddon
componentText addons like "https://" or "USD" in the muted addon look; elements render as given and bare icons turn muted. Takes span props.
FieldAction
componentThe 28px trailing icon button (32px in lg): label (required, its aria-label and title), icon, size (defaults to the Field's), keepFocus (default true, the input keeps focus while it is pressed), plus every Button prop. 44px hit area on touch. Props: FieldActionProps.
FieldLabel
componentThe label on its own: as ("label" | "span"), behavior ("native" | "focus"), size, required, optional, optionalLabel, requiredIndicator, disabled. Reads its target, size and flags from the nearest Field; every prop overrides. Props: FieldLabelProps.
FieldDescription
componentHelper text in the caption style, with the Field's description id so aria-describedby finds it. Takes p props.
FieldError
componentThe error text with its alert icon (icon replaces it, null hides it), with the Field's error id. Props: ComponentProps<"p"> & { icon?: ReactNode }.
FieldCounter
componentThe visible "count / max", hidden from screen readers, with data-state="ok | near | over" (amber from 90%, red at the limit). Props: ComponentProps<"span"> & { count?: number; max: number }.
FieldMessage
componentThe animated row under a control: description or error (never both, crossfading) plus the counter, inside a polite live region. Opens and closes with the collapse preset and works outside a Field too, e.g. under a checkbox. Props: FieldMessageProps.
useFieldControl
hookThe hook every control calls once. Merges its props with a parent Field or FieldGroup and returns id, labelId, describedBy, invalid, disabled, required, readOnly, size (the control's, else the Field's), orientation, controlProps (for a native element), ariaProps (for anything else), isWrapped and wrap(control), which renders the Field around the control when it has its own label, description, error or counter. Signature: (options: FieldControlOptions) => FieldControlState.
useFieldContext
hookThe nearest Field's ids and flags (id, labelId, describedBy, invalid, disabled, required, readOnly, size, orientation), or null outside one. For custom controls. Signature: () => FieldContextValue | null.
useFormReset
hookCalls onReset after the element's form is reset, once the form has put its fields back, so uncontrolled controls return to their defaultValue. Signature: (ref: RefObject<HTMLElement | null>, onReset: () => void) => void.
fieldBoxClasses
functionThe box classes for any element (a trigger button, a custom wrapper). Unknown values fall back to soft / md / md. Signature: (options: { variant?, size?, radius?, multiline?, className? }) => string.
FIELD_BOX
constantThe base class recipe of the field box. Import it; never retype the classes.
FIELD_VARIANTS
constantThe box classes per variant (soft, outline, underline, ghost).
FIELD_SIZES
constantThe box height and padding classes per size.
FIELD_MULTILINE_SIZES
constantThe box padding classes per size for multiline controls (textareas).
FIELD_RADII
constantThe box radius classes per size and radius.
FIELD_INPUT
constantThe classes of the bare input inside the box.
FIELD_ADDON
constantThe muted addon classes (text like "https://" or "USD").
FIELD_TRIGGER
constantThe classes of a picker trigger button styled as a field.
FIELD_CHEVRON
constantThe classes of the trailing chevron on picker triggers.
FIELD_HEIGHT_VAR
constantThe CSS variable that carries the box height, for parts that line up with it.
Examples
Validation on blur
Leave the email field and its error slides in while the form glides down; type a capital letter in the username and the hint crossfades into the error.
Horizontal settings
orientation on a card FieldGroup: labels move into a 136px column once a field is 448px wide and stack on top below that, as here in a narrow column or on a phone.
Two columns
columns={2}: one column on phones, two from 640px; the street spans both. The state is a native select in FieldBox asChild.
Box variants and sizes
soft, outline, underline and ghost at 32, 40 and 48px, each with an icon and a suffix.
Counter
12 / 70, 66 / 70 in amber from 90%, 70 / 70 in red at the limit; type to move between them. Screen readers hear the count for the last 10%.
Label action and optional
A link at the end of the label row, a FieldAction that reveals the password, and an optional mark.
Any control, and a disabled group
FieldControl wires ids and aria onto a native range; untick the checkbox and the disabled FieldGroup below comes back to life.
Hidden label
hideLabel keeps the name for screen readers in a toolbar search with a ghost box.
Files
- components/ui/field.tsx
FAQ
Is the React Field component free?
Yes. The source is free and needs no account: copy it from the Code tab or install it with the CLI.
How do I install Field?
Run npx wingo-ui@latest add field. It also adds the Wingo UI items it builds on (Button, Motion presets and UIProvider) and the npm packages @radix-ui/react-slot, motion and lucide-react. With shadcn, run npx shadcn@latest add https://wingo-ui.com/r/field.json.
How does Field behave on phones?
Horizontal fields put the label beside the control once the field is 448px wide and stack it on top below that, so on a phone every label sits above its field and every input is 16px so iOS never zooms.
See also
Used in
More React form components
Articles using this component
- React Country Select With Flags, Search and Dial Codes
Component guides9 min read
- Shadcn Number Input: Steppers, Min, Max and Decimals
Component guides8 min read
- Nextjs Contact Form That Sends Email and Blocks Spam
Build it: screens and apps10 min read
- Shadcn Password Input: Show/Hide, Strength Meter, Caps Lock
Component guides9 min read