Tag
Tag is a compact label for a keyword, filter, person or selection. Use it for filters and selected values people can remove.
Beta until the focus indicator (C5), the dismiss-icon contrast (pending a decision) and disabled-text contrast (C8) are resolved, and the sizes are measured in a browser. Everything else on this page is implemented and tested.
Preview
Change the controls to try every supported combination. The tag is live: click it when it's a toggle, and remove it when it's removable.
import { Tag } from '@nuton/ui'
<Tag
leading={<RiBookOpenLine />}
sublabel="(4)"
onRemove={removeTopic}
>
Biology
</Tag>Installation
Availability
@nuton/ui and @nuton/tokens are private workspace packages. They aren't published to a registry yet (D4: registry deferred; D12: distribution waits on the provenance review). Inside the Nuton monorepo, add them as workspace dependencies. The commands below apply once the packages are published.
1. Add the packages
pnpm add @nuton/ui @nuton/tokens @remixicon/reactnpm install @nuton/ui @nuton/tokens @remixicon/reactyarn add @nuton/ui @nuton/tokens @remixicon/reactPeer dependencies: react and react-dom 18 or later. Tag draws its dismiss icon with @remixicon/react, and you pass Remix icons to the leading slot (D1).
2. Load tokens and component styles once
// app/layout.tsx (or your app's entry file)
import '@nuton/tokens/tokens.css' // --nu-* design tokens
import '@nuton/ui/styles.css' // component styles (uses the tokens)Tags use the Label/X Small and Paragraph/X Small type tokens, which read --nu-font-body (Inter). Map it to your loaded font as described on the Button page.
Usage
import { Tag } from '@nuton/ui'
export function LessonTopics({ topics, onRemove }: { topics: string[]; onRemove: (topic: string) => void }) {
return (
<div role="group" aria-label="Lesson topics">
{topics.map((topic) => (
<Tag key={topic} onRemove={() => onRemove(topic)}>{topic}</Tag>
))}
</div>
)
}- The default is a stroke, static tag: plain text in a 28px outlined chip. It does nothing until you pass
onClickoronRemove. onClickmakes the tag a toggle button; pair it withactive, which setsaria-pressed. Use it for filters.onRemoveadds the dismiss button. The component doesn't move focus after a removal, so you must.- Keep labels to one or two words in sentence case. Put counts in
sublabel, for example “(4)”. - For a status people can't act on (“Completed”, “New”), use Badge instead.
Variants and sizes
Tag has one size and two appearances. What it holds (a leading icon, an avatar or a sublabel) and what it does (nothing, toggle or remove) combine freely.
Appearance × leading content
| Appearance | Look | Use it |
|---|---|---|
stroke | White fill, 1px Stroke/Soft-200 border | The default. On white and light surfaces. |
gray | Bg/Weak-50 fill, no border | Lower emphasis, such as applied filters or tags inside a bordered card. |
Figma's six types (Basic, Left Icon, Avatar, Country, Brand and Company) are one leading slot. Use leadingKind="icon" (the default) for a Remix icon, which takes the state's icon colour, and leadingKind="media" for an avatar, flag or logo, which keeps its own colours and fades to 48% when disabled. An <Avatar> in the slot shrinks to 16px and shows one initial (P2). No flags or brand logos ship in @nuton/ui (D12, C15): your app supplies them.
Dismiss icon
const [spaces, setSpaces] = useState(['Biology 101', 'Year 9 chemistry', 'Exam revision'])
<div role="group" aria-label="Shared with spaces">
{spaces.map((space) => (
<Tag key={space} onRemove={() => setSpaces((s) => s.filter((x) => x !== space))}>
{space}
</Tag>
))}
</div>
// After a removal, move focus to the next tag (see Composition examples).The remove button is named “Remove <label>” when the label is plain text. Pass removeLabel otherwise, or to localise it.
Toggle
const [notStarted, setNotStarted] = useState(true)
const [saved, setSaved] = useState(false)
<div role="group" aria-label="Show lessons">
<Tag active={notStarted} onClick={() => setNotStarted(!notStarted)}>Not started</Tag>
<Tag active={saved} onClick={() => setSaved(!saved)}>Saved</Tag>
</div>Size
<Tag>Biology</Tag>
<Tag leading={<RiPriceTag3Line />} sublabel="(4)">Chemistry</Tag>| Part | Value | Token |
|---|---|---|
| Height | 28 (4 + 20px line + 4) | --nu-space-1, Label/X Small |
| Side padding | 8; 6 on the side with a leading item (Figma 4, spacing balance); 4 on the dismiss side | --nu-space-2 / --nu-space-1-5 / --nu-space-1 |
| Border and radius | 1px inside stroke, radius 6 | --nu-radius-6 |
| Leading item | 16 × 16, 4 before the label | --nu-space-4 |
| Label, sublabel, dismiss gap | 4 (Figma 2, spacing balance) | --nu-space-1 |
| Dismiss icon | 16 × 16 icon, 24 × 24 hit area | --nu-space-4 / --nu-space-6 |
| Type | Label: Label/X Small (12/20). Sublabel: Paragraph/X Small (12/16) | --nu-type-label-x-small-* |
Values follow Figma's geometry (Tag [1.1] 57:17219) and are computed from the CSS. They haven't been measured in a browser yet, which the template requires before Stable. Long labels truncate with an ellipsis when the tag is narrower than its content.
States and interactions
onClick, sets aria-pressed="true".State tokens
Each state sets the tag's component tokens. Override them on a wrapper only for a documented exception.
| Token | Default (stroke / gray) | Hover (stroke / gray) | Active | Disabled |
|---|---|---|---|---|
--nu-tag-bg | Bg/White / Bg/Weak-50 | Bg/Weak-50 / Bg/White | Bg/White | Bg/Weak-50 |
--nu-tag-border | Stroke/Soft-200 / none | none / Stroke/Soft-200 | Stroke/Strong-950 | none |
--nu-tag-fg | Text/Sub-600 | Text/Sub-600 | Text/Strong-950 | Text/Disabled-500 |
--nu-tag-sublabel | Text/Sub-600 | Text/Sub-600 | Text/Sub-600 | Text/Disabled-500 |
--nu-tag-icon | Icon/Soft-400 | Icon/Sub-600 | Icon/Strong-950 | Icon/Disabled-300 |
--nu-tag-remove | Icon/Soft-400 | Icon/Sub-600 / Icon/Soft-400 | Icon/Strong-950 | Icon/Disabled-300 |
--nu-tag-focus-shadow | buttons/important-focus (Button's neutral ring, C5) | |||
Two colours are pending a decision: the sublabel uses local Text/Sub-600 instead of the remote 01-Tokens Soft-400 it binds (following D7 and C19), and Figma's Gray hover keeps the dismiss icon at Soft-400 while Stroke hover darkens it. Code implements that hover as bound; it's a candidate for the Figma cleanup batch (D9).
Composition examples
Filter lessons
Topic tags are toggles. The selected topics also appear as removable gray tags, and a status line announces the result. Remove a filter with the mouse, or focus it and press Delete: focus moves to the next filter, then to the topic's toggle when none are left.
Add and remove topics
A field adds topics to a lesson; each one is a removable tag. When the last tag goes, focus returns to the input.
Keywords on a lesson
Static tags describe content. Next to them, the lesson's status is a Status badge, because it's a state, not a keyword.
Navigation and semantics
Tag renders a <span>, or a <button> with onClick. It has no link mode: don't navigate from onClick, and don't wrap a tag in an <a>. For a keyword that opens a search page, use a link. Wrap related tags in an element with role="group" and an aria-label, or in a list when their number matters.
API reference
Props
| Prop | Type | Default | Description |
|---|---|---|---|
childrenrequired | ReactNode | — | The label (Figma Edit Text). Plain text gives the remove button its default name. |
appearance | 'stroke' | 'gray' | 'stroke' | Figma Style: white with a Soft-200 border, or Weak-50 fill. |
active | boolean | — | Figma State=Active (selected). With onClick, sets aria-pressed; without it, make the selection clear in the surrounding text. |
disabled | boolean | false | Figma State=Disabled. Disables the toggle and the remove button. |
leading | ReactNode | — | 16px leading content: a Remix icon, an <Avatar>, a flag or a logo. Rendered aria-hidden. |
leadingKind | 'icon' | 'media' | 'icon' | icon is tinted with the state's icon colour; media keeps its own colours and fades to 48% when disabled. |
sublabel | ReactNode | — | Secondary text after the label, such as "(4)". Part of the accessible name. |
onClick | (event: MouseEvent<HTMLButtonElement>) => void | — | Makes the tag a button covering the whole tag. Combine with active for a toggle. |
onRemove | () => void | — | Figma Dismiss Icon=On. Shows the remove button, activated by click, Enter, Space, Delete or Backspace. You move focus afterwards. |
removeLabel | string | "Remove <label>" | Accessible name of the remove button. The default needs a plain-text label (development warning otherwise). Pass a localised string. |
ref | Ref<HTMLSpanElement> | — | Forwarded to the outer span. |
…rest | Omit<HTMLAttributes<HTMLSpanElement>, 'onClick'> | — | Any other attribute for the outer span (id, title, data-*, aria-*…). onClick is replaced by the prop above. |
Styling hooks
The component renders .nu-tag with data-appearance, data-active, data-disabled, data-interactive, data-leading (the leading kind) and data-removable. Its parts are .nu-tag__main, .nu-tag__leading, .nu-tag__label, .nu-tag__sublabel and .nu-tag__remove. Use props rather than styling these. The --nu-tag-* tokens (see State tokens) are the supported override points.
Accessibility
Keyboard
| Key | Behaviour |
|---|---|
| Tab | Moves to the toggle (with onClick), then to the remove button (with onRemove). Static tags aren’t focusable; disabled ones are skipped. |
| Enter / Space | Toggles the tag, or removes it when the remove button has focus. |
| Delete / Backspace | Removes the tag when either its toggle or its remove button has focus. |
Screen readers
A static tag is plain text. A toggle is a button with aria-pressed, named by the label and sublabel. The remove button is named “Remove <label>” or by removeLabel. The leading item is hidden. Announce filter results in a status region.
Focus
The ring is Button's neutral focus effect, below 3:1 today (C5); Figma defines none for Tag. Browsers without :has() show the native outline on the toggle. Tag doesn't move focus after a removal.
Contrast and colour
Labels pass: Sub-600 is 6.69:1 on white and 6.24:1 on Weak-50; active Strong-950 is 17.93:1. The dismiss and leading icons (Icon/Soft-400) measure 2.49:1 and 2.33:1, below 3:1 (WCAG 1.4.11, pending a decision). Disabled text is 1.43:1 (C8). Active is also exposed as aria-pressed; in forced colours it gains an outline.
Touch and pointer
The toggle covers the whole 28px tag. The remove button's 16px icon has a 24 × 24 hit area (WCAG 2.5.8) that sits above the toggle. Hover applies only on hover-capable pointers and only to tags that do something.
You must: move focus to the next tag or the input after a removal; pass removeLabel when the label isn't plain text or the UI isn't in English; give a group of toggle tags an accessible name; use a link, not a tag, for navigation.
Figma mapping
| Figma property | Figma values | Code |
|---|---|---|
| Type | Basic · Left Icon · Avatar · Country · Brand · Company | leading + leadingKind: icon (Left Icon) · media (the rest) |
| 🏵️ Style | Stroke · Gray | appearance |
| 📌 State | Default · Hover · Active · Disabled | Interactive hover, active, disabled, plus focus (code-only) |
| ✖️ Dismiss Icon | Off · On | onRemove (and removeLabel) |
| Sublabel | Boolean + text | sublabel |
| Pick Left / Country / Brand / Company | Instance swaps | The node you pass to leading |
| No property | None | onClick: toggle behaviour (code-only) |
Guidelines and related components
Use Tag for
- Filters people switch on and off, such as lesson topics.
- Values someone has picked and can remove: topics, spaces, students.
- Short keywords that describe content.
Don't use Tag for
- A status or count people can't act on. Use Badge.
- Choosing exactly one option from a small set. Use Button group or Radio.
- Navigation. Use a link.
Do and don't
Common mistakes
- Removing a tag and leaving focus on nothing, so keyboard users are sent back to the top of the page.
- Using
activeon a static tag with nothing else to show the selection. - Passing a non-text label (an element) to a removable tag without
removeLabel, so the button is just “Remove”. - Changing the results of a filter without announcing them in a status region.