Skip to content

Field

The layout system for forms. Labels, controls, descriptions and errors, arranged consistently and accessibly at any width.

SourceRegistry
import {
  Field,
  FieldContent,
  FieldDescription,
  FieldGroup,
  FieldLabel,
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Switch } from "@/components/ui/switch"

export default function FieldDemo() {
  return (
    <FieldGroup className="w-72">
      <Field>
        <FieldLabel htmlFor="field-demo-name">Workspace name</FieldLabel>
        <Input id="field-demo-name" defaultValue="Acme Research" />
        <FieldDescription>Visible to everyone you invite.</FieldDescription>
      </Field>
      <Field orientation="horizontal">
        <FieldContent>
          <FieldLabel htmlFor="field-demo-sso">Require SSO</FieldLabel>
        </FieldContent>
        <Switch id="field-demo-sso" defaultChecked />
      </Field>
    </FieldGroup>
  )
}

Installation

$ pnpm dlx shadcn@latest add https://www.prfct.dev/r/field.json

Usage

import {
  Field,
  FieldDescription,
  FieldError,
  FieldGroup,
  FieldLabel,
  FieldLegend,
  FieldSet,
} from "@/components/ui/field"
<FieldSet>
  <FieldLegend>Profile</FieldLegend>
  <FieldGroup>
    <Field>
      <FieldLabel htmlFor="name">Name</FieldLabel>
      <Input id="name" />
      <FieldDescription>Shown on your public profile.</FieldDescription>
    </Field>
  </FieldGroup>
</FieldSet>

Field works with every prfct control — Input, Textarea, Select, Checkbox, Radio Group, Switch, Slider and more — so every form in a product shares one rhythm.

Anatomy

  1. 1LabelNames the control. Always visible; associated with htmlFor.
  2. 2DescriptionOptional guidance: format, limits, consequences.
  3. 3ControlAny prfct form control — here an Input.
PartRole
FieldSet / FieldLegendA native <fieldset> and its caption. Groups related controls and names the group for assistive technology.
FieldGroupVertical rhythm between fields — 20px by default, 12px for checkbox groups. Also a container-query root for responsive fields.
FieldOne control with its label, description and error. role="group", laid out vertically, horizontally or responsively.
FieldContentStacks a label and description next to a control in horizontal fields.
FieldLabel / FieldTitleThe control's label; FieldTitle is a non-label heading for choice cards.
FieldDescriptionHelper text below the control.
FieldErrorThe validation message, with an icon, announced as an alert.
FieldSeparatorA divider between sections, with an optional centered label.

Examples

Form

A complete form: a FieldSet with a legend and description, a two-column row for name fields, a select, a textarea, a separator and the actions. Everything inherits the same spacing without a single margin class.

Profile
"use client"

import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import {
  Field,
  FieldDescription,
  FieldGroup,
  FieldLabel,
  FieldLegend,
  FieldSeparator,
  FieldSet,
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import {
  Select,
  SelectContent,
  SelectGroup,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select"
import { Textarea } from "@/components/ui/textarea"

const roles = [
  { label: "Design", value: "design" },
  { label: "Engineering", value: "engineering" },
  { label: "Product", value: "product" },
  { label: "Research", value: "research" },
]

export default function FieldForm() {
  return (
    <form
      className="w-full max-w-lg"
      onSubmit={(event) => event.preventDefault()}
    >
      <FieldGroup>
        <FieldSet>
          <FieldLegend>Profile</FieldLegend>
          <FieldDescription>
            This is how teammates see you across the workspace.
          </FieldDescription>
          <FieldGroup>
            <div className="grid gap-5 sm:grid-cols-2">
              <Field>
                <FieldLabel htmlFor="field-form-first">First name</FieldLabel>
                <Input
                  id="field-form-first"
                  autoComplete="given-name"
                  defaultValue="Ada"
                />
              </Field>
              <Field>
                <FieldLabel htmlFor="field-form-last">Last name</FieldLabel>
                <Input
                  id="field-form-last"
                  autoComplete="family-name"
                  defaultValue="Lovelace"
                />
              </Field>
            </div>
            <Field>
              <FieldLabel htmlFor="field-form-team">Team</FieldLabel>
              <Select items={roles} defaultValue="engineering">
                <SelectTrigger id="field-form-team" className="w-full">
                  <SelectValue />
                </SelectTrigger>
                <SelectContent>
                  <SelectGroup>
                    {roles.map((role) => (
                      <SelectItem key={role.value} value={role.value}>
                        {role.label}
                      </SelectItem>
                    ))}
                  </SelectGroup>
                </SelectContent>
              </Select>
            </Field>
            <Field>
              <FieldLabel htmlFor="field-form-about">About</FieldLabel>
              <Textarea
                id="field-form-about"
                placeholder="What are you working on?"
              />
              <FieldDescription>Markdown is supported.</FieldDescription>
            </Field>
          </FieldGroup>
        </FieldSet>
        <FieldSeparator />
        <Field orientation="horizontal">
          <Checkbox id="field-form-public" defaultChecked />
          <FieldLabel htmlFor="field-form-public" className="font-normal">
            Show my profile in the member directory
          </FieldLabel>
        </Field>
        <Field orientation="horizontal" className="justify-end">
          <Button type="button" variant="outline">
            Cancel
          </Button>
          <Button type="submit">Save profile</Button>
        </Field>
      </FieldGroup>
    </form>
  )
}

Validation

Mark the Field with data-invalid, the control with aria-invalid, and render a FieldError linked through aria-describedby. Pass a list to errors to show several rules at once. Submit this form to see it in action. The example re-checks only on submit.

"use client"

import * as React from "react"

import { Button } from "@/components/ui/button"
import {
  Field,
  FieldDescription,
  FieldError,
  FieldGroup,
  FieldLabel,
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"

type Errors = { email?: string; password?: { message: string }[] }

function validate(data: FormData): Errors {
  const email = String(data.get("email") ?? "")
  const password = String(data.get("password") ?? "")
  const errors: Errors = {}

  if (!/^\S+@\S+\.\S+$/.test(email)) {
    errors.email = "Enter an email address like name@company.com."
  }
  const rules = [
    password.length < 12 && { message: "Use at least 12 characters." },
    !/\d/.test(password) && { message: "Include at least one number." },
  ].filter(Boolean) as { message: string }[]
  if (rules.length) errors.password = rules

  return errors
}

export default function FieldValidation() {
  const [errors, setErrors] = React.useState<Errors>({})

  return (
    <form
      noValidate
      className="w-full max-w-sm"
      onSubmit={(event) => {
        event.preventDefault()
        setErrors(validate(new FormData(event.currentTarget)))
      }}
    >
      <FieldGroup>
        <Field data-invalid={errors.email ? true : undefined}>
          <FieldLabel htmlFor="field-validation-email">Email</FieldLabel>
          <Input
            id="field-validation-email"
            name="email"
            type="email"
            defaultValue="ada@"
            aria-invalid={errors.email ? true : undefined}
            aria-describedby={
              errors.email ? "field-validation-email-error" : undefined
            }
          />
          {errors.email && (
            <FieldError id="field-validation-email-error">
              {errors.email}
            </FieldError>
          )}
        </Field>
        <Field data-invalid={errors.password ? true : undefined}>
          <FieldLabel htmlFor="field-validation-password">Password</FieldLabel>
          <Input
            id="field-validation-password"
            name="password"
            type="password"
            defaultValue="hunter"
            aria-invalid={errors.password ? true : undefined}
            aria-describedby="field-validation-password-help"
          />
          {errors.password ? (
            <FieldError
              id="field-validation-password-help"
              errors={errors.password}
            />
          ) : (
            <FieldDescription id="field-validation-password-help">
              12+ characters, including a number.
            </FieldDescription>
          )}
        </Field>
        <Button type="submit" className="w-full">
          Create account
        </Button>
      </FieldGroup>
    </form>
  )
}
<Field data-invalid>
  <FieldLabel htmlFor="email">Email</FieldLabel>
  <Input id="email" aria-invalid aria-describedby="email-error" />
  <FieldError id="email-error">Enter an email address like name@company.com.</FieldError>
</Field>
Why both attributes?
aria-invalid on the control is what assistive technology reads and what turns the border red. data-invalid on the Field is a styling and testing hook for the whole field — use it to adjust surrounding elements without reaching into the control.

Horizontal

Settings pages read best as rows: the label and description on the left, the control on the right. Use orientation="horizontal" with a FieldContent wrapper.

import {
  Field,
  FieldContent,
  FieldDescription,
  FieldGroup,
  FieldLabel,
  FieldSeparator,
} from "@/components/ui/field"
import { Switch } from "@/components/ui/switch"

const settings = [
  {
    id: "digest",
    label: "Weekly digest",
    description: "A summary of activity in your projects, every Monday.",
    checked: true,
  },
  {
    id: "mentions",
    label: "Mentions",
    description: "Notify me when someone @mentions me in a comment.",
    checked: true,
  },
  {
    id: "marketing",
    label: "Product updates",
    description: "Occasional news about features and improvements.",
    checked: false,
  },
]

export default function FieldHorizontal() {
  return (
    <FieldGroup className="w-full max-w-md gap-4">
      {settings.map((setting, index) => (
        <div key={setting.id} className="contents">
          {index > 0 && <FieldSeparator />}
          <Field orientation="horizontal">
            <FieldContent>
              <FieldLabel htmlFor={`field-horizontal-${setting.id}`}>
                {setting.label}
              </FieldLabel>
              <FieldDescription>{setting.description}</FieldDescription>
            </FieldContent>
            <Switch
              id={`field-horizontal-${setting.id}`}
              defaultChecked={setting.checked}
            />
          </Field>
        </div>
      ))}
    </FieldGroup>
  )
}

Choice cards

Wrap a whole Field in a FieldLabel and it becomes a selectable card. The entire card is the click target, and the checked state tints it with the brand color. Works with radio groups and checkboxes alike.

Plan
import {
  Field,
  FieldContent,
  FieldDescription,
  FieldLabel,
  FieldLegend,
  FieldSet,
  FieldTitle,
} from "@/components/ui/field"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"

const plans = [
  {
    value: "starter",
    title: "Starter",
    description: "For individuals. 3 projects, community support.",
    price: "$0",
  },
  {
    value: "team",
    title: "Team",
    description: "For small teams. Unlimited projects and roles.",
    price: "$12",
  },
  {
    value: "enterprise",
    title: "Enterprise",
    description: "SSO, audit logs and a dedicated contact.",
    price: "Custom",
  },
]

export default function FieldChoiceCards() {
  return (
    <FieldSet className="w-full max-w-md">
      <FieldLegend variant="label">Plan</FieldLegend>
      <RadioGroup defaultValue="team">
        {plans.map((plan) => (
          <FieldLabel key={plan.value} htmlFor={`field-plan-${plan.value}`}>
            <Field orientation="horizontal">
              <RadioGroupItem
                value={plan.value}
                id={`field-plan-${plan.value}`}
              />
              <FieldContent>
                <FieldTitle>{plan.title}</FieldTitle>
                <FieldDescription>{plan.description}</FieldDescription>
              </FieldContent>
              <span className="text-sm font-medium text-foreground tabular-nums">
                {plan.price}
              </span>
            </Field>
          </FieldLabel>
        ))}
      </RadioGroup>
    </FieldSet>
  )
}

Checkbox group

Group related checkboxes in a FieldSet with a FieldLegend — screen readers announce the legend when focus enters the group. Give the inner FieldGroup data-slot="checkbox-group" for tighter spacing.

Incident alerts
import { Checkbox } from "@/components/ui/checkbox"
import {
  Field,
  FieldDescription,
  FieldGroup,
  FieldLabel,
  FieldLegend,
  FieldSet,
} from "@/components/ui/field"

const channels = [
  { id: "email", label: "Email", checked: true },
  { id: "push", label: "Push notifications", checked: true },
  { id: "sms", label: "SMS", checked: false },
  { id: "slack", label: "Slack", checked: false },
]

export default function FieldCheckboxGroup() {
  return (
    <FieldSet className="w-full max-w-xs">
      <FieldLegend variant="label">Incident alerts</FieldLegend>
      <FieldDescription>
        Where should we reach you when a monitor fails?
      </FieldDescription>
      <FieldGroup data-slot="checkbox-group">
        {channels.map((channel) => (
          <Field key={channel.id} orientation="horizontal">
            <Checkbox
              id={`field-channel-${channel.id}`}
              name="channels"
              value={channel.id}
              defaultChecked={channel.checked}
            />
            <FieldLabel
              htmlFor={`field-channel-${channel.id}`}
              className="font-normal"
            >
              {channel.label}
            </FieldLabel>
          </Field>
        ))}
      </FieldGroup>
    </FieldSet>
  )
}

Responsive

orientation="responsive" stacks label and control in narrow containers and places them side by side once the surrounding FieldGroup is wider than the md container breakpoint. It responds to its container, not the viewport, so the same form works in a dialog and on a full page.

import {
  Field,
  FieldContent,
  FieldDescription,
  FieldGroup,
  FieldLabel,
  FieldSeparator,
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select"

export default function FieldResponsive() {
  return (
    <FieldGroup className="w-full max-w-2xl">
      <Field orientation="responsive">
        <FieldContent>
          <FieldLabel htmlFor="field-responsive-domain">
            Custom domain
          </FieldLabel>
          <FieldDescription>
            Serve your docs from your own domain.
          </FieldDescription>
        </FieldContent>
        <Input
          id="field-responsive-domain"
          placeholder="docs.acme.com"
          className="@md/field-group:w-64"
        />
      </Field>
      <FieldSeparator />
      <Field orientation="responsive">
        <FieldContent>
          <FieldLabel htmlFor="field-responsive-region">Region</FieldLabel>
          <FieldDescription>
            Where your data is stored and processed.
          </FieldDescription>
        </FieldContent>
        <NativeSelect
          id="field-responsive-region"
          defaultValue="eu-central"
          className="@md/field-group:w-64"
        >
          <NativeSelectOption value="us-east">
            US East (Virginia)
          </NativeSelectOption>
          <NativeSelectOption value="eu-central">
            EU Central (Frankfurt)
          </NativeSelectOption>
          <NativeSelectOption value="ap-south">
            Asia Pacific (Mumbai)
          </NativeSelectOption>
        </NativeSelect>
      </Field>
    </FieldGroup>
  )
}

Guidelines

When to use

  • For every form, from a single inline field to a multi-step flow.
  • For settings pages, with horizontal fields.
  • For selectable cards that behave like radios or checkboxes.

When not to use

  • For inline editing inside tables or toolbars, where a label would be visual noise — label the control with aria-label instead.
  • For filters and search boxes that sit outside a form — a bare Input Group is usually enough.

One column

Lay out forms in a single column. Multi-column forms break the vertical path the eye follows and are easy to complete out of order. Pair fields side by side only when they belong together, like first and last name, or city and postal code.

Validate, then explain

Validate on submit, never on the first keystroke; after that, clear each error as soon as its field is fixed (see Forms). Put the error directly below the field it concerns, say what went wrong and how to fix it, and keep the person's input — don't clear the field. For long forms, also move focus to the first invalid field.

DoSpecific: says what’s wrong and how to fix it, with an icon as well as color.

Invalid input

Don’tVague, and color is the only signal — invisible to many people.

Descriptions earn their place

Add a FieldDescription only when it changes what people do: a format, a limit, a consequence. A description that restates the label is noise. If every field needs one, the labels probably need work.

Accessibility

  • Grouping. Field renders role="group"; FieldSet and FieldLegend are a native <fieldset> and <legend>, which assistive technology announces as the group's name.
  • Association. Connect every FieldLabel to its control with htmlFor/id, and descriptions and errors with aria-describedby.
  • Errors are alerts. FieldError renders role="alert", so it is announced as it appears. When several fields fail at once, consider announcing a single summary instead.
  • Not by color alone. Invalid fields combine a red border, an icon and a written message (WCAG 1.4.1).
  • Big targets. Choice cards make the whole card clickable, far beyond the 24×24px minimum (WCAG 2.5.8).
  • Disabled fields. Put data-disabled on the Field and disabled on the control; the label dims with the control. The description keeps its color, so the reason a field is unavailable stays readable.

API reference

Field

Renders a <div role="group">. Accepts all div props.

PropTypeDefault
orientation

Stacks the parts, places them in a row, or switches at the FieldGroup's md container width.

"vertical" | "horizontal" | "responsive""vertical"
data-invalid

Marks the field as invalid for styling and tests. Pair with aria-invalid on the control.

booleanNo default
data-disabled

Dims FieldLabel and FieldTitle. The description is not dimmed. Pair with disabled on the control.

booleanNo default

FieldSet and FieldLegend

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

PropTypeDefault
variant

FieldLegend only. legend is a 16px section title; label matches a field label for small groups.

"legend" | "label""legend"

FieldGroup

Renders a <div> that stacks fields with 20px gaps and establishes the field-group container for responsive fields. Set data-slot="checkbox-group" for 12px gaps.

FieldLabel, FieldTitle and FieldContent

FieldLabel renders a Label and accepts its props. When it wraps a Field, it becomes a choice card. FieldTitle renders a <div> styled like a label, for card headings that aren't the control's accessible name. FieldContent renders a <div> that stacks a label and description.

FieldDescription

Renders a <p> with helper text at 13px in muted-foreground. Links inside it use the link color.

FieldError

Renders a <div role="alert"> with an alert icon. Renders nothing when it has no content.

PropTypeDefault
children

The message. Takes precedence over errors.

ReactNodeNo default
errors

Messages from a validation library. Duplicates are removed; several render as a list.

Array<{ message?: string } | undefined>No default

FieldSeparator

Renders a horizontal rule between sections.

PropTypeDefault
children

Optional label centered on the rule, e.g. “or”.

ReactNodeNo default