Skip to content

Tag

Removable and selectable chips — dismissable filter tokens and pick-to-toggle option chips, with TagGroup coordinating roving-keyboard selection and removal across a set.

Stablev0.4.2added in v0.1.0@garn/ui/tag
On this page

Default

Plain chips — neutral, brand, and a leading-icon chip.

Tone × appearance

Every tone rendered against every appearance. Hover a cell for the data attributes it emits; click the copy icon for that combination's JSX.

Removable

Standalone dismissable filter tokens (onRemove).

Count

A trailing count (a composed Counter) and a custom trailing slot.

Shapes

sharp · rounded · pill.

Sizes

sm · md.

Single Select

A single-select TagGroup (disallowEmptySelection) — selected reads as a check + fill.

Multi Select

A multi-select TagGroup — toggle several chips.

Removable Group

Applied-filters: a TagGroup with onRemove and an empty state.

Selectable Removable

Selectable AND removable chips in one group — both affordances on one TagGroup.

Disabled

disabledKeys — a chip that can't be selected and is skipped by roving.

Truncate

A long label truncated with maxWidth + a native tooltip.

Installation

garn is copy-in — the garn CLI writes this component's source into your project, so you own and can edit it. It resolves any base files and installs the npm dependencies below for you.

terminal
npx garn-ui add tag
First time? Set up garn in your project
Registry
tag
Deps
class-variance-authority@radix-ui/react-slotlucide-react
Registry deps
utilsuse-controllable-statecounter

Import

import { TagGroup } from "@garn/ui/tag";

Anatomy

The parts this component is built from — compose them to assemble it.

TagGroup[data-slot="tag-group"]

The labelled, roving-tabindex grid that coordinates a set of Tags. Props: selectionMode ('none' | 'single' | 'multiple'), selectedKeys / defaultSelectedKeys / onSelectionChange, disabledKeys, disallowEmptySelection, onRemove (enables removal), label (auto-wired accessible name), empty (empty-state slot), and ambient tone/appearance/shape/size defaults each Tag may override. Selection and removal key off each Tag's value.

Props

The component's public props and their types.

appearance
Description
No description.
Type
"framed" | "outline" | "soft" | "solid"
Default
soft
shape
Description
No description.
Type
"pill" | "rounded" | "sharp"
Default
rounded
size
Description
No description.
Type
"md" | "sm"
Default
md
tone
Description
No description.
Type
"brand" | "danger" | "discovery" | "info" | "neutral" | "success" | "warning"
Default
neutral
asChild
Description
Render as the child element (a link/button chip), merging props.
Type
boolean
Default
false
count
Description
A trailing count, rendered as a Counter matched to the chip's tone/size (its appearance inverts for contrast).
Type
number
disabled
Description
Disable the chip — dims it and blocks selection / removal / click.
Type
boolean
Default
false
leading
Description
Leading content — icon, avatar, or dot.
Type
React.ReactNode
maxWidth
Description
Max label width when `truncate` is set (px number, or any CSS length).
Type
number | string
Default
200
onRemove
Description
Standalone removal handler (in a group, the group's `onRemove` is used).
Type
(value?: string) => void
removeLabel
Description
Accessible label for the remove button. Default: `Remove {label}`.
Type
string
trailing
Description
Trailing content — rendered after the label, before any remove button.
Type
React.ReactNode
truncate
Description
Truncate a long label with an ellipsis + native tooltip.
Type
boolean
Default
false
value
Description
Stable key for selection/removal. Required inside a selectable/removable group.
Type
string

Plus 278 inherited native <span> attributes.

TagGroup

appearance
Description
Ambient appearance inherited by child Tags (each Tag may override).
Type
TagAppearance
Default
"soft"
defaultSelectedKeys
Description
Uncontrolled initial selection.
Type
string[]
disabledKeys
Description
Disable these chips by `value` (in addition to a chip's own `disabled`).
Type
string[]
disallowEmptySelection
Description
Keep at least one chip selected (single mode can't toggle the last off).
Type
boolean
Default
false
empty
Description
Rendered when the group has no chips (e.g. all removed).
Type
React.ReactNode
label
Description
Convenience group label (auto-wired as the group's accessible name).
Type
React.ReactNode
onRemove
Description
Enable removal: called with the removed chip's `value`.
Type
(value: string) => void
onSelectionChange
Description
Called with the full set of selected `value`s whenever the selection changes.
Type
(keys: string[]) => void
selectedKeys
Description
Controlled selection (chip `value`s).
Type
string[]
selectionMode
Description
Whether and how chips can be selected.
Type
TagSelectionMode
Default
"none"
shape
Description
Ambient shape inherited by child Tags (each Tag may override).
Type
TagShape
Default
"rounded"
size
Description
Ambient size inherited by child Tags (each Tag may override).
Type
TagSize
Default
"md"
tone
Description
Ambient tone inherited by child Tags (each Tag may override).
Type
TagTone
Default
"neutral"

Plus 279 inherited native HTML attributes.

Styling

Target these data-slots and states, and remap these tokens, to restyle without forking the component.

[data-slot="tag"][data-slot="tag-leading"][data-slot="tag-label"][data-slot="tag-trailing"][data-slot="tag-remove"]

States

SelectorState
[data-selected=true]selected
[data-disabled]disabled
[data-interactive=true]interactive
PropertyTokenTier
fill--garn-badge-brand-bgbrandablesemantic

Private instance vars (never externalize): --t-soft-bg

When to use

Removable + selectable chips: dismissable filter tokens (applied filters, recipients, keywords) and pick-to-toggle option chips. Use a standalone <Tag onRemove> for a lone token, or a <TagGroup> to coordinate selection (selectionMode) and removal with roving-keyboard a11y across a set.

Reach for something else when

  • A static, non-interactive label or status (use Badge)
  • a numeric count (use Counter)
  • a single pick-to-change value (use BadgeSelect)
  • a few mutually-exclusive choices in a form (use RadioGroup)
  • a long single-select list (use Select).

Overview

Tag is the interactive chip — a keyword, label, or filter the user can remove or select, often in a TagGroup. It shares the chip family's tone × appearance × size vocabulary with Badge, but where Badge is a read-only marker, Tag carries affordances: a remove control, selected state, focus. Use it for editable label sets and filter chips; for a status you only display, use Badge, and for a single value the user picks from a menu, use BadgeSelect.

Guidelines

Use Tag when the chip is actionable. If users add, remove, or toggle it, it's a Tag — not a Badge. Make the remove control a real, labelled button ("Remove <name>") so it's reachable by keyboard and announced.

Group related tags and keep labels tight. A TagGroup communicates that the chips belong to one set (the filters on a list, the labels on an item); keep each label to a word or two so a row of them stays scannable.

Keep tone meaningful and removal forgiving. Reuse the chip tones consistently, and don't lean on color alone. When removing a tag has consequences, make it easy to undo rather than gating each removal behind a confirm.

Best practices

Do
  • Give every TagGroup an accessible name (label / aria-label).
  • Set a stable value on each Tag in a selectable or removable group — selection and removal key off it.
  • Let onRemove be the source of the remove affordance (don't hand-place a remove button); pass onRemove on the group for per-chip removal, or on a standalone Tag.
  • Use tone for intent and share it across the badge family (Badge / Counter / Tag) so a status reads the same everywhere.
  • Use asChild to make a chip a link (a tag that filters/navigates).
Don't
  • Don't use a Tag for a static label that can't be removed or selected — that's Badge.
  • Don't nest your own interactive element inside a clickable standalone Tag (use a TagGroup, whose role=row hosts the remove button safely).
  • Don't strip the focus ring or override the roving tabindex — they are the keyboard contract.
  • Don't reach for raw colors; tone resolves through the --garn-badge-* tokens (edit dark mode in the token layer).

Content guidelines

  • Keep chip labels short; enable truncate (+ maxWidth) for user-generated tags so a long label stays a chip.
  • Prefer a leading icon/avatar over color to distinguish chip kinds.

Troubleshooting

Selecting or removing a chip in a TagGroup does nothing.

Cause. The Tag has no value — selection and removal key off it.

Fix. Give each Tag a stable value; selectedKeys / onSelectionChange / onRemove / disabledKeys all reference it.

No remove button appears on the chips.

Cause. Removal is gated by an onRemove handler, not a boolean.

Fix. Pass onRemove on the TagGroup (per-chip removal) or on a standalone Tag; the remove button is then auto-rendered.

A clickable standalone Tag with its own remove button triggers a nested-interactive a11y error.

Cause. A standalone clickable chip is a <button>; a second button inside it nests interactives.

Fix. Use a TagGroup — its chips are role=row (non-interactive), which can host a focusable remove button safely.

A dev console warns that the TagGroup has no accessible name.

Cause. The group has no label, aria-label, or aria-labelledby.

Fix. Pass label (auto-wired as the group's name) or an aria-label.

Editing dark-mode chip colors in the component has no effect.

Cause. Tones resolve through the --garn-badge-* component tokens; dark values live in the token layer.

Fix. Adjust dark mode in the garn theme CSS (.dark block) — never with raw ramps in tag.tsx.

Accessibility

Role
grid
Focus
2px ring in --garn-ring on the chip (the roving row) and the remove button; roving tabindex keeps a TagGroup a single tab stop.

Accessibility requirements

warn

Give the TagGroup an accessible name (a `label`, or aria-label / aria-labelledby).

when a TagGroup has no `label`, `aria-label`, or `aria-labelledby`

A1.4.1Use of ColorAA1.4.3Contrast (Minimum)A2.1.1KeyboardAA2.4.7Focus VisibleA4.1.2Name, Role, Value
  • A standalone Tag has no implicit role (a <span>), becomes a <button> when clickable, or the asChild element (e.g. an <a>); keep a readable text label and don't encode meaning by color alone.
  • A TagGroup is a role=grid with one roving tab stop; each Tag is a role=row (aria-selected in single/multiple mode) wrapping role=gridcell content and a gridcell remove button — the grid pattern is what lets a chip carry a focusable remove button without a nested-interactive violation.
  • Keyboard: arrows + Home/End rove between chips (logical, so they mirror under RTL); Enter/Space toggle selection; Backspace/Delete remove the focused chip and move focus to its neighbour (or the previous chip if it was last, or the group if none remain).
  • Every remove button is named from its chip ('Remove {label}', overridable via removeLabel). The group is multiselectable only in 'multiple' mode (aria-multiselectable).
  • Give the TagGroup an accessible name via `label` (auto-wired as aria-labelledby), `aria-label`, or `aria-labelledby` — a group of chips without a name is anonymous to assistive tech.