# Badge

> A short label, status or count next to other content, with an optional remove button for tags and chips.

Page: https://svelte-lean.vercel.app/primitives/badge · Markdown: https://svelte-lean.vercel.app/primitives/badge.md · Index: https://svelte-lean.vercel.app/llms.txt

A short label, status or count in a pill, with an optional remove button for tags and chips.

```svelte
<script lang="ts">
	import '@svelte-lean/styles/badge.css';
</script>

<span data-slean="badge">Draft</span>
<span data-slean="badge" data-color="success">Paid</span>
<span data-slean="badge" data-color="error">Overdue</span>
```

Plain text with a color; no role and no state.

## Examples

### Looks

```svelte
<script lang="ts">
	import '@svelte-lean/styles/badge.css';

	const variants = ['standard', 'filled', 'outlined'];
	const colors = ['primary', 'secondary', 'error', 'warning', 'info', 'success', 'neutral'];
</script>

<!-- neutral is the default color, standard (a soft tint) the default variant. -->
{#each variants as variant}
	<div class="row">
		{#each colors as color}
			<span data-slean="badge" data-variant={variant} data-color={color}>{color}</span>
		{/each}
	</div>
{/each}
```

standard (a soft tint) is the default variant, neutral the default color. The word carries the meaning; the color repeats it.

### Sizes

```svelte
<script lang="ts">
	import '@svelte-lean/styles/badge.css';
</script>

<span data-slean="badge" data-size="small">Small</span>
<span data-slean="badge">Medium</span>
<span data-slean="badge" data-size="large">Large</span>
```

### With an icon

```svelte
<script lang="ts">
	import '@svelte-lean/styles/badge.css';
</script>

<span data-slean="badge" data-color="success">
	<svg aria-hidden="true" viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2">
		<path d="M3 8.5l3 3 7-7" />
	</svg>
	Verified
</span>
<span data-slean="badge" data-color="warning">
	<svg aria-hidden="true" viewBox="0 0 16 16" width="12" height="12" fill="currentColor">
		<circle cx="8" cy="8" r="4" />
	</svg>
	Degraded
</span>
<span data-slean="badge" data-variant="outlined">
	<svg aria-hidden="true" viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" stroke-width="1.75">
		<circle cx="8" cy="8" r="6" />
		<path d="M8 4.5V8l2.5 1.5" />
	</svg>
	Scheduled
</span>
```

The badge lays out its children with a gap; the icon is hidden from assistive technology.

### Status in a list

```svelte
<script lang="ts">
	import '@svelte-lean/styles/badge.css';

	const invoices = [
		{ id: 'INV-1042', client: 'Harbor Studio', status: 'Paid', color: 'success' },
		{ id: 'INV-1043', client: 'Northwind', status: 'Due soon', color: 'warning' },
		{ id: 'INV-1044', client: 'Kite & Co', status: 'Overdue', color: 'error' },
		{ id: 'INV-1045', client: 'Aster Labs', status: 'Draft', color: undefined }
	];
</script>

<ul class="invoices">
	{#each invoices as invoice (invoice.id)}
		<li>
			<span>{invoice.client} <small>{invoice.id}</small></span>
			<span data-slean="badge" data-color={invoice.color}>{invoice.status}</span>
		</li>
	{/each}
</ul>

<style>
	.invoices {
		display: grid;
		margin: 0;
		padding: 0;
		list-style: none;
	}
	.invoices li {
		display: flex;
		justify-content: space-between;
		align-items: center;
		padding-block: 0.625rem;
		border-block-end: 1px solid var(--slean-border);
	}
</style>
```

### Removable

```svelte
<script lang="ts">
	import '@svelte-lean/styles/badge.css';
	import { tick } from 'svelte';

	let topics = $state([
		{ label: 'Design', removable: true },
		{ label: 'Research', removable: true },
		{ label: 'Accessibility', removable: true, primary: true },
		{ label: 'Archived', removable: false }
	]);
	let list: HTMLUListElement;

	// Removal is the application's: drop the item from the data, then move focus to the next
	// remove button, or the previous one when none follows.
	async function remove(index: number) {
		topics.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="badges" bind:this={list}>
	{#each topics as topic, i (topic.label)}
		<li>
			<span data-slean="badge" data-variant="outlined" data-color={topic.primary ? 'primary' : undefined}
				>{topic.label}
				{#if topic.removable}
					<button type="button" data-slean-part="remove" aria-label="Remove {topic.label}"
						onclick={() => remove(i)}></button>
				{/if}</span>
		</li>
	{/each}
</ul>

<style>
	.badges {
		display: flex;
		flex-wrap: wrap;
		gap: 0.5rem;
		margin: 0;
		padding: 0;
		list-style: none;
	}
</style>
```

A remove part makes the badge a tag. Removing is the application’s remove(): it drops the item and moves focus to the next remove button.

### Tag input

```svelte
<script lang="ts">
	import '@svelte-lean/styles/badge.css';
	import '@svelte-lean/styles/field.css';
	import '@svelte-lean/styles/input.css';
	import { tick } from 'svelte';

	let keywords = $state(['svelte', 'css']);
	let draft = $state('');
	let input: HTMLInputElement;
	let list: HTMLUListElement;

	function onkeydown(event: KeyboardEvent) {
		const value = draft.trim().toLowerCase();
		if (event.key !== 'Enter' || !value) return;
		event.preventDefault();
		if (!keywords.includes(value)) keywords.push(value);
		draft = '';
	}

	// The next remove button, the previous one, or the input when the list is empty.
	async function remove(index: number) {
		keywords.splice(index, 1);
		await tick();
		const buttons = list.querySelectorAll<HTMLButtonElement>('[data-slean-part="remove"]');
		(buttons[index] ?? buttons[index - 1] ?? input).focus();
	}
</script>

<div data-slean="field">
	<label for="keywords" data-slean-part="label">Keywords</label>
	<input id="keywords" type="text" data-slean="input" placeholder="Type and press Enter"
		bind:value={draft} bind:this={input} {onkeydown} />
</div>
<ul aria-label="Keywords" class="badges" bind:this={list}>
	{#each keywords as keyword, i (keyword)}
		<li>
			<span data-slean="badge" data-variant="outlined">{keyword}
				<button type="button" data-slean-part="remove" aria-label="Remove {keyword}"
					onclick={() => remove(i)}></button></span>
		</li>
	{/each}
</ul>
```

Enter adds a badge; removing the last one returns focus to the input. For a form value, the select's creatable mode keeps the values in a native select.

### Disabled remove button

```svelte
<script lang="ts">
	import '@svelte-lean/styles/badge.css';
</script>

<!-- Assigned by an administrator: the remove button is shown, disabled and skipped by Tab. -->
<ul aria-label="Assigned teams" class="badges">
	<li>
		<span data-slean="badge" data-variant="outlined">Platform
			<button type="button" data-slean-part="remove" aria-label="Remove Platform" disabled></button></span>
	</li>
	<li>
		<span data-slean="badge" data-variant="outlined">Billing
			<button type="button" data-slean-part="remove" aria-label="Remove Billing" disabled></button></span>
	</li>
</ul>
```

A disabled remove button is drawn dimmed and skipped by Tab.

### Count on an icon button

```svelte
<script lang="ts">
	import '@svelte-lean/styles/button.css';
	import '@svelte-lean/styles/badge.css';
</script>

<!-- The count is part of the button's name; the badge itself is hidden. -->
<button type="button" data-slean="button" data-variant="outlined" data-color="neutral" data-icon-only aria-label="Inbox, 3 unread" class="has-count">
	<svg aria-hidden="true" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linejoin="round">
		<path d="M3 13h5l1.5 3h5L16 13h5" />
		<path d="M5.5 5h13L21 13v6H3v-6z" />
	</svg>
	<span data-slean="badge" data-color="error" data-size="small" aria-hidden="true" class="count">3</span>
</button>

<style>
	/* The badge package positions nothing: placing it on the corner is the application's CSS. */
	.has-count {
		position: relative;
	}
	.count {
		position: absolute;
		inset-block-start: -0.375rem;
		inset-inline-end: -0.375rem;
		/* A ring in the page color separates the badge from the button's border. */
		box-shadow: 0 0 0 2px var(--slean-bg);
	}
</style>
```

The count is in the button's aria-label; the corner placement is the application's CSS.

## Usage

Plain markup with a stylesheet: install `@svelte-lean/styles`. No JavaScript runs.

```bash
npm install @svelte-lean/styles
```

`stylesheets`

```ts
import '@svelte-lean/styles/badge.css';
```

The color is `data-color`: `primary`, `secondary`, `info`, `success`, `warning`, `error` or `neutral` (the default). The fill is `data-variant`: `standard` (the default, a soft tint), `filled` or `outlined`. Sizes are `data-size="small"`, the default `medium` and `"large"`.

A badge that changes while the page is open is not announced. When the change matters, write it to a live region (`role="status"`) as well.

A removable badge ends with `<button type="button" data-slean-part="remove" aria-label="Remove Design">`; put several in a `<ul>` with a label. On removal, move focus to the next remove button, the previous one when none follows, or the input that adds badges when the list is empty. A badge that toggles a filter is a toggle or a checkbox instead.

## Anatomy

| Part | Element | data-slean-part | Required | Notes |
| --- | --- | --- | --- | --- |
| root | `<span data-slean="badge">` | – | yes | data-color="primary\|secondary\|info\|success\|warning\|error\|neutral" (neutral by default), data-variant="standard\|filled\|outlined", data-size="small\|medium\|large". Text inside; several removable badges go in a \<ul\>. |
| remove | `<button type="button" aria-label="Remove …">` | `remove` | no | The last child, empty; the cross is drawn. The application removes the badge and moves focus. |

## Keyboard

| Key | When | Result |
| --- | --- | --- |
| `Tab`/`Shift+Tab` | anywhere | Passes the badge by (it is text) and stops on each remove button (native) |
| `Enter`/`Space` | focus on a remove button | Activates it; the application removes the badge and moves focus |

## Styling

`badge.css` draws the pill, the standard, filled and outlined variants from the current color, the three sizes, tabular numbers, and the remove button with its masked cross, hover fill, hit area and disabled state. It positions nothing: the count example places the badge with the application's CSS. The tokens it reads:

| Token | Default (light) | Applies to |
| --- | --- | --- |
| `--slean-space-1` | `calc(var(--slean-spacing) * 1)` | gap, padding-inline |
| `--slean-space-2` | `calc(var(--slean-spacing) * 2)` | padding-inline |
| `--slean-chip-radius` | `var(--slean-radius-full)` | border-radius |
| `--slean-color-soft` | `var(--slean-primary-soft)` | background |
| `--slean-color-soft-text` | `var(--slean-primary-soft-text)` | color |
| `--slean-text-xs` | `0.75rem` | font-size |
| `--slean-font-weight-medium` | `500` | font-weight |
| `--slean-color-main` | `var(--slean-primary-main)` | background |
| `--slean-color-contrast-text` | `var(--slean-primary-contrast-text)` | color |
| `--slean-color-border` | `color-mix(in oklch, var(--slean-primary-main) 50%, transparent)` | border-color |
| `--slean-space-3` | `calc(var(--slean-spacing) * 3)` | padding-inline |
| `--slean-text-sm` | `0.875rem` | font-size |
| `--slean-radius-sm` | `calc(var(--slean-radius) * 0.6)` | border-radius |
| `--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 |

State selectors the stylesheet targets, all from the platform or ARIA: `:disabled`, `:focus-visible`, `:hover`.

Variant attributes: `data-variant` (`filled`, `outlined`); `data-size` (`small`, `large`).

## Under the hood

Runtime and size

- **Tier**: 0 · Native
- **Behavior JS**: 0 B brotli · 0 B gzip · `fixtures/results.json (native-only)` · [method](https://svelte-lean.vercel.app/performance/methodology.md)
- **Platform features**: `<span>`, `text`, `<button>`, `aria-label`, `:has()`
- **Shared listeners**: none
- **Per-instance listeners**: none
- **Lazy state**: none
- **Native base**: `<span> with an optional remove <button>`

Tier 0: no behavior module. The Vite plugin maps `badge` to no module, and the native-only fixture proves the path ships no Svelte Lean JavaScript.

Why this implementation

A badge is a word or a number in a pill. The browser renders and reads text without help, so the primitive is a stylesheet and a contract: which tokens color each variant, and how a badge takes part in the name of what it labels.

The accessibility questions are the real content. A color is only color, and color alone is not information: forced-colors mode shows every badge alike, so the word has to say it. A count on an icon button is part of the button’s name, not a separate announcement.

A tag is the same label with a remove button, so it is the same primitive with one optional part (ADR 0011 §1.6). The button brings focus, Enter and Space, the click event and a disabled state; removal stays the application’s, because the list is the application’s data. The contract names the 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 text and its reading order
- the name of the control a count badge sits in (aria-label)
- the remove button: focus, Enter and Space, the click event, disabled, its name from aria-label
- the list semantics of the \<ul\> that holds removable badges

### Svelte Lean owns

- badge.css: the pill, the standard, filled and outlined variants drawn from the current color, three sizes, tabular numbers, the masked cross of the remove button and its 24px hit area
- the contract: the text carries the meaning, the count belongs to the control’s name, where focus goes after a removal
- documentation

Accessibility

- A badge is plain text in the reading order; it has no role and is not focusable.
- The text carries the meaning; the color repeats it, and forced colors show every badge alike with a visible border.
- Inside a control, the count is part of the control’s name (`aria-label="Inbox, 3 unread"`) and the badge is `aria-hidden`.
- Every standard badge is a soft fill with its own text color from the palette tokens, checked by axe on the playground page.
- The remove part is a native `<button>` named by `aria-label`, reachable with Tab and activated with Enter or Space; its drawn box is small, its hit area is 24 by 24 pixels. A disabled one is skipped by Tab and drawn dimmed, `GrayText` in forced colors.

Platform features

| Feature | Baseline | Outside the target |
| --- | --- | --- |
| `<span>, text and <button>` | Widely available | Not applicable within the support policy |
| `:has()` | Baseline 2023 (Chrome 105, Safari 15.4, Firefox 121) | The padding beside the remove button stays as wide as on the other side |

Without JavaScript

Fully functional; remove buttons do nothing, because removal is application code.

Server rendering and hydration

Static HTML.

The package attaches nothing; hydration attaches the application’s click handlers to remove buttons.

Compatibility

Everything here is widely available. `:has()` (Baseline 2023) only tightens the padding beside the remove button.

Tests and source

- `apps/playground/tests/primitives/display.spec.ts` Playwright: open state, exclusive groups, stretched links, roles and names, carousel scrolling, axe
- `packages/styles/tests` static checks of the stylesheet (layers, tokens, specificity, dark parity)

- `packages/primitives/src/badge/contract.md` the contract in prose: semantics, keyboard, focus, ARIA, SSR, JS-disabled behavior
- `packages/primitives/src/badge/contract.ts` typed constants: name, tier, base, parts, options, events
- `packages/styles/css/badge.css` the optional stylesheet
