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.
@garn/ui/tagOn 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.
Link
asChild — a chip that is a link (a tag that navigates).
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.
npx garn-ui add tag- 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
| Selector | State | Description |
|---|---|---|
[data-selected=true] | selected | A selected chip (selectionMode single/multiple) — a leading check + a stronger solid fill; the row carries aria-selected. |
[data-disabled] | disabled | A disabled chip (a Tag's `disabled` or the group's `disabledKeys`) — dimmed, not focusable, and skipped by roving navigation. |
[data-interactive=true] | interactive | A selectable or clickable chip — hover feedback + pointer cursor. |
| Property | Token | Tier |
|---|---|---|
fill | --garn-badge-brand-bgbrandable | semantic |
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
- Give every TagGroup an accessible name (label / aria-label).
- Set a stable
valueon 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 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
Give the TagGroup an accessible name (a `label`, or aria-label / aria-labelledby).
when a TagGroup has no `label`, `aria-label`, or `aria-labelledby`
- 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.