Skip to content
Component installs need the registry setup
VegaStack Design

Field

The form-field scaffold — label, description, error, legend, separator and choice-card layouts in vertical, horizontal or responsive orientation.

Status
stable
Since
0.1.0
Accessibility pattern
role=group with fieldset and legend

Last updated

Profile

This appears on invoices and emails.

This appears on invoices and emails.

Install

Configure registry credentials first using the Quickstart. Before copying this item or any transitive registry dependency, verify the signed manifest and save the exact bytes for Field before installation. Retain the digest printed by this command.

pnpm exec vegastack-design verify field

Install with the pinned shadcn CLI. Bare shadcn does not verify VegaStack signature or integrity.

pnpm dlx shadcn@4.21.0 add @vegastack/field

Run the exact offline --post-write command printed by the preflight, using its saved item path and independently retained --expected-integrity digest. Use the saved preflight for every transitive registry dependency too; stop on any mismatch before using the components.

The same command installs the registry items it composes: @vegastack/label, @vegastack/separator.

Usage

import {
  Field,
  FieldDescription,
  FieldError,
  FieldGroup,
  FieldLabel,
  FieldLegend,
  FieldSet,
} from "@/components/ui/field";

<FieldSet>
  <FieldLegend>Profile</FieldLegend>
  <FieldGroup>
    <Field>
      <FieldLabel htmlFor="name">Full name</FieldLabel>
      <Input id="name" />
      <FieldDescription>This appears on invoices.</FieldDescription>
    </Field>
  </FieldGroup>
</FieldSet>;
Profile

This appears on invoices and emails.

This appears on invoices and emails.

Anatomy

Field — data-slot="field"
FieldContent — data-slot="field-content"
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"
FieldSeparator — data-slot="field-separator" | "field-separator-content"
FieldSet — data-slot="field-set"
FieldTitle — data-slot="field-title"
FieldHeader — data-slot="field-header"
FieldAction — data-slot="field-action"
FieldCounter — data-slot="field-counter-status"

A field is a label, a control, optional helper text and an optional error, in that order. FieldContent is the flex column that keeps a label and its description together when the control sits beside them; it is not needed when there is no description.

Optional helper text.

Examples

Composition

FieldGroup
├── Field
│   ├── FieldLabel
│   ├── Input / Textarea / Switch / Select
│   ├── FieldDescription
│   └── FieldError
├── FieldSeparator
└── Field

Field · FieldLabel · Input.

FieldGroup · FieldSeparator · Field.

Form

Field is the scaffold every form is built from: one FieldGroup, one Field per control, and a horizontal Field for the actions.

We only use it to sign you in.

Input

Choose a unique username for your account.

Must be at least 8 characters long.

Textarea

Share your thoughts about our service.

Select

Select your department or area of work.

Slider

Price Range

Set your budget range ($200 – 800).

Fieldset

FieldSet renders a real <fieldset>; FieldLegend its <legend>.

Address Information

We need your address to deliver your order.

Checkbox

Show these items on the desktop

Select the items you want to show on the desktop.

Radio

Notify me about

Switch

Ask for a code from your authenticator app on every sign-in.

Choice Card

Wrapping a Field in a FieldLabel turns the whole block into a clickable card, which tints when the control inside it is checked. It works with RadioGroupItem, Checkbox and Switch.

Compute Environment

Select the compute environment for your cluster.

Field Group

FieldGroup stacks fields; FieldSeparator divides them.

Get notified when a long-running request finishes.

Get notified when tasks you have created have updates.

RTL

Every gap and inset is logical, so a right-to-left form mirrors without any per-field work.

Profile

Shown on invoices.

الملف الشخصي

يظهر على الفواتير.

Responsive Layout

  • Vertical — the default: label, control, helper text, stacked.
  • Horizontal — orientation="horizontal" puts the label and the control on one row; pair it with FieldContent to keep descriptions aligned.
  • Responsive — orientation="responsive" switches between the two on the @md/field-group container query, so the layout follows the form's own width rather than the viewport's.
Profile

Fill in your profile information.

Provide your full name for identification.

Validation and Errors

Put data-invalid on the Field to switch the whole block into its error state; the control inside it is marked aria-invalid for you. FieldError takes children, or an errors array it de-duplicates into a list.

Footnote

variant="footnote" renders a centred 12px muted line with a small gap above. Use it for the footer line under a form's submit button, such as the auth blocks' "Don’t have an account?" or "Access is managed by your administrator."

<Field>
  <Button type="submit">Sign in</Button>
  <FieldDescription variant="footnote">
    Access is managed by your administrator.
  </FieldDescription>
</Field>

Header action

FieldHeader puts a field-level action on the label row: the FieldLabel (or a FieldContent of label and description) at the start and a FieldAction at the end, aligned on the label's baseline. The row wraps on narrow widths with the action kept at the end, and the action's negative block margin keeps a size="sm" button from making the row taller than the label. Use it for an action on the whole field, such as "Add more" on an image list or "Use template" on a textarea; the control's own actions stay inside the control.

<Field>
  <FieldHeader>
    <FieldLabel>Images</FieldLabel>
    <FieldAction>
      <Button variant="ghost" size="sm">
        <PlusIcon aria-hidden />
        Add more
      </Button>
    </FieldAction>
  </FieldHeader>
  <FieldDescription>
    3 of 10 images · The first image is the thumbnail.
  </FieldDescription>
</Field>

3 of 10 images · The first image is the thumbnail.

Character counter

FieldCounter counts a text field's characters against its maxLength. It sits at the end of the label row (FieldHeader), on the label's line, so it takes no room under the control, and it stays hidden until the value nears the limit — from 80% of maxLength by default, or the share threshold names — then reads 92/100 in small muted figures, and turns destructive at the limit. Pass the same maxLength to the control so the browser enforces it. Inside a Field the counter is a field description, so the Input or Textarea is described by it while it shows; a screen reader hears it only when it crosses a threshold ("8 characters left", "Character limit reached"), never on every keystroke.

<Field>
  <FieldHeader>
    <FieldLabel htmlFor="bio">Bio</FieldLabel>
    <FieldCounter value={bio} maxLength={100} />
  </FieldHeader>
  <Textarea
    id="bio"
    maxLength={100}
    value={bio}
    onChange={(event) => setBio(event.target.value)}
  />
</Field>
84 of 100 characters

Automatic wiring

Inside a Field, the control needs no ids and no aria-* props. Field renders Base UI's Field underneath, so the control is labelled by FieldLabel, described by the FieldDescription and FieldError that are rendered right now, and marked aria-invalid when the Field carries data-invalid. An explicit id or htmlFor still wins, and an explicit aria-describedby keeps its ids first, with the Field's rendered message ids after it. While the Field is invalid its control is aria-invalid, whatever the control's own prop says. A Slider takes its label and messages on its thumb from the Field; its own id and aria-describedby stay on the slider root, as Base UI's Slider places them. A control outside a Field gets no Field wiring. Put one control in each Field: a second control inside the same Field would take the same label and messages.

Shown on invoices.

Up to 40 characters.

States

The three orientations plus the invalid and disabled blocks in one frame.

The default orientation.

The whole block dims.

Playground

Try every orientation with a description, an error message and the disabled state, then copy the generated JSX.

<Field>
  <FieldLabel htmlFor="email">Email</FieldLabel>
  <Input id="email" type="email" placeholder="you@vegastack.com" />
</Field>

API Reference

PropTypeDefaultDescription
data-invalid"false" | "true" | boolean—
orientation"horizontal" | "responsive" | "vertical" | null—
PropTypeDefaultDescription
variant"label" | "legend"—
PropTypeDefaultDescription
errors({ message?: string; } | undefined)[]—
PropTypeDefaultDescription
maxLength*number—The limit — the same number as the control's own maxLength.
value*string—The field's current value; its length is counted the way maxLength counts it.
thresholdnumber0.8The share of maxLength at which the counter appears, from 0 to 1. Below it nothing shows.

FieldSet, FieldGroup, FieldContent, FieldLabel, FieldTitle, FieldDescription and FieldSeparator add no props of their own — each accepts everything the underlying element accepts.

Accessibility

  • FieldSet and FieldLegend render a real <fieldset>/<legend>, so a screen reader announces the group's name before every control inside it.
  • Field is a role="group" on Base UI's Field, so the control inside it is labelled by FieldLabel, described by the rendered FieldDescription and FieldError, and marked aria-invalid from the Field's data-invalid — with no ids to keep in sync (API-26).
  • FieldError is role="alert" and carries a leading icon, so an error is never signalled by colour alone (A11Y-8), and its ink is the destructive family's -text role, which clears AA on the page (A11Y-13).
  • The choice card carries no focus ring: the control inside it wears the global 2px outline instead (FOC-12), so focus is always on the thing that actually takes it.
  • FieldCounter is a field description while it shows, so the control's aria-describedby names it, and its polite status line speaks only when it crosses a threshold — near the limit and at it — never per keystroke. At the limit its ink is the destructive family's -text role.
  • Use FieldSeparator sparingly — each one is a section boundary a screen reader has to cross.
Shipping speed

The legend names the group; each label names its own control.

ContractStates tested
Behaviourdefault, invalid, disabled, checked
Accessibilitynative-or-base-ui-semantics, browser-accessibility-test, grouped, invalid, disabled
Visualdefault, invalid, disabled, checked

Do / Don't

Do
Put one control, its label, its description and its error inside one Field, and let it wire them.
Don't
Use a Field's description to carry the error — FieldError is role=alert and the description is not.

Deviations

Upstream's file plus packages/ui/upstream/patches/field.patch. Every hunk:

  • FOC-1, FOC-3, FOC-6, FOC-12 — the choice card's has-[>[data-slot=field]]:has-[:focus-visible]:border-ring plus ring-3 ring-ring/50 is removed outright. Field restates no focus chrome of its own: a text control inside it darkens its own border subtly on focus and paints no fill (FOC-3, FOC-14's text-entry exception, MK 2026-09-27), and a checkbox, radio or switch inside it wears the global :focus-visible background tint (FOC-12). The card's own border never changes colour on focus (FOC-14).
  • A11Y-8, A11Y-13, COL-12 — FieldError gains a leading CircleAlertIcon so the error is not signalled by colour alone, and its ink becomes text-destructive-text, the destructive family's text role. Upstream's text-destructive is the fill, which measures under AA as ink. Field's own data-[invalid=true]:text-destructive moves to the same role.
  • API-26 — Field, FieldLabel, FieldDescription and FieldError render Base UI's Field.Root, Field.Label, Field.Description and Field.Error inside a Field, and data-invalid also sets Base UI's invalid, so the control reads its label, description ids and invalid state from the Field. Outside a Field each part renders upstream's plain element. FieldTitle takes its own field-title slot, so the Base UI label is the one field-label.
  • DOC-2 — cn is imported from @vegastack/design.
  • COL-24, INT-4 — hover and press come from the shared state-layer utility, a translucent foreground layer (--state-hover, --state-pressed) over whatever fill is there, so the step reads the same on every surface in both themes; selected, open and highlighted fills are bg- state-selected / bg-state-hover. Operator, Regent, 2026-10-31.

On this page