Field
A form-field wrapper — label, inline label action, description, and error/success message, built on Base UI Field.
- Status
- Since
0.1.0- Accessibility pattern
- labelled field with description and error
Last updated
We'll never share your email.
Install
Add Field from the VegaStack registry. The CLI verifies the item's integrity hash before writing it.
pnpm dlx shadcn@latest add @vegastack/fieldThe same command installs the registry items it composes: @vegastack/input, @vegastack/use-animation-replay.
Usage
import { Field, FieldControl } from "@/components/ui/field";
<Field label="Email" description="We'll never share it.">
<FieldControl type="email" placeholder="you@vegastack.com" />
</Field>;Field is the ergonomic, prop-driven wrapper: pass label, description, error, or success and
it composes the parts for you. Built on Base UI Field,
which auto-wires aria-describedby and aria-invalid between the label, control, description, and
error — no manual ARIA wiring.
Anatomy
Field is a compound component. Every exported part, with the
data-slot it renders (generated from the canonical source):
Examples
Anatomy
Field is prop-driven by default, but the underlying primitives are exported for full control over
composition. Compose them inside FieldRoot:
import {
FieldRoot,
FieldLabel,
FieldControl,
FieldDescription,
FieldError,
} from "@/components/ui/field";
<FieldRoot>
<FieldLabel>Email</FieldLabel>
<FieldDescription>We'll never share it.</FieldDescription>
<FieldControl type="email" />
<FieldError match>Enter a valid email.</FieldError>
</FieldRoot>;FieldRoot— groups the parts and wires accessibility between them (data-slot="field"). Owns theorientationvariant. Renders a<div>.FieldLabel— accessible label, auto-associated with the control (data-slot="field-label"). Renders a<label>.FieldControl— the form control to label and validate (data-slot="field-control"). Renders an<input>; omit it and drop in any Base UI input/select/checkbox instead.FieldDescription— helper text linked viaaria-describedby(data-slot="field-description"). Renders a<p>, below the control.FieldError— validation message shown when the control is invalid (data-slot="field-error", polite atomicrole="status"). Renders a<div>, below the description.FieldSuccess— positive confirmation message (data-slot="field-success", polite atomicrole="status"). Renders a<p>.FieldGroup— stacks fields with consistent rhythm and provides the@containerthatorientation="responsive"responds to (data-slot="field-group"). Renders a<div>.FieldSet/FieldLegend— a semantic<fieldset>/<legend>pair grouping related fields; the set dims as a unit when disabled (data-slot="field-set"/"field-legend").FieldContent— the label + description column beside a control in horizontal/responsive fields (data-slot="field-content"). Renders a<div>.
Orientations
vertical (default) stacks the label above the control. horizontal places the control before an
inline label — for checkboxes and switches. responsive renders the label+description column beside
the control from the wrapping FieldGroup's @md container width, stacked below it.
States
An inline labelAction (e.g. a "Forgot?" link) sits end-aligned on the label row. error tints the
message destructive and sets aria-invalid; success confirms in success color; borderless
flattens the control for inline editing.
At least 8 characters.
That username is available.
Lowercase, no spaces.
Horizontal validation
In horizontal orientation the error/success message takes basis-full, so it wraps onto its
own row beneath the control and inline label instead of being squeezed into the flex row. This is the
layout you get when a required checkbox or switch fails validation.
You're subscribed.
Disabled
Pass disabled on Field to deactivate the whole field from one prop: it flows to the control and
dims the label and description via the group-has-disabled/field hook. Works in either orientation.
Assigned automatically — cannot be changed.
Borderless
borderless makes the resting border transparent and removes the background and shadow from the child control for inline editing. The
override map targets the input, textarea, and select-trigger slots alike, so the same prop
flattens any of them while keeping the focus border tint.
Form integration
Field works with react-hook-form via Controller. Base UI's
Field.Control emits onValueChange (not a raw onChange), and the invalid/touched/dirty
props let the validation library drive field state — Base UI handles the ARIA. This exact wiring is
covered by a browser test (field-form.test.tsx), so the contract below is type-checked and run.
react-hook-form(^7.80.0) and@hookform/resolvers(^5.4.0) are consumer-installed peers for this pattern —shadcn add @vegastack/fielddoes not pull them. Install them yourself:pnpm add react-hook-form @hookform/resolvers(zodtoo, if you don't already have it).
import { useForm, Controller } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Form } from "@base-ui/react/form";
import {
FieldRoot,
FieldLabel,
FieldControl,
FieldError,
} from "@/components/ui/field";
const schema = z.object({ email: z.email("Invalid email") });
function SignupForm() {
const { control, handleSubmit } = useForm({
resolver: zodResolver(schema),
defaultValues: { email: "" },
});
return (
<Form onSubmit={handleSubmit((d) => console.log(d))}>
<Controller
name="email"
control={control}
render={({
field: { name, ref, value, onBlur, onChange },
fieldState,
}) => (
<FieldRoot
name={name}
invalid={fieldState.invalid}
touched={fieldState.isTouched}
>
<FieldLabel>Email</FieldLabel>
<FieldControl
value={value}
onBlur={onBlur}
onValueChange={onChange}
ref={ref}
/>
<FieldError match={!!fieldState.error}>
{fieldState.error?.message}
</FieldError>
</FieldRoot>
)}
/>
<button type="submit">Sign up</button>
</Form>
);
}Playground
Try every orientation with a description, an error message, and the disabled state, then copy the generated JSX.
<Field label="Email">
<Input type="email" placeholder="you@vegastack.com" />
</Field>API Reference
Field
| Prop | Type | Default | Description |
|---|---|---|---|
children* | React.ReactNode | — | The field control(s) — an <Input>, <Checkbox>, FieldControl, etc. |
borderless | boolean | false | Strip the border, background, and shadow from the child control — for inline editing (titles, descriptions). The control keeps its focus border tint. |
description | React.ReactNode | — | Helper text rendered BELOW the control, above any error or success message, and linked to
the control via aria-describedby (audit D4). In orientation="responsive" it stays in the
label column, which is the whole point of that layout. |
error | React.ReactNode | — | Error message. When set (and invalid is not explicitly false), the field
is treated as invalid: the message shows in destructive color and the
control receives aria-invalid. |
label | React.ReactNode | — | The field label, rendered above (vertical) or beside (horizontal) the control and auto-associated with it for accessibility. |
labelAction | React.ReactNode | — | Inline action rendered on the same row as the label, end-aligned — e.g. a "Forgot password?" link. Vertical orientation only. |
orientation | "horizontal" | "responsive" | "vertical" | — | |
shakeSignal | unknown | — | Bump to a new value (e.g. a submit-attempt counter) to re-shake a field that is ALREADY invalid. The field shakes itself once, automatically, the moment it BECOMES invalid; this is only for a repeat failure against a field that never stopped being wrong. |
success | React.ReactNode | — | Positive confirmation message, rendered in success color below the control.
Ignored while error is present. |
Data attributes and CSS variables on Field
| Attribute | Values |
|---|---|
data-slot | "field-header" | "field-label-action" |
FieldRoot
| Prop | Type | Default | Description |
|---|---|---|---|
orientation | "horizontal" | "responsive" | "vertical" | — | |
shakeSignal | unknown | — | Bump to a new value (e.g. a submit-attempt counter) to re-shake a field that is ALREADY invalid. The field shakes itself once, automatically, the moment it BECOMES invalid; this is only for a repeat failure against a field that never stopped being wrong. |
Data attributes and CSS variables on FieldRoot
| Attribute | Values |
|---|---|
data-orientation | mirrors a prop or state value |
data-slot | "field" |
FieldLabel, FieldControl, FieldDescription, FieldError, FieldSuccess,
FieldGroup, FieldSet, FieldLegend, and FieldContent add no props of
their own — each accepts everything its underlying Base UI Field part or native
element accepts (className, ref, event handlers; FieldError additionally
takes Base UI's match to control when the message shows).
Accessibility
- Base UI Field auto-associates the label with the control and links the description and error via
aria-describedby— no manualhtmlFor/idwiring needed. - An
error(orinvalid) setsaria-invalidon the control and announces the message as a politerole="status"live region. Inline validation is a user-initiated result, not an interruption;alertis reserved for something that arrives without being asked for. - Helper text renders below the control, with the error or success message below that. Above the control it pushed the input away from its own label, and a wrapped description put two lines of prose between the two things the eye pairs.
Fieldowns the invalid shake: one observer on the field root, so every control it wraps reacts identically and the whole field moves as one block. It fires only on a live valid→invalid transition, so a form rendered with server-side errors does not shake on first paint. PassshakeSignal(a submit-attempt counter) to re-shake a field that never stopped being invalid.- On focus the control re-colors its border with the
ringtoken (focus:border-ring/(--alpha-tint-border)) — the border tint is the text-field focus treatment, and it stays even inborderlessmode. - The label is non-selectable and dims when the field is disabled.
| Key | Action |
|---|---|
| Tab | Move focus into the field control. |
| Enter | Submit the surrounding form (single-line controls). |
| Contract | States tested |
|---|---|
| Behaviour | default, disabled, error, invalid, success |
| Accessibility | described, invalid, labeled, alert-announcement, semantic-html |
| Visual | default, disabled, invalid, error, success |