DocumentationComponentsTheme CreatorGitHub
Theme CreatorGitHubIntroduction
InstallationUsageTheming
ComponentsAccordionAction SheetAlertAlertDialogArea ChartAspectRatioAvatarBadgeBannerBar ChartBottom NavBreadcrumbButtonButtonGroupCalendarCardCarouselChartChatBubbleChatBubbleNewCheckboxChipCoachMarkCodeBlockCollapsibleColor PickerComboboxCommandContainerContextMenuDate PickerDividerDrawerDropdown MenuEmptyStateFABFieldFieldsetFile UploadFlexFormGridHoverCardIconButtonLabelLine ChartLinkMenubarMeterModalNavigation MenuNumberFieldOTP InputPaginationPickerPie ChartPopoverProgressPromptInputRadar ChartRadial ChartRadioRatingScroll AreaSearchSegmentedControlSelectShortcutSidebarSkeletonSliderCircularSliderMediaTrimmerSpacerSpinnerSplit ButtonStackStatusStepperSurfaceSwitchTableTabsTextareaTime PickerToastToggleButtonToggleGroupTokenizerToolbarTooltipTop Header DesktopTop Header Mobile
Contributing
Components

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/ui

Usage

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.

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 · 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
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)
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
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

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

  • 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; 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 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 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 (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) 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.

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 is selection="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-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 (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 multiple is the shipped multi-value control.
CheckboxCoachMark

On this page

PlaygroundInstallationUsageExamplesDefaultKindsFilter chipsChoice chipsInput chipsVariantsIntentsSizesRoundedWith iconWith avatarWith statusWith countTruncationFull width and alignmentAs a linkScrolling rowOn a coloured surfaceDisabledSkeletonRTLAPI ReferenceChipChipAvatarDefChipStatusDefChipGroupChipSkeletonKind by handlerData attributesExportsAccessibilityWhen to use which