Copied into your project as .primitiv-input-group — you own the file afterwards, so upgrades are opt-in.
Headless mode installs the npm package instead: @primitiv-ui/react
Anatomy
The Root is the frame; the two adornment slots sit either side of whatever control you put between them. Both slots share one props type — they differ only in which side they occupy, so there is genuinely no prop that tells them apart, and the props tables below print the same list twice. The control in the middle is an ordinary Input: the group does not wrap or replace it, it just re-points its border and background so it renders flat inside the shared frame.
Generated from source — headless rows from each *Props type’s JSDoc, styled rows from contract.json. Never hand-maintained.
InputGroup.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 <label> so the whole frame is
clickable. The data-input-group hook is preserved.
children
ReactNode
—
headless
The wrapped control and its optional leading / trailing
adornments.
ref
Ref<HTMLDivElement>
—
headless
Allows getting a ref to the component instance.
Once the component unmounts, React will set ref.current to null
(or call the ref with null if you passed a callback ref).
Forwarded to the underlying HTMLDivElement (or the asChild
element).
size
"xs" | "sm" | "md" | "lg" | "xl"
md
styled
Frame size; data-density scales each size further. Match the wrapped control's size.
InputGroup.LeadingAdornment
Extends HTMLSpanElement — 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 <span> via the
Slot pattern — e.g. an interactive <button>. Event
handlers compose (child runs first) and the data-input-group-adornment
hook is preserved.
children
ReactNode
—
headless
The adornment content — a decorative icon (mark it
aria-hidden="true"), suffix text, or an interactive control with an
accessible name.
ref
Ref<HTMLSpanElement>
—
headless
Allows getting a ref to the component instance.
Once the component unmounts, React will set ref.current to null
(or call the ref with null if you passed a callback ref).
Forwarded to the underlying HTMLSpanElement (or the asChild
element).
InputGroup.TrailingAdornment
Extends HTMLSpanElement — 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 <span> via the
Slot pattern — e.g. an interactive <button>. Event
handlers compose (child runs first) and the data-input-group-adornment
hook is preserved.
children
ReactNode
—
headless
The adornment content — a decorative icon (mark it
aria-hidden="true"), suffix text, or an interactive control with an
accessible name.
ref
Ref<HTMLSpanElement>
—
headless
Allows getting a ref to the component instance.
Once the component unmounts, React will set ref.current to null
(or call the ref with null if you passed a callback ref).
Forwarded to the underlying HTMLSpanElement (or the asChild
element).
Styling contract
16 CSS custom properties on .primitiv-input-group — 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.
InputGroup
className:.primitiv-input-group
Attribute
Value
When
data-input-group
""
always
InputGroup.Root
Attribute
Value
When
data-input-group
""
always
InputGroupLeadingAdornment
className:.primitiv-input-group__leading
Attribute
Value
When
data-input-group-adornment
leading
always — it is how the stylesheet tells the two adornment slots apart
InputGroup.LeadingAdornment
Attribute
Value
When
data-input-group-adornment
leading
always — it is how the stylesheet tells the two adornment slots apart
InputGroupTrailingAdornment
className:.primitiv-input-group__trailing
Attribute
Value
When
data-input-group-adornment
trailing
always — it is how the stylesheet tells the two adornment slots apart
InputGroup.TrailingAdornment
Attribute
Value
When
data-input-group-adornment
trailing
always — it is how the stylesheet tells the two adornment slots apart
Accessibility
The control inside still needs its own label. The group is a <div> — it adds no name and no role, so an aria-label on it does nothing for the input. Label the Input (or wrap the pair in a Field, which is what associates a visible label with it).
A decorative adornment must be aria-hidden="true". A search glyph beside a field labelled "Search" is announced twice otherwise, and the second announcement carries no information.
An interactive adornment needs a name of its own. asChild onto a Button keeps it a real, focusable control in the tab order — and because it is icon-only, aria-label is the only thing that names it. Never put a click handler on a bare adornment <span>: it would be unreachable by keyboard.
Mind the tab order. A trailing control comes after the input in the DOM, which is what a keyboard user expects — type, then Tab to Clear. Putting an interactive control in the leading slot puts it before the field, so reach for that only when the action genuinely precedes typing.
The focus ring is drawn on the frame but focus is on the input, via :focus-within. That is deliberate: the visible ring matches the frame the user sees while the real focus target stays the control, so screen-reader focus and visible focus never disagree.
A unit or currency adornment describes the expected format, so it belongs in the accessible name or a Field description too — a glyph outside the input is not read as part of it.
type="search" grows its own clear button, and it used to collide with this one: WebKit and Blink draw ::-webkit-search-cancel-button as soon as the field has a value, so a group with its own Clear rendered two X's the moment you typed. Nothing in the DOM shows it — it is a UA pseudo-element, so it survives any review that reads markup. The stylesheet now suppresses it, but only when the group actually supplies a trailing adornment: stripping the native control from a search field that offers no replacement would be worse than the duplicate.
Examples
Every example below reacts to the density control. Currently showing Styled mode.
Decorative or interactive?
This is the decision the API cannot make for you, and both adornments below get it right in opposite ways. The leading Search glyph is decorative — it repeats what the placeholder already says, so it is aria-hidden and reaches no one twice. The trailing Clear is interactive: it does something, so it is a real Button (via asChild, which merges the slot's positioning onto it), it is focusable, and it carries its own aria-label because an icon-only control has no text to be named by. Get this backwards and you either hide a control from assistive technology or announce a picture.
Compare the two. A bare Input draws its own border, background and focus ring; inside a group all three move to the wrapper, because the group re-points the Input's --primitiv-input-* custom properties. Worth knowing exactly how: the inner border is not removed, it is made transparent — still 1px, so the box it occupies does not change and nothing shifts when a control moves into a group. Focus the second one: the ring appears on the whole frame, read off the real control with :focus-within, so focus still belongs to the input and nothing coordinates it in JavaScript.
import{Search}from"@primitiv-ui/icons";import{InputGroup,InputGroupLeadingAdornment,InputGroupTrailingAdornment}from"@/components/ui/input-group";import{Input}from"@/components/ui/input";// its own frame<Inputaria-label="Bare"placeholder="A bare Input"/>// the group's frame; the Input renders flat inside it<InputGroup><InputGroupLeadingAdornment><Searcharia-hidden="true"/></InputGroupLeadingAdornment><Inputaria-label="Grouped"placeholder="In a group"/></InputGroup>
import{Search}from"@primitiv-ui/icons";import{InputGroup}from"@primitiv-ui/react";import{Input}from"@primitiv-ui/react";// its own frame<Inputaria-label="Bare"placeholder="A bare Input"/>// the group's frame; the Input renders flat inside it<InputGroup.Root><InputGroup.LeadingAdornment><Searcharia-hidden="true"/></InputGroup.LeadingAdornment><Inputaria-label="Grouped"placeholder="In a group"/></InputGroup.Root>
Text adornments
An adornment does not have to be an icon. A currency symbol or a unit suffix is the other common case, and it is decorative in the same sense — it describes the format of the field, so it belongs in the label or a description as well, not only as a glyph beside the box. Here aria-label carries the currency so the field is not announced as a bare number.
£per month
import{InputGroup,InputGroupLeadingAdornment,InputGroupTrailingAdornment}from"@/components/ui/input-group";import{Input}from"@/components/ui/input";<InputGroup><InputGroupLeadingAdornment>£</InputGroupLeadingAdornment><Inputaria-label="Budget in pounds"type="number"placeholder="0.00"/><InputGroupTrailingAdornment>per month</InputGroupTrailingAdornment></InputGroup>
import{InputGroup}from"@primitiv-ui/react";import{Input}from"@primitiv-ui/react";<InputGroup.Root><InputGroup.LeadingAdornment>£</InputGroup.LeadingAdornment><Inputaria-label="Budget in pounds"type="number"placeholder="0.00"/><InputGroup.TrailingAdornment>per month</InputGroup.TrailingAdornment></InputGroup.Root>
With a visible label (Field)
This composition works, and it is worth saying why, because the equivalent does not for every control. Field cascades its generated id to whatever reads FieldContext — and the control inside a group is an ordinary Input, which does. So Field.Label's htmlFor lands on the real input even though it is nested two levels down, and clicking the label focuses the field. The group itself reads no context and needs none; it is only the frame.
Components, tokens and guides.
import{ useState }from"react";import{Close,Search}from"@primitiv-ui/icons";import{Field,FieldLabel,FieldDescription,FieldErrorText}from"@/components/ui/field";import{InputGroup,InputGroupLeadingAdornment,InputGroupTrailingAdornment}from"@/components/ui/input-group";import{Input}from"@/components/ui/input";import{Button}from"@/components/ui/button";const[query, setQuery]=useState("design tokens");<Field><FieldLabel>Search the docs</FieldLabel><InputGroup><InputGroupLeadingAdornment><Searcharia-hidden="true"/></InputGroupLeadingAdornment>{/* no id needed — Input reads FieldContext and takes the field's */}<Inputtype="search"value={query}onChange={(e)=>setQuery(e.target.value)}/>{query &&(<InputGroupTrailingAdornmentasChild><Buttonvariant="ghost"size="xs"aria-label="Clear"onClick={()=>setQuery("")}><Closearia-hidden="true"/></Button></InputGroupTrailingAdornment>)}</InputGroup><FieldDescription>Components, tokens and guides.</FieldDescription></Field>
import{ useState }from"react";import{Close,Search}from"@primitiv-ui/icons";import{Field}from"@primitiv-ui/react";import{InputGroup}from"@primitiv-ui/react";import{Input}from"@primitiv-ui/react";import{Button}from"@primitiv-ui/react";const[query, setQuery]=useState("design tokens");<Field.Root><Field.Label>Search the docs</Field.Label><InputGroup.Root><InputGroup.LeadingAdornment><Searcharia-hidden="true"/></InputGroup.LeadingAdornment>{/* no id needed — Input reads FieldContext and takes the field's */}<Inputtype="search"value={query}onChange={(e)=>setQuery(e.target.value)}/>{query &&(<InputGroup.TrailingAdornmentasChild><Buttonvariant="ghost"size="xs"aria-label="Clear"onClick={()=>setQuery("")}><Closearia-hidden="true"/></Button></InputGroup.TrailingAdornment>)}</InputGroup.Root><Field.Description>Components, tokens and guides.</Field.Description></Field.Root>
Invalid and disabled
The frame carries the state, and no prop on the group says so. Its stylesheet reads the wrapped control directly — :has(input[aria-invalid="true"]) reddens the border, :has(input:disabled) dims the whole frame — which is the same trick :focus-within uses for the ring. So a Field cascading invalid or disabled reaches the input, and the frame follows on its own: three components agreeing with no prop threaded between them. Note where the colour lands — on the frame, not as a second red box around the inner control.
No results for that query.
import{Search}from"@primitiv-ui/icons";import{Field,FieldLabel,FieldDescription,FieldErrorText}from"@/components/ui/field";import{InputGroup,InputGroupLeadingAdornment,InputGroupTrailingAdornment}from"@/components/ui/input-group";import{Input}from"@/components/ui/input";// the Field sets it on the Input; the frame reads it off the Input<Fieldinvalid><FieldLabel>Search the docs</FieldLabel><InputGroup><InputGroupLeadingAdornment><Search aria-hidden="true" /></InputGroupLeadingAdornment><Inputtype="search"defaultValue="???"/></InputGroup><FieldErrorText>No results for that query.</FieldErrorText></Field><Fielddisabled><FieldLabel>Search (unavailable)</FieldLabel><InputGroup><InputGroupLeadingAdornment><Search aria-hidden="true" /></InputGroupLeadingAdornment><Inputtype="search"/></InputGroup></Field>
import{Search}from"@primitiv-ui/icons";import{Field}from"@primitiv-ui/react";import{InputGroup}from"@primitiv-ui/react";import{Input}from"@primitiv-ui/react";// the Field sets it on the Input; the frame reads it off the Input<Field.Rootinvalid><Field.Label>Search the docs</Field.Label><InputGroup.Root><InputGroup.LeadingAdornment><Search aria-hidden="true" /></InputGroup.LeadingAdornment><Inputtype="search"defaultValue="???"/></InputGroup.Root><Field.ErrorText>No results for that query.</Field.ErrorText></Field.Root><Field.Rootdisabled><Field.Label>Search (unavailable)</Field.Label><InputGroup.Root><InputGroup.LeadingAdornment><Search aria-hidden="true" /></InputGroup.LeadingAdornment><Inputtype="search"/></InputGroup.Root></Field.Root>
Sizes and density
size on the group sets the frame and the adornments together, and the nearest data-density ancestor rescales the whole ramp again. Note the Clear button is size="xs" at every group size: an adornment control should sit inside the frame's height rather than setting it, so it is sized against the slot, not against the group.