Primitives Layout and display
Tag
A label with an optional remove button. The button is native; removing the tag and moving focus are the application's, and the package draws the tag and the cross. Tier 0.
- Tier
- 0 · Native
- Behavior JS
- 0 B brotli · 0 B gzip ·
fixtures/· methodresults.json (native-only) - Platform features
<span>,<button>,aria-label,:has()- Shared listeners
- none
- Per-instance listeners
- none
- Lazy state
- none
- Native base
<span> with an optional remove <button>
On this page
Example
<ul aria-label="Topics" class="tags">
<li>
<span data-slean="tag">Design
<button type="button" data-slean-part="remove" aria-label="Remove Design"></button></span>
</li>
<li>
<span data-slean="tag">Research
<button type="button" data-slean-part="remove" aria-label="Remove Research"></button></span>
</li>
<li>
<span data-slean="tag" data-variant="accent">Accessibility
<button type="button" data-slean-part="remove" aria-label="Remove Accessibility"></button></span>
</li>
<li><span data-slean="tag">Archived</span></li>
</ul><script lang="ts">
import '@svelte-lean/styles/tag.css';
import { tick } from 'svelte';
let tags = $state([
{ label: 'Design', removable: true },
{ label: 'Research', removable: true },
{ label: 'Accessibility', removable: true, accent: true },
{ label: 'Archived', removable: false }
]);
let list: HTMLUListElement;
// Removal is the application's: drop the tag from the data, then move focus to the next
// remove button, or the previous one when none follows.
async function remove(index: number) {
tags.splice(index, 1);
await tick();
const buttons = list.querySelectorAll<HTMLButtonElement>('[data-slean-part="remove"]');
(buttons[index] ?? buttons[index - 1])?.focus();
}
</script>
<ul aria-label="Topics" class="tags" bind:this={list}>
{#each tags as tag, i (tag.label)}
<li>
<span data-slean="tag" data-variant={tag.accent ? 'accent' : undefined}>{tag.label}
{#if tag.removable}
<button type="button" data-slean-part="remove" aria-label="Remove {tag.label}"
onclick={() => remove(i)}></button>
{/if}</span>
</li>
{/each}
</ul>
<style>
.tags {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin: 0;
padding: 0;
list-style: none;
}
</style>Why this implementation exists
A tag is text in a box, and its remove control is a button. The button brings focus, Enter and Space, the click event and a disabled state with it; the tag needs no behavior of its own.
Removal is where component libraries hold state: a list of values, an onRemove callback, a focus move. The list is already the application’s data, so the application removes the item and Svelte removes the element. The contract names the one step that is easy to miss: after a removal the focused button is gone, and focus falls to the page unless the application moves it.
The browser owns
- the remove button: focus, Enter and Space, the click event, disabled
- the button’s name from aria-label
- the list semantics of the <ul> that holds the tags
Svelte Lean owns
- tag.css: the label box, the accent variant, the cross drawn with two borders, the coarse-pointer target
- the contract: the button’s name, and where focus goes after a removal
- documentation
Usage
The markup needs no package. Install @svelte-lean/styles for the stylesheet; @svelte-lean/primitives adds the typed contract and nothing at runtime.
npm install @svelte-lean/stylespnpm add @svelte-lean/stylesyarn add @svelte-lean/stylesbun add @svelte-lean/stylesimport '@svelte-lean/styles/tag.css';Name every remove button after its tag (aria-label="Remove Design"); the cross is drawn and the button has no text. Put a set of tags in a <ul> with a label.
On removal, move focus to the next remove button, the previous one when none follows, or to the input that adds tags when the list is empty. A tag that toggles a filter is a Toggle or a checkbox instead.
Anatomy
| Part | Element | data-slean-part | Required | Notes |
|---|---|---|---|---|
| root | <span data-slean="tag"> | – | yes | The label as text. data-variant="accent". In a <ul> when there are several. |
| remove | <button type="button" aria-label="Remove …"> | remove | no | Empty; the cross is drawn. The application removes the tag and moves focus. |
Runtime profile
Tier 0: the tag has no behavior module, the Vite plugin maps tag to no module, and the native-only fixture proves the path ships no Svelte Lean JavaScript. The click handler in the example is the page's own code.
Accessibility contract
- The remove part is a native
<button>named byaria-label, reachable with Tab and activated with Enter or Space. - The cross is drawn on
::beforeand::afterwith borders; forced colors keep it because it iscurrentColor. - After a removal focus goes where the application sends it; without that step it falls to the document body.
- A disabled remove button is skipped by Tab and drawn at reduced opacity,
GrayTextin forced colors.
Keyboard
| Key | When | Result |
|---|---|---|
| Tab/Shift+Tab | anywhere | Moves between remove buttons; the label is not focusable (native) |
| Enter/Space | focus on a remove button | Activates it; the application removes the tag and moves focus |
Platform features
| Feature | Baseline | Outside the target |
|---|---|---|
<button> | Widely available | Not applicable within the support policy |
:has() | Baseline 2023, widely available since June 2026 | The padding beside the remove button stays as wide as on the other side |
Without JavaScript
The tags render; the remove buttons do nothing, because removal is application code.
Server rendering
Static HTML.
Before hydration
Before hydration the remove buttons do nothing. Hydration attaches the application's click handler; the package attaches nothing.
Styling
tag.css draws the label box, the accent variant, the remove button and its cross, its hover fill, a larger target on coarse pointers, and the disabled state. The tokens it reads:
| Token | Default (light) | Applies to |
|---|---|---|
--slean-space-1 | 0.25rem | gap |
--slean-space-2 | 0.5rem | padding-inline |
--slean-border | var(--slean-neutral-6) | border |
--slean-radius-sm | 0.375rem | border-radius |
--slean-muted | var(--slean-neutral-3) | background |
--slean-fg | var(--slean-neutral-12) | color |
--slean-text-sm | 0.875rem | font-size |
--slean-accent-soft | oklch(95% 0.03 258) | background |
--slean-accent-soft-fg | oklch(42% 0.17 258) | color |
--slean-fg-muted | var(--slean-neutral-11) | color |
--slean-duration-fast | 100ms | transition |
--slean-ease | cubic-bezier(0.2, 0, 0, 1) | transition |
--slean-icon-close | url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M4.5 4.5l7 7m0-7l-7 7' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' fill='none'/%3E%3C/svg%3E") | mask |
--slean-muted-hover | var(--slean-neutral-4) | background |
State selectors the stylesheet targets, all from the platform or ARIA: :disabled, :focus-visible, :hover.
Variant attributes: data-variant (accent).
Compatibility notes
Everything here is widely available. :has() (Baseline 2023) only tightens the padding beside the remove button.
Testing
apps/playground/tests/primitives/display.spec.tsPlaywright: open state, exclusive groups, stretched links, roles and names, carousel scrolling, axepackages/styles/testsstatic checks of the stylesheet (layers, tokens, specificity, dark parity)
Source
packages/primitives/src/tag/contract.mdthe contract in prose: semantics, keyboard, focus, ARIA, SSR, JS-disabled behaviorpackages/primitives/src/tag/contract.tstyped constants: name, tier, base, parts, options, eventspackages/styles/css/tag.cssthe optional stylesheet