User Interface (UI)
Let's start with how a tag looks: its parts, its sizes, its icon, how it shows its states and the variables that restyle each part.
Anatomy
- Container — The root, a
<span>, which draws the background and holds the parts. - Leading icon — Optional. An icon before the label, hidden from assistive technologies.
- Label — The text, which holds the link when the tag is one.
- Remove button — Optional. A
<button>after the label, named after it, like "Remove Bug". - Focus ring — An outline around the whole tag when its link has focus, drawn outside it so nothing moves.
Every tag has a container and a label. The leading icon renders when you give it one, and the remove button when the tag is removable, as long as it's neither disabled nor read-only. The link and the remove button sit side by side, never one inside the other, since HTML doesn't allow a button inside a link (see Don't nest interactive elements). You can see the order of the parts in the DOM contract.
Sizes
A Tag has the Input's five size names, and at each one it's as tall as the Select's tags at that size, so a tag on its own looks the same as one in a Select. We recommend size over setting a height, since it scales the text and the remove button together.
<Tag size="xs" label="Bug" removable onRemove={remove} />
<Tag size="sm" label="Bug" removable onRemove={remove} />
{/* …md, lg and xl */}
| Size | Height | Font size | Padding |
|---|---|---|---|
xs | 18px | 11px | 4px |
sm | 20px | 12px | 8px |
md | 24px | 12px | 8px |
lg | 28px | 14px | 8px |
xl | 32px | 14px | 8px |
Heights are minimums, so a tag grows with larger text rather than clipping it. The remove button is as wide as the tag is tall, and at every size it takes a press over at least 24 × 24px, which Contrast and size explains.
Icons
A tag can have an icon before its label, like a label's symbol on an issue, which helps people scan a row of tags. The leading slot takes it, and the Tag sizes it to the text with --tag--icon--size.
<Tag label="Bug" leading={<BugIcon />} />
<Tag label="Feature" leading={<SparklesIcon />} />
<Tag label="Docs" leading={<BookOpenIcon />} />
Icons are decoration, so they're hidden from assistive technologies, and the label alone names the tag (WCAG 1.1.1). Keep it that way, since an icon that means something the label doesn't say is lost on anyone who can't see it. The Select's tags take their icon through a slot called icon, because it's the option's icon there, shown in the panel too. On its own, the Tag calls it leading, like the Button and the Input.
States
A plain tag has no hover or focus, since there's nothing to press. A tag that's a link, or that's removable, has hover and focus on the parts people press, and any tag can be disabled or read-only.
<Tag label="Bug" />
<Tag label="Bug" href="/labels/bug" />
<Tag label="Bug" removable onRemove={remove} />
<Tag label="Bug" href="/labels/bug" removable disabled />
<Tag label="Bug" href="/labels/bug" removable readonly />
| State | Selector | Recommended look |
|---|---|---|
| Link | :has(> [data-slot=label] a) on the root | A border at 55% of the text colour, which a plain tag doesn't have |
| Hover | a[href]:hover or [data-slot=remove]:hover, inside @media (hover: hover) | On a link, the background at 16% of the text colour, up from 10%, and the label underlined. On the remove button, the same background behind it |
| Focus | a:focus-visible::after, and [data-slot=remove]:focus-visible | A 2px outline in --color--focus, 2px outside the tag for its link, and inside the remove button's edge |
| Disabled | [data-disabled] | The whole tag at 50% opacity, without its remove button |
| Read-only | [data-readonly] | The tag's usual look, without its remove button |
A link tag has a border, so people can tell it from a plain tag without relying on its colour, which colour blind people may not see (WCAG 1.4.1). The border is drawn inside the tag, so a link tag is the same size as a plain one, and as the Select's tags. None of the states change the tag's size or position, so a row of tags doesn't shift as the pointer moves along it, and you'll find how they behave under Every state.
Tokens
The Tag's look comes from variables on its root, which you can set from a class. Tokens are named as token paths: --tag--… for the tag, then a group per part or state, like --tag--icon--… and --tag--link--…. They're in the components layer, so a class of yours wins without any specificity tricks.
| Token | Default | Part |
|---|---|---|
--tag--height | 1.5rem, per size | Container |
--tag--padding | 0.5rem, per size | Container |
--tag--gap | 0.25rem | Leading icon |
--tag--radius | 0.25rem | Container |
--tag--font-size | 0.75rem, per size | Label |
--tag--background | The text colour at 10% | Container |
--tag--color | --color--neutral-text | Label |
--tag--border-color | transparent | Container |
--tag--icon--size | 1em | Leading icon |
--tag--link--border-color | The text colour at 55% | Container, as a link |
--tag--hover--background | The text colour at 16% | Link and remove button, on hover |
It reads --color--neutral-text and --color--focus from your theme. A Tag at a size has the same height, padding, radius and font size as the Select's tags at that size, and each of their tokens has a Tag token with the same job. The one default that differs is the text colour: a Select's tag inherits it from the field, while a Tag sets --color--neutral-text itself, since it isn't inside one.
| The Select's token | The Tag's token |
|---|---|
--select--tag--height | --tag--height |
--select--tag--padding | --tag--padding |
--select--tag--radius | --tag--radius |
--select--tag--font-size | --tag--font-size |
--select--tag--background | --tag--background |
--select--tag--color | --tag--color |
--select--tag--icon--size | --tag--icon--size |
The label has a contrast of at least 4.5:1 against the tag's background, which is the text colour at 10% over the page. With our reference tokens, from the Tailwind CSS palette, that's 12.3:1 on white and 10.9:1 on zinc-900. A link tag's border is the text colour at 55%, like the Input's, which comes to 3.5:1 against white and 5:1 against zinc-900, so people can see it's there (WCAG 1.4.11). The sections below restyle each part, with its variables and, where it has one, its slot.
Container and label
The root draws the tag, so its tokens set the background, the text colour and the radius. The default slot changes what the label says, and the remove button keeps its name, which comes from label.
<Tag className="brand" label="Bug" removable onRemove={remove} />
.brand {
--tag--radius: 1rem;
--tag--background: var(--color--primary);
--tag--color: var(--color--primary-contrast);
}
If you colour tags by what they mean, like a label's colour on an issue, set --tag--background and --tag--color from a class per category, and check each pairing for 4.5:1. The colour can't be the only way people tell two tags apart, so the label still has to say it (WCAG 1.4.1).
Leading icon
The leading slot takes the icon, and --tag--icon--size and --tag--gap set its size and the space after it.
<Tag className="brand" label="Bug" leading={<TagIcon />} removable onRemove={remove} />
.brand {
--tag--icon--size: 0.875rem;
--tag--gap: 0.375rem;
}
Remove button
The remove slot changes the remove button's icon, and the button and its name stay, so a screen reader still says "Remove Bug". --tag--hover--background sets what shows behind it on hover.
<Tag className="brand" label="Bug" removable remove={<CircleXIcon />} onRemove={remove} />
.brand {
--tag--hover--background: color-mix(in srgb, var(--color--error) 20%, transparent);
}
UI Checklist
| Rule | Level | Scope | Requirement | Basis | Check |
|---|---|---|---|---|---|
tag/sizes | Should | Component | It takes the Input's five size names, each as tall as the Select's tag at that size | Open Components | Unit test, Visual regression test |
tag/target-size | Must | Component | The remove button and a link label each take a press over at least 24 × 24px, and at least 44px tall on a touch screen | WCAG 2.5.8 (AA), 2.5.5 (AAA) | Emulation |
tag/contrast | Must | Both | The label has at least 4.5:1 against the tag's background, and the focus ring at least 3:1 against what's behind it | WCAG 1.4.3, 1.4.11 (AA) | Contrast checker |
tag/focus-visible | Must | Component | The link and the remove button show an outline at least 2px thick on :focus-visible | WCAG 2.4.7 (AA) | Keyboard |
tag/resizable | Must | Component | The tag is sized with a min-height in rem, so it grows with its text | Open Components, beyond WCAG 1.4.4 (AA) | 200% zoom |
tag/decorative-icons | Must | Component | The leading icon and the remove button's icon are hidden from assistive technologies | WCAG 1.1.1 (A) | Unit test, ARIA snapshot |
tag/link-distinct | Should | Component | A link tag looks different from a plain one by more than its colour, like a border | Open Components, beyond WCAG 1.4.1 (A) | Visual regression test |
tag/truncate | Should | Component | A long label stays on one line and ends in an ellipsis, while its full text stays in the DOM | Open Components | Unit test, Visual regression test |
tag/stable-size | Should | Component | Hover, focus and being a link don't change the tag's size or position | Open Components | Visual regression test |
tag/tokens | Must | Component | Each part sets its look through --tag--… token paths in the components layer | Open Components | Stylelint |
tag/forced-colors | Must | Component | The tag's boundary, its focus ring and its disabled state stay visible in forced colours mode | Open Components | Emulation |
FAQ
color later wouldn't break anything, if a design system needs one.Overview
How to build a tag that labels what something is about, that people can remove or follow as a link, with a keyboard and a screen reader, and that agents can check.
User Experience
How a tag behaves for every person, input and setting, from what screen readers hear to removing it, following it and every state in between.