Skip to content
Component installs need the registry setup— the Base UI shadcn project, the @vegastack namespace and the Cloudflare Access service token.
VegaStack Design

Field

A form-field wrapper — label, inline label action, description, and error/success message, built on Base UI Field.

Status
stable
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/field

The 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):

Field — data-slot="field-header" | "field-label-action"
FieldContent — data-slot="field-content"
FieldControl — data-slot="field-control"
FieldDescription — data-slot="field-description"
FieldError — data-slot="field-error"
FieldGroup — data-slot="field-group"
FieldLabel — data-slot="field-label"
FieldLegend — data-slot="field-legend"
FieldRoot — data-slot="field"
FieldSet — data-slot="field-set"
FieldSuccess — data-slot="field-success"

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 the orientation variant. 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 via aria-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 atomic role="status"). Renders a <div>, below the description.
  • FieldSuccess — positive confirmation message (data-slot="field-success", polite atomic role="status"). Renders a <p>.
  • FieldGroup — stacks fields with consistent rhythm and provides the @container that orientation="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.

Enter a valid email address.
Forgot?

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 must accept the terms to continue.

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/field does not pull them. Install them yourself: pnpm add react-hook-form @hookform/resolvers (zod too, 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

PropTypeDefaultDescription
children*React.ReactNodeThe field control(s) — an <Input>, <Checkbox>, FieldControl, etc.
borderlessbooleanfalseStrip the border, background, and shadow from the child control — for inline editing (titles, descriptions). The control keeps its focus border tint.
descriptionReact.ReactNodeHelper 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.
errorReact.ReactNodeError 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.
labelReact.ReactNodeThe field label, rendered above (vertical) or beside (horizontal) the control and auto-associated with it for accessibility.
labelActionReact.ReactNodeInline action rendered on the same row as the label, end-aligned — e.g. a "Forgot password?" link. Vertical orientation only.
orientation"horizontal" | "responsive" | "vertical"
shakeSignalunknownBump 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.
successReact.ReactNodePositive confirmation message, rendered in success color below the control. Ignored while error is present.

Data attributes and CSS variables on Field

AttributeValues
data-slot"field-header" | "field-label-action"

FieldRoot

PropTypeDefaultDescription
orientation"horizontal" | "responsive" | "vertical"
shakeSignalunknownBump 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

AttributeValues
data-orientationmirrors 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 manual htmlFor/id wiring needed.
  • An error (or invalid) sets aria-invalid on the control and announces the message as a polite role="status" live region. Inline validation is a user-initiated result, not an interruption; alert is 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.
  • Field owns 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. Pass shakeSignal (a submit-attempt counter) to re-shake a field that never stopped being invalid.
  • On focus the control re-colors its border with the ring token (focus:border-ring/(--alpha-tint-border)) — the border tint is the text-field focus treatment, and it stays even in borderless mode.
  • The label is non-selectable and dims when the field is disabled.
KeyAction
TabMove focus into the field control.
EnterSubmit the surrounding form (single-line controls).
ContractStates tested
Behaviourdefault, disabled, error, invalid, success
Accessibilitydescribed, invalid, labeled, alert-announcement, semantic-html
Visualdefault, disabled, invalid, error, success

Do / Don't

Do
Pair every control with a Field so it gets a label, description, and an accessible error.
Don't
Hand-wire htmlFor/aria-describedby/aria-invalid — Field does it for you via Base UI.

On this page