Skip to content
Primitiv home
Framework
Consumption mode

Chip

stableSource Figma

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.

Playground

Density

Preview

Design
Size
import { Chip } from "@/components/ui/chip";
<Chip size="md" onRemove={() => removeFilter()}>  Design</Chip>

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

Installation

npx 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

Chip

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

PropTypeDefaultDescription
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.
disabledboolean—Disables the remove button and dims the whole chip.
leadingIconReactNode—An optional icon or avatar rendered before the label — e.g. <Chip leadingIcon={<User />}>Jane Doe</Chip>.
removeLabelstring`Remove ${children}` when `children` is a string, else `"Remove"`.aria-label for the remove button.
size"xs" | "sm" | "md" | "lg" | "xl"mdChip size; data-density scales each size further.

Styling contract

--primitiv-chip-background--primitiv-chip-border-color--primitiv-chip-foreground--primitiv-chip-height--primitiv-chip-padding-inline--primitiv-chip-gap--primitiv-chip-icon-size--primitiv-chip-border-width--primitiv-chip-radius--primitiv-chip-font-family--primitiv-chip-font-size--primitiv-chip-font-weight--primitiv-chip-line-height

Keyboard

KeyBehaviour
TabMove focus to the remove button, showing its :focus-visible ring. A disabled chip's button is skipped, exactly like any disabled button.
EnterActivate the remove button, calling onRemove — it is a real <button>, so this is native.
SpaceAlso 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

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.

Density
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"]);
<Stack direction="row" gap="sm" wrap="wrap">  {filters.map((filter) => (    <Chip      key={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.

Density
Jane DoeABAlex Brand
import { Chip } from "@/components/ui/chip";import { User } from "@primitiv-ui/icons";
<Chip leadingIcon={<User aria-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.

Density
DesignDesignDesignDesignDesign
import { Chip } from "@/components/ui/chip";
<div data-density="comfortable">  <Chip size="xs" onRemove={remove}>Design</Chip>  <Chip size="sm" onRemove={remove}>Design</Chip>  <Chip size="md" onRemove={remove}>Design</Chip>  <Chip size="lg" onRemove={remove}>Design</Chip>  <Chip size="xl" onRemove={remove}>Design</Chip></div>

Disabled

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.

Density
Region: EU (locked)
import { Chip } from "@/components/ui/chip";
<Chip disabled onRemove={remove}>Region: EU (locked)</Chip>