Skip to content
Primitiv home
Framework
Consumption mode

Field

stableSource Figma

A form-field wrapper — coordinates a label, control, description, and error message and cascades id / validity / disabled / required to the control it wraps.

Playground

Density

Preview

We only use this to send receipts.
Size
import { Field, FieldLabel, FieldDescription, FieldErrorText } from "@/components/ui/field";import { Input } from "@/components/ui/input";
<Field size="md">  <FieldLabel>Email address</FieldLabel>  <Input type="email" placeholder="you@example.com" />  <FieldDescription>We only use this to send receipts.</FieldDescription></Field>

Density is set by a data-density ancestor — the Context system, not a Field prop.

Installation

npx primitiv add field

Import

import { Field } from "@/components/ui/field";

Copied into your project as .primitiv-field — you own the file afterwards, so upgrades are opt-in.

Headless mode installs the npm package instead: @primitiv-ui/react

Anatomy

<Field invalid>  <FieldLabel>Email address</FieldLabel>  <Input type="email" />  <FieldDescription>We only use this to send receipts.</FieldDescription>  <FieldErrorText>That does not look like an email address.</FieldErrorText></Field>

Props

Field.Root

Extends HTMLDivElement — every native attribute of that element is accepted and forwarded.

PropTypeDefaultFromDescription
asChildbooleanfalseheadlessRender the consumer's element instead of <div> via the Slot pattern — e.g. a semantic <fieldset>. The data-field-* hooks and context provider are preserved.
childrenReactNodeheadlessThe label, control, description, and error sub-components composed inside the field.
disabledbooleanfalseheadlessDisables the field. Cascades to the control's disabled prop and sets data-field-disabled on the wrapper.
idstringheadlessStable id for the field. Wired to the control via FieldLabel's htmlFor, and used to derive the descriptionId (<id>-description) and errorId (<id>-error) exposed through FieldContext. Auto-generated via React's useId when omitted.
invalidbooleanfalseheadlessMarks the field invalid. Cascades to a context-aware control (e.g. Input) as aria-invalid, sets data-field-invalid on the wrapper, and gates FieldErrorText rendering.
requiredbooleanfalseheadlessMarks the field required. Cascades to the control's required prop and sets data-field-required on the wrapper.
size"xs" | "sm" | "md" | "lg" | "xl"mdstyledControl size for the whole field; data-density scales each size further.

Field.Label

Extends HTMLLabelElement — every native attribute of that element is accepted and forwarded.

PropTypeDefaultFromDescription
asChildbooleanfalseheadlessRender the consumer's element instead of <label> via the Slot pattern, with htmlFor merged on.
childrenReactNodeheadlessThe label text.

Field.Description

Extends HTMLDivElement — every native attribute of that element is accepted and forwarded.

PropTypeDefaultFromDescription
asChildbooleanfalseheadlessRender the consumer's element instead of <div> via the Slot pattern (e.g. a <p> or <span>), with the derived id merged on.
childrenReactNodeheadlessThe helper text.

Field.ErrorText

Extends HTMLDivElement — every native attribute of that element is accepted and forwarded.

PropTypeDefaultFromDescription
asChildbooleanfalseheadlessRender the consumer's element instead of <div> via the Slot pattern, with the derived id and role="alert" merged on.
childrenReactNodeheadlessThe error message.

Styling contract

--primitiv-field-gap--primitiv-field-label-color--primitiv-field-label-font-family--primitiv-field-label-font-size--primitiv-field-label-font-weight--primitiv-field-label-line-height--primitiv-field-helper-color--primitiv-field-error-color--primitiv-field-helper-font-family--primitiv-field-helper-font-size--primitiv-field-helper-font-weight--primitiv-field-helper-line-height--primitiv-field-required-color--primitiv-field-required-gap

Data attributes

Field

className: .primitiv-field

AttributeValueWhen
data-field""always
data-field-invalid""invalid
data-field-disabled""disabled
data-field-required""required

Accessibility

  • Field exists to make the wiring impossible to get wrong, not to add semantics of its own. The label points at the control, the description and error are linked by aria-describedby, and the ids are generated — so the failure mode where a label is visually beside a control but not associated with it cannot happen.
  • The aria-describedby chain is composed in a deliberate order: any ids you pass yourself come first, then the field's description, then the error when invalid. Your own value is added to, never replaced.
  • ErrorText renders only when the field is invalid. That is the accessible behaviour — an error permanently in the DOM and hidden with CSS can still be announced, and one merely dimmed is announced as though it applied.
  • Colour is not the error. The invalid state paints a ring and reddens the message, but the ErrorText has to say what is wrong — and it must be linked, which is what aria-describedby is doing, so it is read as part of the control rather than as loose text nearby.
  • For a group of controls — radios, related checkboxes — use asChild to render a real <fieldset> with the label as its <legend>. A <div> with text above it does not tell assistive technology that the options belong together.
  • disabled on the field cascades the native attribute to the control, so it leaves the tab order. If the field needs to stay discoverable while unavailable, keep it enabled and explain the constraint in the description rather than disabling the group.
  • Field owns no keyboard behaviour. Whatever you put inside keeps its own — which is the point: the wrapper never intercepts focus or keys.
  • Field.Label always renders htmlFor, even when nothing can claim it. Only Input, Textarea and Select adopt the field's id, so with any other control the label points at an element that does not exist — a dangling reference, and the label associates with nothing. Until that is fixed at source, give a non-context-aware control its name directly: aria-labelledby pointing at the label for Slider and SegmentedControl, a real <fieldset> / <legend> for a group of radios, or nothing at all for Switch and Checkbox, which render their own <label> around the text you pass them.

Examples

The cascade (the headline)

Type something without an @. One invalid prop on the Field does four things at once: it sets aria-invalid on the control, links the error text to it through aria-describedby, puts data-field-invalid on the wrapper for CSS, and decides whether the error renders at all. That last one is the surprise — ErrorText is gated, not hidden, so there is no stale error sitting in the DOM waiting to be announced.

Density
We only use this to send receipts.
import { useState } from "react";import { Field, FieldLabel, FieldDescription, FieldErrorText } from "@/components/ui/field";import { Input } from "@/components/ui/input";
const [value, setValue] = useState("");const invalid = !value.includes("@");
<Field invalid={invalid}>  <FieldLabel>Email address</FieldLabel>  <Input value={value} onChange={(e) => setValue(e.target.value)} />  <FieldDescription>We only use this to send receipts.</FieldDescription>  <FieldErrorText>That does not look like an email address.</FieldErrorText></Field>

Ids you never write

Give the Field an id and it derives the rest — <id>-description and <id>-error — then wires the label's htmlFor, the control's id, and the aria-describedby chain to match. Omit it and React's useId supplies one, which is the usual case: the ids only need to be unique and correct, not memorable. Inspect the control below and you will find all three links already made.

Density
We only use this to send receipts.
import { Field, FieldLabel, FieldDescription, FieldErrorText } from "@/components/ui/field";import { Input } from "@/components/ui/input";
// id optional — omit it and useId generates one<Field id="email">  <FieldLabel>Email address</FieldLabel>   {/* htmlFor="email" */}  <Input />                        {/* id="email", aria-describedby="email-description" */}  <FieldDescription>We only use this to send receipts.</FieldDescription>  {/* id="email-description" */}</Field>

Disabled and required

Both cascade the same way invalid does, so you set them in one place rather than on the wrapper for styling and on the control for behaviour. disabled reaches the control's disabled prop and dims the label with it; required reaches the control's required. Each also lands on the wrapper as data-field-disabled / data-field-required for anything your own CSS needs.

Density
Assigned when the account was created.
import { Field, FieldLabel, FieldDescription, FieldErrorText } from "@/components/ui/field";import { Input } from "@/components/ui/input";
<Field required>  <FieldLabel>Full name</FieldLabel>  <Input /></Field>
<Field disabled>  <FieldLabel>Account id</FieldLabel>  <Input defaultValue="acct_18f3" />  <FieldDescription>Assigned when the account was created.</FieldDescription></Field>

Which controls the cascade reaches

Three controls read FieldContext: Input, Textarea and Select. Those inherit the id, the aria-describedby chain, aria-invalid, disabled and required without being told about the field at all — Textarea below is given nothing but rows. Everything else is only inside the field, not wired to it: Switch, Checkbox and Radio bring their own <label>, and Slider and SegmentedControl need aria-labelledby pointing at a label you give an id. For those, Field.Label's htmlFor has nothing to attach to — see the note under Accessibility.

Density
Include the steps you took.
import { Field, FieldLabel, FieldDescription, FieldErrorText } from "@/components/ui/field";import { Textarea } from "@/components/ui/textarea";
<Field required>  <FieldLabel>What went wrong?</FieldLabel>  {/* no id, no aria-*, no required — all inherited */}  <Textarea rows={3} />  <FieldDescription>Include the steps you took.</FieldDescription></Field>

A group of controls (asChild)

A Field wrapping several radios needs to be a <fieldset>, not a <div> — that is the native way to say “these options belong together”, and the Label becomes its <legend>. asChild on both swaps the elements while keeping the context, the ids and the data-field-* hooks. This is the group-labelling requirement the Radio page describes, done with the components you already have.

Density
Plan
import { Field, FieldLabel, FieldDescription, FieldErrorText } from "@/components/ui/field";import { Radio } from "@/components/ui/radio";import { Stack } from "@/components/ui/stack";
<Field asChild>  <fieldset>    <FieldLabel asChild><legend>Plan</legend></FieldLabel>    <Stack gap="sm">      <Radio name="plan" value="free">Free</Radio>      <Radio name="plan" value="pro">Pro</Radio>    </Stack>  </fieldset></Field>

Overriding the cascade

A prop you set on the control always beats the one the field cascaded — the field supplies a default, it does not seize control. That is what makes a mixed field possible: a disabled Field with one control that stays editable, or a control with its own aria-describedby pointing somewhere else. Outside a Field, every one of these components behaves exactly as it does on its own page.

Density
import { Field, FieldLabel, FieldDescription, FieldErrorText } from "@/components/ui/field";import { Input } from "@/components/ui/input";
<Field disabled>  <FieldLabel>Coupon code</FieldLabel>  {/* the field says disabled; this control says otherwise, and wins */}  <Input disabled={false} placeholder="Still editable" /></Field>