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.
Playground
Installation
pnpm add @tessinaui/uiUsage
import { Chip, ChipGroup } from "@tessinaui/ui";<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 · 36 · 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.
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | — | 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 · 36 · 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 |
selected | boolean | — | Controlled pressed state. Makes the chip a toggle (aria-pressed) |
defaultSelected | boolean | false | Uncontrolled 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 |
onClick | MouseEventHandler<HTMLElement> | — | A plain action — makes the chip a button. Also fires on a toggle |
onKeyDown | KeyboardEventHandler<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 |
removeLabel | string | "Remove" | Accessible name of the remove button; the chip's label is appended via aria-describedby |
onRemoveFocusTarget | RefObject<HTMLElement | null> | () => HTMLElement | null | — | Where focus goes when the last interactive chip of a row is removed (the row's input, typically) |
value | string | — | Identifies the chip inside a selecting <ChipGroup>; makes it a toggle there |
disabled | boolean | false | Native 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 |
render | ReactElement | — | Render the control into another element — an <a> keeps link semantics |
showCheck | boolean | true | Show a check mark while selected, in place of the leading icon |
leadingIcon | ReactNode | — | Decorative icon before the label |
leadingAvatar | ChipAvatarDef | — | Decorative avatar before the label — see below |
leadingStatus | ChipStatusDef | StatusVariant | — | A status dot: { presence } / { intent }. The StatusVariant string form is deprecated |
showLeadingIcon | boolean | true | (deprecated) Hides leadingIcon / leadingStatus when false. Omit the prop instead |
trailingIcon | ReactNode | — | Decorative icon after the label (and after the count) |
count | number | — | A count, rendered (n) after the label |
maxWidth | number | string | — | Truncate the label with an ellipsis past this width; the full text goes in title |
fullWidth | boolean | false | Stretch 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
| Field | Type | Description |
|---|---|---|
src | string | Image source |
name | string | Drives the initials. The chip's label names the person; the avatar is decoration |
initials | string | Override 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) |
status | StatusVariant | (deprecated) Use presence / statusIntent |
ChipStatusDef
| Field | Type | Description |
|---|---|---|
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.
| Prop | Type | Default | Description |
|---|---|---|---|
aria-label / aria-labelledby | string | — | 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 |
wrap | boolean | — | (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" |
value | single: string | null · multiple: string[] | — | Controlled selection |
defaultValue | single: string | null · multiple: string[] | null / [] | Uncontrolled initial selection |
onValueChange | single: (value: string | null, event: SyntheticEvent) => void · multiple: (value: string[], event: SyntheticEvent) => void | — | Fired with the next selection — no cast needed at the call site |
disabled | boolean | false | Disables every chip inside |
dir | "ltr" | "rtl" | — | Text direction; arrow keys follow it |
ChipSkeleton
| Prop | Type | Default | Description |
|---|---|---|---|
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 |
showLeading | boolean | false | Reserve a leading avatar/dot circle |
Kind by handler
| You pass | data-kind | What renders | What VoiceOver announces |
|---|---|---|---|
| Nothing | tag | A <span> — no role, no Tab stop, no hover or press, no tap extension | The 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 alone | action | <button type="button">, or the render element (an <a> stays a link) | "Sort, button" — or "Avatar, link" |
onRemove | input | A 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
| Attribute | On | Values |
|---|---|---|
data-slot | root, and the parts | chip (root, or the shell of an input chip), chip-control, chip-remove, chip-label, chip-check, chip-count, chip-group, chip-skeleton |
data-kind | root | tag | toggle | action | input |
data-variant / data-intent / data-size / data-tone | root | The resolved prop |
data-selected / data-disabled | root | Present (empty) while selected / disabled — target [data-selected], not ="true" |
data-selection / data-overflow | group | single | 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>witharia-pressedthat 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 byremoveLabel("Remove") and described by the chip's label througharia-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 — passonClick(orrender, or make it a toggle) and it becomes a button; withonRemovealone 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
| Key | Result |
|---|---|
| Tab | Moves 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 / End | In a group: the first / last item |
| Space / Enter | Activates a toggle or action chip (a native <button>; an anchor host takes Enter only, as links do) |
| Backspace / Delete | On 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
- Tag: "Design" — read as text, not as a control.
- Toggle: "Vegan, toggle button, pressed" / "not pressed". 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; itsnameonly 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 and M3 do 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.
- A neutral
outlinechip draws its border from--outline-borderat 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 (CONVENTIONS §9): 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) andinfo(5.20 / 7.51). Neutralsolidreaches 1.48 / 1.91 and lightwarningonly 2.17, andsoftdoes 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-borderat full strength. - Intent outlines sit on the intent's light fill, which carries the boundary instead.
disableduses the native attribute on buttons,aria-disabled+tabindex="-1"on an anchor host, andaria-disabledon 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-disabledis not. The group's arrow keys skip disabled items.
When to use which
- Chip vs Badge. A Badge is a small count or status label attached to something else — unread on a bell, "New" on a card — and it never does anything. A chip stands on its own as a label and usually does something.
- Chip vs ToggleButton. A toggle chip is a ToggleButton in chip clothing — the same
aria-pressed. Use ToggleButton in toolbars for tool states (bold, mute, often icon-only); use chips where the options are content-facing words that wrap in a row — filters, choices, interests. - Chip vs Button. A chip never carries the primary action of a view. Action chips are small and contextual, in a row — suggestions, "Sort", "Near me". If it is the main thing to do, it is a Button.
- Chip vs tag. There is no Tag component: a tag is a chip with nothing to do. Give a chip a handler only when it actually does something, and leave it a tag otherwise.
- Choice vs filter. One-of-many is
selection="single", any-of-many isselection="multiple". A one-of-many that is a setting rather than content (view mode, period) reads better as a SegmentedControl or ToggleGroup.
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-pressedand 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-labelon a chip. The remove button is named "Remove" and described by the chip's label automatically; change the verb withremoveLabelonly when "Remove" is wrong ("Remove filter"). - A person chip's label is the person's name. The avatar's
nameis 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
onRemoveFocusTargetso the last removal lands focus back in it. - There is no
elevatedvariant, by decision (CONVENTIONS §9, 2026-08-23). 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 does not derive a colour from who the person is — the avatar identity palette is its own ticket (TES-106).
- The chip is not a multi-value input. A field that commits typed entries as chips (tokenizer) is its own component (TES-105); until it ships,
Combobox multipleis the shipped multi-value control.