User Interface (UI)
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
- List — A
<ul>, named like "Labels", that wraps its tags onto as many rows as they need. - Item — An
<li>per tag, holding the shipped Tag with all of its own parts. - Gap — The space between tags in a row, and between rows, which grows on touch screens.
- 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 */}
| Size | Tag height | Gap | Space between rows | On touch screens |
|---|---|---|---|---|
xs | 18px | 4px | 6px | 26px |
sm | 20px | 4px | 4px | 24px |
md | 24px | 4px | 4px | 20px |
lg | 28px | 4px | 4px | 16px |
xl | 32px | 4px | 4px | 12px |
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.
<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" />
| State | Selector | Recommended look |
|---|---|---|
| Removable | [data-slot=remove] in each tag | Each tag's remove button, as the Tag draws it |
| Disabled | [data-disabled] on each tag | Every tag at 50% opacity, without its remove button, as the Tag draws it |
| Read-only | [data-readonly] on each tag | Every 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-visible | A 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.
| Token | Default | Part |
|---|---|---|
--tag-group--gap | 0.25rem | Gap |
--tag-group--item--height | 1.5rem, per size | Gap, empty state |
--tag-group--row-gap | 1.5rem minus the tag's height, or 2.75rem on touch screens, and never less than the gap | Gap |
--tag-group--empty--color | --color--neutral-text at 75% | Empty state |
--tag-group--empty--font-size | 0.75rem, per size | Empty 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
| Rule | Level | Scope | Requirement | Basis | Check |
|---|---|---|---|---|---|
tag-group/uses-tag | Must | Component | Each item holds the Tag, and the group sets none of its variables and draws no tag, button or icon of its own | Open Components | Unit test, Review |
tag-group/sizes | Should | Component | It takes the Tag's five size names and passes its size to every tag | Open Components | Unit test |
tag-group/wrap | Must | Component | Tags wrap onto as many rows as they need, with no horizontal scroll at 320px wide | WCAG 1.4.10 (AA) | Emulation, 200% zoom |
tag-group/row-gap | Must | Component | Rows 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 row | WCAG 2.5.8 (AA), 2.5.5 (AAA) | Emulation |
tag-group/stable-height | Should | Component | The empty state is as tall as a tag, so removing the last tag doesn't move what's below the group | Open Components | Visual regression test |
tag-group/contrast | Must | Both | The empty state's text has at least 4.5:1 against what's behind it, and its focus ring at least 3:1 | WCAG 1.4.3, 1.4.11 (AA) | Contrast checker |
tag-group/tokens | Must | Component | The group sets its look through --tag-group--… token paths in the components layer | Open Components | Stylelint |
tag-group/forced-colors | Must | Component | The empty state's text and the focus ring on it and on the list stay visible in forced colours mode | Open Components | Emulation |
Overview
How to build a group of tags that screen readers name and count, that wraps without crowding touch targets, and that keeps keyboard users in place as tags are removed.
User Experience
How a tag group behaves for every person, input and setting, from what screen readers hear to where focus goes when a tag is removed.