Tag Group

User Interface (UI)

How a tag group looks, from its list and its sizes to the space between rows, its states and the tokens that restyle it.

Let's start with how a tag group looks: its parts, its sizes, the space between its tags, how it shows its states and the variables that restyle it. Each tag in it looks the way the Tag says, and the group never restyles one.

Anatomy

  1. List — A <ul>, named like "Labels", that wraps its tags onto as many rows as they need.
  2. Item — An <li> per tag, holding the shipped Tag with all of its own parts.
  3. Gap — The space between tags in a row, and between rows, which grows on touch screens.
  4. Empty state — The text, like "No tags", that shows in place of the list once it has no tags.

Every tag group has a root, a <div>, which holds the list while there are tags, and the empty state once there are none. The list and the empty state never show together, so a screen reader never counts the empty state as a tag. You can see the order of the parts in the DOM contract.

Sizes

A Tag Group takes the Tag's five size names and passes its size to every tag, so a group's tags are all the same height. The space between rows depends on that height, as Gap and rows explains, so we recommend size on the group over setting a height on its tags.

  • Bug
  • Docs
  • Accessibility
  • Good first issue
  • Help wanted
xs
  • Bug
  • Docs
  • Accessibility
  • Good first issue
  • Help wanted
sm
  • Bug
  • Docs
  • Accessibility
  • Good first issue
  • Help wanted
md
  • Bug
  • Docs
  • Accessibility
  • Good first issue
  • Help wanted
lg
  • Bug
  • Docs
  • Accessibility
  • Good first issue
  • Help wanted
xl
<TagGroup size="xs" label="Labels" tags={labels} removable onRemove={remove} />
<TagGroup size="sm" label="Labels" tags={labels} removable onRemove={remove} />
{/* …md, lg and xl */}
SizeTag heightGapSpace between rowsOn touch screens
xs18px4px6px26px
sm20px4px4px24px
md24px4px4px20px
lg28px4px4px16px
xl32px4px4px12px

The heights are the Tag's, and the empty state takes the same height and font size, so swapping one for the other doesn't move what's below the group.

Gap and rows

Tags sit 4px apart in a row, and wrap onto the next row when they run out of room, so the group never scrolls sideways, even at 320px wide (WCAG 1.4.10). Rows sit far enough apart that the tags' hit areas don't cover the tags on the next row. A Tag takes a press over at least 24px of height, which reaches past a tag shorter than that, so rows sit at least 24px minus the tag's height apart (WCAG 2.5.8). On touch screens, where the hit areas reach 44px, rows sit at least 44px minus the tag's height apart (WCAG 2.5.5), as the Select does for its own tags.

States

The group's states are its tags' states, set for all of them at once, plus one of its own: empty. Each tag can also be disabled or read-only on its own, through its entry in tags.

  • Bug
  • Docs
Removable
Links
Disabled
Read-only

No labels

Empty
<TagGroup label="Labels" tags={labels} removable onRemove={remove} />
<TagGroup label="Labels" tags={linkedLabels} />
<TagGroup label="Labels" tags={linkedLabels} removable disabled />
<TagGroup label="Labels" tags={linkedLabels} removable readonly />
<TagGroup label="Labels" tags={[]} emptyLabel="No labels" />
StateSelectorRecommended look
Removable[data-slot=remove] in each tagEach tag's remove button, as the Tag draws it
Disabled[data-disabled] on each tagEvery tag at 50% opacity, without its remove button, as the Tag draws it
Read-only[data-readonly] on each tagEvery tag's usual look, without its remove button
Empty[data-slot=empty], in place of [data-slot=list]The empty state's text at 75% of the text colour, as tall as a tag
Focus[data-slot=empty]:focus-visible, or [data-slot=list]:focus-visibleA 2px outline in --color--focus, 2px outside its edge, once focus lands there after a removal

Hover and focus on each tag's link and remove button are the Tag's own, so you'll find them on its States. The group only draws focus on the empty state and on the list, which take it from a script after a removal, as Move focus when a tag goes explains, and never from Tab. It's drawn in --color--focus, which has at least 3:1 against what's behind it (WCAG 1.4.11): 6.5:1 on white and 5.7:1 on zinc-900 with our reference tokens.

Tokens

The Tag Group's look comes from variables on its root, which you can set from a class. Tokens are named as token paths: --tag-group--… for the group, then a group per part, like --tag-group--empty--…. They're in the components layer, so a class of yours wins without any specificity tricks.

TokenDefaultPart
--tag-group--gap0.25remGap
--tag-group--item--height1.5rem, per sizeGap, empty state
--tag-group--row-gap1.5rem minus the tag's height, or 2.75rem on touch screens, and never less than the gapGap
--tag-group--empty--color--color--neutral-text at 75%Empty state
--tag-group--empty--font-size0.75rem, per sizeEmpty state

It reads --color--neutral-text and --color--focus from your theme, and sets none of the Tag's variables, so a class that restyles the Tag, from the Tag's tokens, restyles every tag in the group the same way. --tag-group--item--height is the Tag's height at the group's size, which the space between rows depends on, so if you change your tags' height, set it to match.

The empty state's text is the text colour at 75%, which comes to 6.6:1 on white and 8.3:1 on zinc-900 with our reference tokens, from the Tailwind CSS palette, so it reads as quieter than the tags and still has at least 4.5:1 (WCAG 1.4.3). The sections below restyle each part, with its variables and, where it has one, its slot.

List

--tag-group--gap sets the space between tags. Rows sit at least that far apart too, and further when the tags' hit areas need it.

  • Bug
  • Docs
  • Accessibility
<TagGroup className="roomy" label="Labels" tags={labels} removable onRemove={remove} />
.roomy {
  --tag-group--gap: 0.5rem;
}

If you'd like more room between rows than the gap, set --tag-group--row-gap, keeping it at least 24px minus the tag's height, and 44px minus it on touch screens, so the hit areas still don't cover each other.

Empty state

The empty slot changes what the empty state says, like a hint on how to add a tag, and --tag-group--empty--color and --tag-group--empty--font-size set how it looks.

No labels yet. Add one from the menu.

<TagGroup className="labels" label="Labels" tags={[]} empty={<>No labels yet. Add one from the menu.</>} />
.labels {
  --tag-group--empty--color: var(--color--neutral-text);
  --tag-group--empty--font-size: 0.875rem;
}

Keep the empty state to text, and put an "Add label" button after the group instead. The empty state only shows while there are no tags, so a button inside it would disappear the moment it adds the first one, and take the focus of whoever pressed it with it.

UI Checklist

RuleLevelScopeRequirementBasisCheck
tag-group/uses-tagMustComponentEach item holds the Tag, and the group sets none of its variables and draws no tag, button or icon of its ownOpen ComponentsUnit test, Review
tag-group/sizesShouldComponentIt takes the Tag's five size names and passes its size to every tagOpen ComponentsUnit test
tag-group/wrapMustComponentTags wrap onto as many rows as they need, with no horizontal scroll at 320px wideWCAG 1.4.10 (AA)Emulation, 200% zoom
tag-group/row-gapMustComponentRows sit at least 24px minus the tag's height apart, and 44px minus it on a touch screen, so no tag's hit area covers a tag on the next rowWCAG 2.5.8 (AA), 2.5.5 (AAA)Emulation
tag-group/stable-heightShouldComponentThe empty state is as tall as a tag, so removing the last tag doesn't move what's below the groupOpen ComponentsVisual regression test
tag-group/contrastMustBothThe empty state's text has at least 4.5:1 against what's behind it, and its focus ring at least 3:1WCAG 1.4.3, 1.4.11 (AA)Contrast checker
tag-group/tokensMustComponentThe group sets its look through --tag-group--… token paths in the components layerOpen ComponentsStylelint
tag-group/forced-colorsMustComponentThe empty state's text and the focus ring on it and on the list stay visible in forced colours modeOpen ComponentsEmulation
Copyright © 2026