Base components
Tag
A label for a filter, a category or a chosen option that can carry a count, a remove button or a checkbox, and that fades out and closes its gap when it is removed.
Live preview
Loading preview
A filter row with tags Design, Product and Marketing that can be removed, tags with a dot, a flag and an avatar, tags with counts, and a group of tags with checkboxes. Replay removes the middle tag so the row closes the gap, then adds it back.
Installation
Run this in your app after the one-time setup on the Getting started page.
$ npx @ganisigit/lunia-ds add tagUsage
The demo above, as code.
'use client';
import * as React from 'react';
import { Tag } from './tag';
const FILTERS = ['Design', 'Product', 'Marketing'];
const CHANNELS = ['Email', 'SMS', 'Push'];
function Flag() {
return (
<svg aria-hidden="true" viewBox="0 0 16 16">
<path d="M0 8a8 8 0 0 1 16 0Z" fill="var(--lunia-red-500)" />
<path d="M0 8a8 8 0 0 0 16 0Z" fill="var(--lunia-base-white)" />
<circle cx="8" cy="8" r="7.75" fill="none" stroke="var(--lunia-border-secondary)" strokeWidth="0.5" />
</svg>
);
}
function Person() {
return (
<svg aria-hidden="true" viewBox="0 0 16 16" className="bg-tertiary text-fg-quaternary">
<circle cx="8" cy="6.5" r="2.5" fill="currentColor" />
<path d="M3 14.5a5 5 0 0 1 10 0Z" fill="currentColor" />
</svg>
);
}
export default function TagDemo({ replay = 0 }: { replay?: number }) {
const [filters, setFilters] = React.useState(FILTERS);
const [channels, setChannels] = React.useState([true, false, true]);
const row = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
if (replay === 0) return;
// The middle tag leaves and the row closes the gap, then it comes back.
const button = row.current?.querySelector<HTMLButtonElement>('[data-tag="Product"] button');
button?.click();
const timer = window.setTimeout(() => setFilters(FILTERS), button ? 900 : 200);
return () => window.clearTimeout(timer);
}, [replay]);
return (
<div className="flex flex-col items-start gap-5">
<div ref={row} className="flex flex-wrap items-center gap-2">
{FILTERS.filter((name) => filters.includes(name)).map((name) => (
<Tag
key={name}
data-tag={name}
size="md"
onRemove={() => setFilters((list) => list.filter((n) => n !== name))}
>
{name}
</Tag>
))}
</div>
<div className="flex flex-wrap items-center gap-2">
<Tag size="md" dot="fg-success-secondary">
Active
</Tag>
<Tag size="md" leading={<Flag />}>
Indonesia
</Tag>
<Tag size="md" leading={<Person />}>
Olivia
</Tag>
</div>
<div className="flex flex-wrap items-center gap-2">
<Tag size="lg" count={12}>
Design
</Tag>
<Tag size="lg" count={8}>
Engineering
</Tag>
</div>
<div className="flex flex-wrap items-center gap-2">
{CHANNELS.map((name, i) => (
<Tag
key={name}
checked={channels[i]}
onCheckedChange={(value) => setChannels((list) => list.map((v, j) => (j === i ? value : v)))}
>
{name}
</Tag>
))}
</div>
</div>
);
}
API
Parts: root, checkbox (optional, before the content), leading content (dot, country flag or avatar), label, count, remove button (X close).
| Prop | Type | Default | Description |
|---|---|---|---|
| size | "sm" | "md" | "lg" | "sm" | 24, 24 or 28px tall (Figma); md has larger text than sm. |
| dot | string | undefined | Shows a dot before the label in this color token, for example fg-success-secondary. |
| count | number | undefined | Shows a count after the label. Not shown when the remove button is shown. |
| checked | boolean | undefined | Shows the checkbox with this state, for a tag that can be picked. The checkbox shows only when checked or defaultChecked is set. |
| defaultChecked | boolean | undefined | Checkbox state when uncontrolled. |
| disabled | boolean | false | Fades the checkbox and the remove button to 50% and turns them off. |
| removeLabel | string | "Remove" | Verb for the remove button name. The button is named removeLabel plus the tag text. |
| Event | Description |
|---|---|
| remove | The remove button was pressed, or Backspace or Delete was pressed on it. The remove button renders only when this event has a listener. |
| checkedChange | The checkbox changed. Payload: the new checked state. |
| Slot | Description |
|---|---|
| default | Label text. |
| leading | Flag or avatar before the label (Figma Icon Country or Avatar). |
Motion
Every row names a token from the Motion page. The last column says what happens when the user asks for reduced motion.
| ID | Trigger | Element | Property | From → to | Timing | Reduced motion |
|---|---|---|---|---|---|---|
| TAG1 | hover the remove button | remove button | background-color, icon color | transparent, fg-tertiary → bg-primary_hover, fg-tertiary_hover | duration-sm (200)ease-out | Swaps at once |
| TAG2 | keyboard focus on the remove button or the checkbox | remove button, checkbox | box-shadow | none → compact focus ring (1px gap, 3px focus-ring) | duration-2xs (120)ease-out | Shows at once |
| TAG3 | the tag is added after first render | tag | opacity, scale | 0, scale-press → 1, 1 | duration-sm (200)ease-out | Shows at once |
| TAG4 | the tag is removed | tag | opacity, scale | 1, 1 → 0, scale-press | duration-xs (160)ease-exit | Hides at once |
| TAG5 | the tag is removed | tag box (so the next tags close the gap) | width, margin-inline-end | tag width, gap → 0, 0 | duration-md (260)ease-settledelay 60ms | Resizes at once |
| TAG6 | checked -> true / false | checkbox | background-color, border-color | bg-primary, border-control → bg-brand-solid, bg-brand-solid | duration-sm (200)ease-out | Swaps at once |
| TAG7 | checked -> true | check mark | stroke-dashoffset | path length → 0 | duration-md (260)ease-outdelay 60ms | Shows at once |
Accessibility
- The tag is a plain element; only its checkbox and remove button are focusable.
- The remove button is a native button named removeLabel plus the tag text, for example "Remove Design"; Backspace and Delete on it also remove the tag.
- After a remove, focus moves to the next tag's remove button, or the previous one, or the input that owns the list; the parent decides with the remove event.
- The checkbox is a real checkbox (role checkbox, aria-checked) named by the tag text.
- The remove button is 14 to 20px and the checkbox 14 to 18px: each gets a 24 x 24px tap area (WCAG 2.5.8).
- The dot, flag and avatar are aria-hidden; a count is read with its meaning, for example "Design, 12 items", through aria-label on the count.
- Reduced motion: tags appear and leave at once and the gap closes at once.