DocumentationComponentsTheme Creator
Theme CreatorGitHubIntroduction
InstallationUsageThemingMotion
Components
ButtonIconButtonLinkLabelBadgeStatusChipAvatarShortcutSpinnerSkeletonProgressMeter
AI agentsContributing
  1. Components
  2. Chip

Chip

A compact label that is a tag, a toggle, an action or an input chip — decided by the handlers you give it, never by a prop.

$ pnpm add @tessinaui/ui

When to use

  • A short label that describes or filters content. Categories, attributes, topics, suggestions: words a reader scans in a row.
  • Filters and choices. Put chips in a ChipGroup with selection="multiple" for any-of-many and selection="single" for one-of-many. Each chip is a toggle.
  • Removable values the person has added. Give a chip onRemove to make an input chip, such as the tags on a post or the recipients of a message.
  • Small contextual actions. An action chip with onClick, like "Sort" or "Near me", sits in a row beside the content it affects.
  • A static tag. With no handler a chip is plain text in a pill, not a control. Leave it that way when it only labels.
  • Labels of one or two words. Keep them in sentence case and short enough not to wrap; maxWidth truncates the rest.

When not to use

  • For the main action of a view. An action chip is small and contextual. Use a Button.
  • For a tool state in a toolbar. Bold or mute, often icon-only, is a ToggleButton.
  • For a count or a status attached to something else. An unread count on a bell or "New" on a card never does anything. Use a Badge or a Status.
  • For a setting such as view mode or period. A one-of-many that is not content reads better as a SegmentedControl or ToggleGroup.
  • For navigation. A chip is not a tab or a menu. Use Link, or render the chip as one (As a link) when it truly goes somewhere.
  • For typed multi-value entry. A field that turns typed text into chips is a Tokenizer.
  • For long labels or sentences. If the text needs more than a couple of words, use a Checkbox or a list.

Installation

Terminal
pnpm add @tessinaui/ui

Usage

example.tsx
import { Chip, ChipGroup } from "@tessinaui/ui";
example.tsx
<Chip>Design</Chip>                                          // tag — a <span>
<Chip selected={on} onSelectedChange={setOn}>Vegan</Chip>    // toggle — aria-pressed
<Chip onClick={openSort}>Sort</Chip>                         // action — a button
<Chip onRemove={() => remove(tag)}>Trading cards</Chip>      // input — with a remove button

<ChipGroup aria-label="Cuisine" selection="multiple" value={cuisines} onValueChange={setCuisines}>
  <Chip value="thai">Thai</Chip>
  <Chip value="indian">Indian</Chip>
</ChipGroup>

Examples

Default

With no handler a chip is a static tag: a <span> with no control semantics, no Tab stop and no hover state.

Kinds

What a chip is follows from what it is given — nothing, selected / defaultSelected, onClick or onRemove. There is no kind prop; the derived kind is exposed as data-kind and in the Kind by handler below.

Filter chips

Any number can be on. selection="multiple" lets the group own the selection; each chip opts in with value and becomes a toggle with aria-pressed.

Choice chips

One or none. selection="single" behaves like radio buttons that can also be cleared — pressing the selected chip deselects it.

Input chips

onRemove adds a real remove button, named "Remove" and described by the chip's label. Backspace or Delete removes the chip from either part; focus then moves to the next interactive chip, else the previous one, else to onRemoveFocusTarget — the field here.

Variants

Four fills at rest — outline, soft, solid and ghost. The neutral outline border is deliberately light, firming up on hover. A selected chip takes the solid primary fill whichever variant it started from, so the variants only differ while off.

Intents

At rest an intent tints the quiet variants with the family's light fill and ink (solid takes the full fill straight away). Selected, every intent becomes its solid fill with its own locked ink; none selects into primary.

Sizes

24 · 28 · 32 · 40 · 44 px — the chip's own scale, below the button family's. Interactive chips under 44 px grow an invisible tap extension on touch devices; a tag is not a control and is exempt.

Rounded

The family map: none 0 · sm 4 · md 6 · lg 8 · xl 12 px · full pill. A leading avatar follows the chip's corners.

With icon

leadingIcon and trailingIcon are decorative — wrapped in an aria-hidden span and sized from the chip's scale. While a toggle is selected the check mark takes the leading slot; showCheck={false} keeps the icon there instead.

With avatar

leadingAvatar draws a decorative avatar; the chip's label names the person. name drives the initials when there is no image, and presence / statusIntent put a dot on it. An avatar outranks the check mark, so a selected person chip keeps its face.

With status

leadingStatus takes { presence } (online / away / busy / offline, shape-coded) or { intent }. The dot is decorative; if the state matters, say it in the label.

With count

count renders "(n)" after the label in tabular figures and is part of the accessible name — "Shoes (12)".

Truncation

maxWidth caps the label, not the chip: the text truncates with an ellipsis and the full string goes into title. Slots keep their size.

Full width and alignment

fullWidth stretches the chip to its container; align decides where the content sits once the chip is wider than it. In an input chip the control takes the slack and the remove button stays pinned at the end.

As a link

render puts the chip on another element. An anchor host keeps link semantics — announced as a link, activated by Enter, listed among the page's links.

Scrolling row

overflow="scroll" keeps one row that scrolls sideways with the scrollbar hidden and both edges faded, so a cut-off chip reads as "more this way". Arrow keys still walk the whole row.

On a coloured surface

Wrap a region in <Surface> and every chip inside inherits tone="on-color", deriving its colours from the ink the surface paints. No per-chip prop.

Disabled

disabled dims every kind to 50 %, and the element that dims carries the state. Buttons take the native attribute — an input chip disables its remove button, and its label too when that label is a button; a tag and an input chip's shell take aria-disabled, so a remove-only label reads as inactive to assistive tech as well as to the eye, with the shell dropping pointer events for the whole chip. A disabled <ChipGroup> disables every chip inside it.

Skeleton

ChipSkeleton reads its height, width and radius from the chip's own scale. showLeading reserves the circle an avatar or status dot will occupy. Each one is a live region announcing "Loading".

RTL

Under dir="rtl" the leading slot sits on the right, the remove button moves to the left end, and ArrowLeft walks the group forward.

API Reference

Chip

Extends the HTML attributes of the element it renders. In an input chip the extra attributes (id, aria-*, data-*) go to the label element — a <button> when the chip is activatable, otherwise a <span> — while className, style and dir go to the shell around it and the remove button.

PropTypeDefaultDescription
childrenReactNode—The label. It names the control, so keep it text.
variant"outline" | "soft" | "solid" | "ghost""outline"Fill at rest
intent"none" | "error" | "warning" | "success" | "info""none"Semantic colour — a tint at rest, the solid fill while selected
size"xs" | "sm" | "md" | "lg" | "xl""md"24 · 28 · 32 · 40 · 44 px
rounded"none" | "sm" | "md" | "lg" | "xl" | "full""full"Corner treatment; the leading avatar follows it
tone"default" | "on-color"inherited from <Surface>Colour recipe for a coloured or dark surface
selectedboolean—Controlled pressed state. Makes the chip a toggle (aria-pressed)
defaultSelectedbooleanfalseUncontrolled initial pressed state. Makes the chip a self-managed toggle
onSelectedChange(selected: boolean, event: SyntheticEvent) => void—Fired when a toggle chip is pressed. Makes the chip a toggle
onClickMouseEventHandler<HTMLElement>—A plain action — makes the chip a button. Also fires on a toggle
onKeyDownKeyboardEventHandler<HTMLElement>—Runs before the chip's own Backspace/Delete handling; call preventDefault() to suppress it
onRemove(event: SyntheticEvent) => void—Renders a remove button — makes the chip an input chip
removeLabelstring"Remove"Accessible name of the remove button; the chip's label is appended via aria-describedby
onRemoveFocusTargetRefObject<HTMLElement | null> | () => HTMLElement | null—Where focus goes when the last interactive chip of a row is removed (the row's input, typically)
valuestring—Identifies the chip inside a selecting <ChipGroup>; makes it a toggle there
disabledbooleanfalseNative disabled on buttons, aria-disabled + out of the tab order on an anchor host; aria-disabled on a tag and on an input chip's shell
renderReactElement—Render the control into another element — an <a> keeps link semantics
showCheckbooleantrueShow a check mark while selected, in place of the leading icon
leadingIconReactNode—Decorative icon before the label
leadingAvatarChipAvatarDef—Decorative avatar before the label — see below
leadingStatusChipStatusDef | StatusVariant—A status dot: { presence } / { intent }. The StatusVariant string form is deprecated
showLeadingIconbooleantrue(deprecated) Hides leadingIcon / leadingStatus when false. Omit the prop instead
trailingIconReactNode—Decorative icon after the label (and after the count)
countnumber—A count, rendered (n) after the label
maxWidthnumber | string—Truncate the label with an ellipsis past this width; the full text goes in title
fullWidthbooleanfalseStretch to the container
align"start" | "center""center"Content alignment when the chip is wider than its content
dir"ltr" | "rtl"—Text direction

Leading slot priority, when more than one is given: avatar → check → status → icon.

ChipAvatarDef

FieldTypeDescription
srcstringImage source
namestringDrives the initials. The chip's label names the person; the avatar is decoration
initialsstringOverride the initials derived from name
presence"online" | "away" | "busy" | "offline"Presence dot on the avatar
statusIntent"none" | "error" | "warning" | "success" | "info"Intent dot on the avatar (ignored when presence is set)
identitystring | numberStable id for the avatar's own hue, so one person keeps one colour wherever their initials show
statusStatusVariant(deprecated) Use presence / statusIntent

ChipStatusDef

FieldTypeDescription
presence"online" | "away" | "busy" | "offline"Where someone is — shape-coded. Wins over intent
intent"none" | "error" | "warning" | "success" | "info"What state something is in

ChipGroup

A labelled role="group" with one Tab stop. Either aria-label or aria-labelledby is required; TypeScript enforces it.

PropTypeDefaultDescription
aria-label / aria-labelledbystring—The group's name — one of the two is required
gap"sm" | "md" | "lg""md"Gap between chips: 4 · 8 · 12 px. On touch devices, wrapped rows stay at least 12 px apart so neighbouring tap extensions never overlap
overflow"wrap" | "scroll""wrap"Wrap onto new lines, or keep one row that scrolls sideways with an edge fade
flushbooleanfalseoverflow="scroll" only. The scroll row bleeds 12 px past its box on each side to keep room for focus rings, tap extensions and its edge fade; flush keeps that room inside the row instead, for a row that spans its container edge to edge with no gutter (a phone screen)
wrapboolean—(deprecated) wrap={false} maps onto overflow="scroll"
selection"none" | "single" | "multiple""none"Let the group own which chips are selected. Chips opt in with value. single is one or none, multiple any number. The three props below are typed by this mode and are not allowed for "none"
valuesingle: string | null · multiple: string[]—Controlled selection
defaultValuesingle: string | null · multiple: string[]null / []Uncontrolled initial selection
onValueChangesingle: (value: string | null, event: SyntheticEvent) => void · multiple: (value: string[], event: SyntheticEvent) => void—Fired with the next selection — no cast needed at the call site
disabledbooleanfalseDisables every chip inside
dir"ltr" | "rtl"—Text direction; arrow keys follow it

ChipSkeleton

PropTypeDefaultDescription
size"xs" | "sm" | "md" | "lg" | "xl""md"Same scale as the chip
rounded"none" | "sm" | "md" | "lg" | "xl" | "full""full"Same map as the chip
showLeadingbooleanfalseReserve a leading avatar/dot circle
showRemovebooleanfalseReserve an input chip's remove button, a square of the chip's height. Set it whenever the real chips are removable, or the row wraps differently once they load
charsnumber—Length of the real label, in characters; the bar is sized from it. Without it, a per-size default width

Kind by handler

You passdata-kindWhat rendersWhat VoiceOver announces
NothingtagA <span> — no role, no Tab stop, no hover or press, no tap extensionThe text, as part of the surrounding content: "Design"
selected, defaultSelected, onSelectedChange, or value inside a selecting <ChipGroup>toggle<button type="button" aria-pressed> with a leading check while selected"Vegan, toggle button, pressed"
onClick or render aloneaction<button type="button">, or the render element (an <a> stays a link)"Sort, button" — or "Avatar, link"
onRemoveinputA shell holding the label and a separate remove <button> named by removeLabel and described by the label. The label is a <span> unless the chip is also activatable"Trading cards", then "Remove, button, Trading cards"

onRemove wins over everything else: an input chip that also has selected keeps aria-pressed on its control. A toggle with onClick fires both.

Data attributes

AttributeOnValues
data-slotroot, and the partschip (root, or the shell of an input chip), chip-control, chip-remove, chip-label, chip-check, chip-count, chip-group, chip-skeleton
data-kindroottag | toggle | action | input
data-variant / data-intent / data-size / data-tonerootThe resolved prop
data-selected / data-disabledrootPresent (empty) while selected / disabled — target [data-selected], not ="true"
data-selection / data-overflowgroupsingle | multiple (absent for none) / wrap | scroll

Exports

CHIP_SIZE and CHIP_ROUNDED are the scale maps the chip and its skeleton read. chipFill({ variant, intent, selected, tone, interactive }) returns the colour classes for one chip, every token named — for a chip-shaped thing that is not a chip. useChipGroup() returns the nearest group's context (selection, isSelected, toggle, disabled), or null outside one.

Accessibility

The accessible name is the label, always. Icons, avatars and status dots inside a chip are decorative and hidden from assistive technology; if something matters, it is in the text.

Patterns

  • A toggle chip follows the WAI-ARIA Button pattern, toggle variant: a <button> with aria-pressed that is true while selected. The check mark is decoration.
  • An input chip is a label and a remove control in one shell. The remove control is a real <button> named by removeLabel ("Remove") and described by the chip's label through aria-describedby, so a row of them never reads as five identical "Remove" buttons. The label is a control only if you also give the chip something to do — pass onClick (or render, or make it a toggle) and it becomes a button; with onRemove alone it is a <span>, because a button whose activation does nothing is a button in name only. Both parts are arrow-key stops either way, and Backspace or Delete removes the chip from both.
  • A group is role="group" with a required name and a roving tabindex — the APG practice for a composite: the row is one Tab stop and arrow keys move inside it. Static tags carry no marker and are not stops.
  • A tag is not a control. A <button> that does nothing is announced as a button, takes a Tab stop, and leaves the user pressing it and wondering. Text that only labels stays text — which also exempts it from the 44 px floor.

Keyboard

KeyResult
TabMoves focus onto a chip — or onto a group, which is a single stop (the last-focused chip)
→ / ←In a group: the next / previous chip control or remove button, wrapping at the ends. Reversed under dir="rtl"
Home / EndIn a group: the first / last item
Space / EnterActivates a toggle or action chip (a native <button>; an anchor host takes Enter only, as links do)
Backspace / DeleteOn an input chip's control: removes it. Focus moves to the next interactive chip, else the previous, else onRemoveFocusTarget — static tags are skipped

What is announced

VoiceOver + Safari, 2026-09-14. Arriving works: "Dietary group", "Vegan selected toggle button", "Vegetarian toggle button". On an input chip it reads "Remove Trading cards button", and removing that chip moves focus to the next one ("Vintage group"). Pressing a toggle chip is silent. The chip does toggle, and re-reading it says "Vegetarian selected toggle button". A plain HTML <button aria-pressed> is silent in the same way, so this is how VoiceOver + Safari treats every toggle button, not a chip defect. The rest of this list is expected from the markup, not a recorded run.

  • Tag: "Design" — read as text, not as a control.
  • Toggle: "Vegan selected toggle button" / "Vegetarian toggle button" (recorded). With a count: "Shoes (12), toggle button".
  • Action: "Sort, button". As a link: "Avatar, link".
  • Input chip: "Trading cards" — text, not a button, unless the chip is also clickable — then on the remove button "Remove, button, Trading cards". A custom removeLabel="Remove filter" reads "Remove filter, button, Trading cards".
  • Interactive person chip: "Jane Doe, button" — the avatar is aria-hidden; its name only drives the initials. As a tag it is simply "Jane Doe".
  • Group: "Dietary, group" on entry.
  • Skeleton: a role="status" live region announcing "Loading".

Touch and pointer

  • Below 44 px, every interactive chip carries an invisible vertical tap extension on touch devices (touch:) that brings the hit box to 44 px while the visual stays compact — the way IconButton does it. The remove button gets its own 44 px target, grown inward over the label so it never hangs past the chip's edge. Tags get none.
  • The focus ring is 2 px with a page-coloured offset. Inside an input chip both buttons draw inset rings, so the shell can never clip them. Those rings take the chip's own ink, which holds on every fill: a solid or selected chip, and a chip on a coloured <Surface>.
  • A neutral outline chip draws its border from --outline-border at 60 % in light and 75 % in dark, returning to full strength on hover. That resting value is below the 3:1 WCAG 1.4.11 asks of a control's boundary, and it is a deliberate design decision: a chip is low-emphasis and carries a visible label — 19.8:1 inside the pill — where a full-strength outline around every filter in a row reads as a grid of boxes. The focus ring (5.2:1) is unchanged, and a selected chip paints a solid fill. If your product needs the boundary itself to clear 3:1, variant="solid" with an intent is the only option that gets there — error (4.77:1 light / 5.20 dark), success (3.22 / 8.93) and info (5.20 / 7.51). Neutral solid reaches 1.48 / 1.91 and light warning only 2.17, and soft does not carry the boundary at all (1.04–1.34:1 across every intent and both themes). For anything else, supply your own border. The button family keeps --outline-border at full strength.
  • Intent outlines sit on the intent's light fill, which carries the boundary instead.
  • disabled uses the native attribute on buttons, aria-disabled + tabindex="-1" on an anchor host, and aria-disabled on a tag and on an input chip's shell. The 50 % dim takes a label under 4.5:1, which WCAG 1.4.3 allows only for an inactive component, so the state has to be one assistive tech — and axe — can read; data-disabled is not. The group's arrow keys skip disabled items.

Guidelines

Copy rules

  • Labels are one or two words in sentence case — "Gluten-free", "Near me" — never a sentence, and never with trailing punctuation.
  • Keep the label identical on and off. The state is carried by aria-pressed and the fill; "Vegan" that becomes "Vegan ✓" changes width mid-click.
  • Put counts in count, not in the text, so they render in tabular figures and stay separable from the name.
  • Do not hand-write aria-label on a chip. The remove button is named "Remove" and described by the chip's label automatically; change the verb with removeLabel only when "Remove" is wrong ("Remove filter").
  • A person chip's label is the person's name. The avatar's name is for initials, not for naming the chip.

Non-goals

  • A chip does not open anything by itself. An action chip that opens a menu is the trigger of a DropdownMenu — the chip supplies the button, the menu supplies the popup.
  • Input chips hold values; the field they come from is yours. Pass its ref as onRemoveFocusTarget so the last removal lands focus back in it.
  • There is no elevated variant, by decision. The lift is a fill, not a shadow: variant="soft" is the elevated chip — its plate clears the page at 1.26:1 light / 1.31:1 dark, which in dark beats every shadow in the Tailwind scale, because the dark card colour is the page colour and a shadow on it measures 1.01:1.
  • A person chip's own fill does not follow who the person is. Pass leadingAvatar.identity and the avatar takes a stable hue of its own.
  • The chip is not a multi-value input. A field that commits typed entries as chips is a Tokenizer, which renders these chips for you.
StatusAvatar

On this page

When to useWhen not to useInstallationUsageExamplesDefaultKindsFilter chipsChoice chipsInput chipsVariantsIntentsSizesRoundedWith iconWith avatarWith statusWith countTruncationFull width and alignmentAs a linkScrolling rowOn a coloured surfaceDisabledSkeletonRTLAPI ReferenceChipChipAvatarDefChipStatusDefChipGroupChipSkeletonKind by handlerData attributesExportsAccessibilityPatternsKeyboardWhat is announcedTouch and pointerGuidelinesCopy rulesNon-goals