Skip to content
Primitiv home
Framework
Consumption mode

Input Group

stableSource

A framed control with leading / trailing adornment slots — wraps an Input for icons, prefixes, clear buttons, and reveal toggles.

Playground

Density

Preview

Size
import { useState } from "react";import { Close, Search } from "@primitiv-ui/icons";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");
<InputGroup size="md">  <InputGroupLeadingAdornment>    <Search aria-hidden="true" />  </InputGroupLeadingAdornment>  <Input    aria-label="Search"    type="search"    placeholder="Search..."    value={query}    onChange={(e) => setQuery(e.target.value)}  />  {query && (    <InputGroupTrailingAdornment asChild>      <Button variant="ghost" size="xs" aria-label="Clear" onClick={() => setQuery("")}>        <Close aria-hidden="true" />      </Button>    </InputGroupTrailingAdornment>  )}</InputGroup>

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

Installation

npx primitiv add input-group

Import

import { InputGroup } from "@/components/ui/input-group";

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

const [query, setQuery] = useState("design tokens");
<InputGroup>  <InputGroupLeadingAdornment>    <Search aria-hidden="true" />  </InputGroupLeadingAdornment>  <Input    aria-label="Search"    type="search"    placeholder="Search..."    value={query}    onChange={(e) => setQuery(e.target.value)}  />  {query && (    <InputGroupTrailingAdornment asChild>      <Button variant="ghost" size="xs" aria-label="Clear" onClick={() => setQuery("")}>        <Close aria-hidden="true" />      </Button>    </InputGroupTrailingAdornment>  )}</InputGroup>

Props

InputGroup.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 <label> so the whole frame is clickable. The data-input-group hook is preserved.
childrenReactNodeheadlessThe wrapped control and its optional leading / trailing adornments.
refRef<HTMLDivElement>headlessAllows 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"mdstyledFrame 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.

PropTypeDefaultFromDescription
asChildbooleanfalseheadlessRender 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.
childrenReactNodeheadlessThe adornment content — a decorative icon (mark it aria-hidden="true"), suffix text, or an interactive control with an accessible name.
refRef<HTMLSpanElement>headlessAllows 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.

PropTypeDefaultFromDescription
asChildbooleanfalseheadlessRender 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.
childrenReactNodeheadlessThe adornment content — a decorative icon (mark it aria-hidden="true"), suffix text, or an interactive control with an accessible name.
refRef<HTMLSpanElement>headlessAllows 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

--primitiv-input-group-bg--primitiv-input-group-fg--primitiv-input-group-adornment-color--primitiv-input-group-border-color--primitiv-input-group-border-color-focus--primitiv-input-group-border-color-invalid--primitiv-input-group-border-width--primitiv-input-group-radius--primitiv-input-group-height--primitiv-input-group-padding-inline--primitiv-input-group-gap--primitiv-input-group-icon-size--primitiv-input-group-font-family--primitiv-input-group-font-size--primitiv-input-group-font-weight--primitiv-input-group-line-height

Data attributes

InputGroup

className: .primitiv-input-group

AttributeValueWhen
data-input-group""always

InputGroupLeadingAdornment

className: .primitiv-input-group__leading

AttributeValueWhen
data-input-group-adornmentleadingalways — it is how the stylesheet tells the two adornment slots apart

InputGroupTrailingAdornment

className: .primitiv-input-group__trailing

AttributeValueWhen
data-input-group-adornmenttrailingalways — 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

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.

Density
import { useState } from "react";import { Close, Search } from "@primitiv-ui/icons";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");
<InputGroup>  <InputGroupLeadingAdornment>    <Search aria-hidden="true" />  </InputGroupLeadingAdornment>  <Input    aria-label="Search"    type="search"    placeholder="Search..."    value={query}    onChange={(e) => setQuery(e.target.value)}  />  {query && (    <InputGroupTrailingAdornment asChild>      <Button variant="ghost" size="xs" aria-label="Clear" onClick={() => setQuery("")}>        <Close aria-hidden="true" />      </Button>    </InputGroupTrailingAdornment>  )}</InputGroup>

The group owns the frame

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.

Density
import { Search } from "@primitiv-ui/icons";import { InputGroup, InputGroupLeadingAdornment, InputGroupTrailingAdornment } from "@/components/ui/input-group";import { Input } from "@/components/ui/input";
// its own frame<Input aria-label="Bare" placeholder="A bare Input" />
// the group's frame; the Input renders flat inside it<InputGroup>  <InputGroupLeadingAdornment>    <Search aria-hidden="true" />  </InputGroupLeadingAdornment>  <Input aria-label="Grouped" placeholder="In a group" /></InputGroup>

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.

Density
£per month
import { InputGroup, InputGroupLeadingAdornment, InputGroupTrailingAdornment } from "@/components/ui/input-group";import { Input } from "@/components/ui/input";
<InputGroup>  <InputGroupLeadingAdornment>£</InputGroupLeadingAdornment>  <Input aria-label="Budget in pounds" type="number" placeholder="0.00" />  <InputGroupTrailingAdornment>per month</InputGroupTrailingAdornment></InputGroup>

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.

Density
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>      <Search aria-hidden="true" />    </InputGroupLeadingAdornment>    {/* no id needed — Input reads FieldContext and takes the field's */}    <Input type="search" value={query} onChange={(e) => setQuery(e.target.value)} />    {query && (      <InputGroupTrailingAdornment asChild>        <Button variant="ghost" size="xs" aria-label="Clear" onClick={() => setQuery("")}>          <Close aria-hidden="true" />        </Button>      </InputGroupTrailingAdornment>    )}  </InputGroup>  <FieldDescription>Components, tokens and guides.</FieldDescription></Field>

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.

Density
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<Field invalid>  <FieldLabel>Search the docs</FieldLabel>  <InputGroup>    <InputGroupLeadingAdornment><Search aria-hidden="true" /></InputGroupLeadingAdornment>    <Input type="search" defaultValue="???" />  </InputGroup>  <FieldErrorText>No results for that query.</FieldErrorText></Field>
<Field disabled>  <FieldLabel>Search (unavailable)</FieldLabel>  <InputGroup>    <InputGroupLeadingAdornment><Search aria-hidden="true" /></InputGroupLeadingAdornment>    <Input type="search" />  </InputGroup></Field>

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.

Density
import { Close, Search } from "@primitiv-ui/icons";import { InputGroup, InputGroupLeadingAdornment, InputGroupTrailingAdornment } from "@/components/ui/input-group";import { Input } from "@/components/ui/input";import { Button } from "@/components/ui/button";
<div data-density="comfortable">  <InputGroup size="xs">    ...  </InputGroup>  <InputGroup size="sm">    ...  </InputGroup>  <InputGroup size="md">    ...  </InputGroup>  <InputGroup size="lg">    ...  </InputGroup>  <InputGroup size="xl">    ...  </InputGroup></div>