An interactive, removable label chip — filter bars, multi-select fields, inputs. The trailing remove button is core to the anatomy, not optional; an optional leading icon or avatar may precede the label. Sized xs–xl; data-density scales each size further.
Registry-only
No headless primitive — this component ships only as a copied styled file, so there is no Headless mode. primitiv add chip installs it whichever mode you are reading.
Density is set by a data-density ancestor — the Context system, not a Chip prop.
Installation
npx primitiv add chip
pnpm dlx primitiv add chip
yarn dlx primitiv add chip
bunx primitiv add chip
Import
import{Chip}from"@/components/ui/chip";
No headless primitive — this one ships only as a copied styled surface, so primitiv add is the only way in, whichever mode you are reading.
Props
Generated from the copied file’s props type and contract.json. Never hand-maintained.
Chip
Extends HTMLSpanElement — every native attribute of that element is accepted and forwarded.
Prop
Type
Default
Description
children* (required)
ReactNode
—
The chip's label content.
onRemove* (required)
() => void
—
Called when the remove button is activated. Required: removability is
core to a Chip's anatomy, not an optional affordance — use Tag for a
non-removable label.
disabled
boolean
—
Disables the remove button and dims the whole chip.
leadingIcon
ReactNode
—
An optional icon or avatar rendered before the label — e.g.
<Chip leadingIcon={<User />}>Jane Doe</Chip>.
removeLabel
string
`Remove ${children}` when `children` is a string, else `"Remove"`.
aria-label for the remove button.
size
"xs" | "sm" | "md" | "lg" | "xl"
md
Chip size; data-density scales each size further.
Styling contract
13 CSS custom properties on .primitiv-chip — mode-agnostic. These names are the stable surface; the values are not.
A Chip has exactly one interactive element — the trailing remove <button> — so its keyboard model is a native button's, nothing more. The label and any leading icon are not focusable.
Key
Behaviour
Tab
Move focus to the remove button, showing its :focus-visible ring. A disabled chip's button is skipped, exactly like any disabled button.
Enter
Activate the remove button, calling onRemove — it is a real <button>, so this is native.
Space
Also activates the remove button, the native second key for a <button>.
Accessibility
Only the remove button is interactive. The pill's <span> is deliberately not clickable — making the whole chip a button and nesting a remove button inside it is an invalid nested-interactive structure. If you need the chip's body to do something (open a detail, toggle a filter), that is a different component; a Chip removes, and only its × is a control.
Name the remove button. It is icon-only, so it needs an accessible name: removeLabel sets it, defaulting to Remove ${children} when the label is a string. A bare "Remove" with no object ("remove what?") is the failure mode when the label is not plain text — pass removeLabel explicitly then.
Manage focus after removal. Removing a chip unmounts the button that had focus, so focus can be lost to the <body>. When you remove a chip, move focus to a sensible neighbour — the next chip, or the filter bar's container — rather than leaving the user stranded. The Chip fires onRemove; the focus move is yours to make.
The label text carries the meaning; the tone is neutral and fixed, so unlike Badge/Tag there is no colour to misread here. Keep the label self-describing ("Status: Active", not "Active").
disabled puts the native disabled attribute on the remove button, which removes it from the tab order and stops onRemove firing — the honest way to show a chip that exists but cannot currently be dismissed.
Chip, Tag or Badge? Chip is the interactive, removable one — a <button> inside. Tag is the read-only category label, Badge the read-only status. If nothing can be removed or clicked, you want one of those, not a Chip with a no-op onRemove.
Examples
Every example below reacts to the density control. Currently showing Styled mode.
A removable filter bar (the headline)
The canonical use: applied filters a user can dismiss one at a time. onRemove is required — it is what a Chip is for — and here it removes the filter from React state, so the × genuinely takes the chip away. (Remove them all to see the reset.) Note the split of responsibility: the Chip fires onRemove, but what removal means, and where focus goes afterward, are yours.
Status: ActiveOwner: YouLabel: Bug
import{Chip}from"@/components/ui/chip";import{Stack}from"@/components/ui/stack";import{ useState }from"react";const[filters, setFilters]=useState(["Status: Active","Owner: You"]);<Stackdirection="row"gap="sm"wrap="wrap">{filters.map((filter)=>(<Chipkey={filter}onRemove={()=>setFilters((c)=> c.filter((f)=> f !== filter))}>{filter}</Chip>))}</Stack>
A leading icon or avatar
leadingIcon renders any node before the label — an icon for a category, or an Avatar for a person (an assignee or attendee chip). Keep it decorative: mark an icon aria-hidden and an avatar image alt="", since the label already carries the name and removeLabel names the button.
Jane DoeABAlex Brand
import{Chip}from"@/components/ui/chip";import{User}from"@primitiv-ui/icons";<ChipleadingIcon={<Useraria-hidden="true"/>}onRemove={removeAssignee}> Jane Doe</Chip>
Sizes and density
Five sizes, each rescaling again with the nearest data-density ancestor — chips in a compact filter bar want xs/sm, one standing alone lg. size reuses the shared framed-control/* scale, so a chip lines up with an Input or Button of the same size. Change the density above and the whole ramp shifts.
disabled dims the whole pill and disables the remove button, so the chip is shown but cannot be dismissed — a filter locked on by a permission, say. It is the remove <button> that carries the disabled attribute (the pill is not a control), so it drops out of the tab order like any disabled button.
Region: EU (locked)
import{Chip}from"@/components/ui/chip";<ChipdisabledonRemove={remove}>Region: EU (locked)</Chip>