A form-field wrapper — coordinates a label, control, description, and error message and cascades id / validity / disabled / required to the control it wraps.
Playground
Preview
We only use this to send receipts.
import{Field,FieldLabel,FieldDescription,FieldErrorText}from"@/components/ui/field";import{Input}from"@/components/ui/input";<Fieldsize="md"><FieldLabel>Email address</FieldLabel><Inputtype="email"placeholder="you@example.com"/><FieldDescription>We only use this to send receipts.</FieldDescription></Field>
import{Field}from"@primitiv-ui/react";import{Input}from"@primitiv-ui/react";<Field.Root><Field.Label>Email address</Field.Label><Inputtype="email"placeholder="you@example.com"/><Field.Description>We only use this to send receipts.</Field.Description></Field.Root>
Density is set by a data-density ancestor — the Context system, not a Field prop.
Installation
npx primitiv add field
pnpm dlx primitiv add field
yarn dlx primitiv add field
bunx 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
Four parts, and only the Root is required — a Field with a label and a control is a complete field. The Root renders a <div> (or your own element via asChild), generates the id, and provides the context every other part reads. Note the headless root is callable directly: Field.Root and Field are the same component, which is why the form pages elsewhere on this site write the shorter <Field>.
<Fieldinvalid><FieldLabel>Email address</FieldLabel><Inputtype="email"/><FieldDescription>We only use this to send receipts.</FieldDescription><FieldErrorText>That does not look like an email address.</FieldErrorText></Field>
<Field.Rootinvalid><Field.Label>Email address</Field.Label><Inputtype="email"/><Field.Description>We only use this to send receipts.</Field.Description><Field.ErrorText>That does not look like an email address.</Field.ErrorText></Field.Root>
Props
Generated from source — headless rows from each *Props type’s JSDoc, styled rows from contract.json. Never hand-maintained.
Field.Root
Extends HTMLDivElement — every native attribute of that element is accepted and forwarded.
Prop
Type
Default
From
Description
asChild
boolean
false
headless
Render 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.
children
ReactNode
—
headless
The label, control, description, and error sub-components composed
inside the field.
disabled
boolean
false
headless
Disables the field. Cascades to the control's disabled prop and
sets data-field-disabled on the wrapper.
id
string
—
headless
Stable 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.
invalid
boolean
false
headless
Marks 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.
required
boolean
false
headless
Marks the field required. Cascades to the control's required prop
and sets data-field-required on the wrapper.
size
"xs" | "sm" | "md" | "lg" | "xl"
md
styled
Control 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.
Prop
Type
Default
From
Description
asChild
boolean
false
headless
Render the consumer's element instead of <label> via the
Slot pattern, with htmlFor merged on.
children
ReactNode
—
headless
The label text.
Field.Description
Extends HTMLDivElement — every native attribute of that element is accepted and forwarded.
Prop
Type
Default
From
Description
asChild
boolean
false
headless
Render the consumer's element instead of <div> via the
Slot pattern (e.g. a <p> or <span>), with the derived
id merged on.
children
ReactNode
—
headless
The helper text.
Field.ErrorText
Extends HTMLDivElement — every native attribute of that element is accepted and forwarded.
Prop
Type
Default
From
Description
asChild
boolean
false
headless
Render the consumer's element instead of <div> via the
Slot pattern, with the derived id and role="alert"
merged on.
children
ReactNode
—
headless
The error message.
Styling contract
14 CSS custom properties on .primitiv-field — mode-agnostic. These names are the stable surface; the values are not.
Emitted automatically by the headless primitive — style against these rather than adding your own state classes. Grouped by the part that emits them, since most are emitted by more than one.
Field
className:.primitiv-field
Attribute
Value
When
data-field
""
always
data-field-invalid
""
invalid
data-field-disabled
""
disabled
data-field-required
""
required
Field.Root
Attribute
Value
When
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
Every example below reacts to the density control. Currently showing Styled mode.
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.
We only use this to send receipts.
That does not look like an email address.
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("@");<Fieldinvalid={invalid}><FieldLabel>Email address</FieldLabel><Inputvalue={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>
import{ useState }from"react";import{Field}from"@primitiv-ui/react";import{Input}from"@primitiv-ui/react";const[value, setValue]=useState("");const invalid =!value.includes("@");<Field.Rootinvalid={invalid}><Field.Label>Email address</Field.Label><Inputvalue={value}onChange={(e)=>setValue(e.target.value)}/><Field.Description>We only use this to send receipts.</Field.Description><Field.ErrorText>That does not look like an email address.</Field.ErrorText></Field.Root>
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.
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<Fieldid="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>
import{Field}from"@primitiv-ui/react";import{Input}from"@primitiv-ui/react";// id optional — omit it and useId generates one<Field.Rootid="email"><Field.Label>Email address</Field.Label>{/* htmlFor="email" */}<Input/>{/* id="email", aria-describedby="email-description" */}<Field.Description>We only use this to send receipts.</Field.Description>{/* id="email-description" */}</Field.Root>
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.
Assigned when the account was created.
import{Field,FieldLabel,FieldDescription,FieldErrorText}from"@/components/ui/field";import{Input}from"@/components/ui/input";<Fieldrequired><FieldLabel>Full name</FieldLabel><Input/></Field><Fielddisabled><FieldLabel>Account id</FieldLabel><InputdefaultValue="acct_18f3"/><FieldDescription>Assigned when the account was created.</FieldDescription></Field>
import{Field}from"@primitiv-ui/react";import{Input}from"@primitiv-ui/react";<Field.Rootrequired><Field.Label>Full name</Field.Label><Input/></Field.Root><Field.Rootdisabled><Field.Label>Account id</Field.Label><InputdefaultValue="acct_18f3"/><Field.Description>Assigned when the account was created.</Field.Description></Field.Root>
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.
Include the steps you took.
import{Field,FieldLabel,FieldDescription,FieldErrorText}from"@/components/ui/field";import{Textarea}from"@/components/ui/textarea";<Fieldrequired><FieldLabel>What went wrong?</FieldLabel>{/* no id, no aria-*, no required — all inherited */}<Textarearows={3}/><FieldDescription>Include the steps you took.</FieldDescription></Field>
import{Field}from"@primitiv-ui/react";import{Textarea}from"@primitiv-ui/react";<Field.Rootrequired><Field.Label>What went wrong?</Field.Label>{/* no id, no aria-*, no required — all inherited */}<Textarearows={3}/><Field.Description>Include the steps you took.</Field.Description></Field.Root>
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.
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.
import{Field,FieldLabel,FieldDescription,FieldErrorText}from"@/components/ui/field";import{Input}from"@/components/ui/input";<Fielddisabled><FieldLabel>Coupon code</FieldLabel>{/* the field says disabled; this control says otherwise, and wins */}<Inputdisabled={false}placeholder="Still editable"/></Field>
import{Field}from"@primitiv-ui/react";import{Input}from"@primitiv-ui/react";<Field.Rootdisabled><Field.Label>Coupon code</Field.Label>{/* the field says disabled; this control says otherwise, and wins */}<Inputdisabled={false}placeholder="Still editable"/></Field.Root>