# Tag chip

> A compact pill for a category, keyword or filter: static, linked, toggle or removable. The remove button is a sibling with its own name, and a toggle's plus turns into a check in place, so selecting never moves the row.

- ID: `cmp_tag_chip_01`
- Slug: `tag-chip-01`
- Version: `1.0.0` (current)
- Status: published
- Published: 2026-10-21T20:37:01Z
- Updated: 2026-10-21
- Available versions: `1.0.0`
- Kind: control
- Primary category: `typography`
- Detail page: https://pagesugar.com/components/tag-chip-01
- Preview: https://pagesugar.com/preview/tag-chip-01

## Variants

| Variant | Label | Default | Artifact digest |
| --- | --- | --- | --- |
| `neutral` | Neutral | yes | `sha256-f7988aa2f9e33643cec0d587b6d427aa1eab1d56290a62f5973da8fb9b2e1f1b` |
| `blue` | Blue selected | no | `sha256-7456ad87f51622f2805f83f6c01b857e0da27b3e8196d5a2d568e51a34a2aea1` |

## Runtime and compatibility

- Runtime: svelte
- Svelte: 5
- SvelteKit required: no (portable Svelte component)
- Imports: The source imports its own files by relative path only. Examples import from $lib/...; SvelteKit 3 projects have no $lib alias by default, so import from your #lib subpath (with the .svelte extension) or a relative path instead.
- Tailwind CSS: 4
- SSR: supported
- Requires client-side JavaScript: yes
- Integration level: local-interaction
- Appearance modes: light
- Suggested directory: `src/lib/components/tag-chip-01`

## Dependencies

No third-party runtime packages.

## Services

No external services required.

## Usage

Render one chip per tag or filter, inside your own list. Pass href for a link, onToggle (and bind:selected) for a filter, onRemove for a remove button. It does not keep a list of chips, add chips from text entry, or remember selections; removal and focus after removal are yours.

Required props: `label`

```svelte
<script lang="ts">
	import TagChip from '$lib/components/tag-chip-01/TagChip.svelte';

	let teams = $state([
		{ label: 'Design', selected: false },
		{ label: 'Platform', selected: true }
	]);
	let filters = $state(['Due this month', 'Label: Billing']);
</script>

<ul role="list" class="flex flex-wrap gap-2">
	{#each teams as team (team.label)}
		<li><TagChip label={team.label} bind:selected={team.selected} onToggle={() => {}} /></li>
	{/each}
</ul>

<ul role="list" class="mt-4 flex flex-wrap gap-2">
	{#each filters as filter, index (filter)}
		<li><TagChip label={filter} onRemove={() => filters.splice(index, 1)} /></li>
	{/each}
</ul>

<TagChip label="Sourdough" href="/journal/tags/sourdough" size="sm" />
```

Limitations:

- One chip, not a tag input. Keeping the list, adding chips from typed text and moving focus after a removal belong to the page; usage.md shows the focus pattern.
- onToggle wins over href: a chip is a link or a toggle, never both.
- A disabled linked chip renders as plain text, because a link cannot be disabled.
- The tooltip for a truncated label needs client JavaScript; the server render has no title until the chip is measured.
- Light mode only. On a dark page set --tag-chip-ink, --tag-chip-muted and --tag-chip-hairline to light values, and pick an accent and on-accent that still clear 4.5:1 together.

## Usage guide

### Tag chip

One chip per tag or filter. The chip decides its mode from the props you pass:

| You pass     | It renders                                   |
| ------------ | -------------------------------------------- |
| `label` only | a static span                                |
| `href`       | a link to the tag page                       |
| `onToggle`   | a toggle button with `aria-pressed`          |
| `onRemove`   | a remove button after the label, in any mode |

A chip is a link or a toggle, never both; `onToggle` wins.

#### Filter chips

`selected` is bindable, so a filter row needs no handler beyond keeping the state:

```svelte
<ul role="list" aria-labelledby="teams" class="flex flex-wrap gap-2">
	{#each teams as team (team.label)}
		<li><TagChip label={team.label} bind:selected={team.selected} onToggle={() => {}} /></li>
	{/each}
</ul>
```

A toggle chip always shows a mark in its leading slot: a plus at rest (or your `icon`), a check
when selected. The slot never empties, so selecting a chip does not push the rest of the row.

#### Removing chips and moving focus

The chip calls `onRemove`; the page removes the item and decides where focus goes, because the
focused button has just been removed. Send it to the remove button now in that place, the
previous one when the last chip went, and a heading or the input when the list is empty:

```svelte
<script lang="ts">
	import { tick } from 'svelte';
	let filters = $state(['Team: Platform', 'Due this month', 'Label: Billing']);
	let list: HTMLUListElement;
	let input: HTMLInputElement;

	async function remove(index: number) {
		filters.splice(index, 1);
		await tick();
		const buttons = list.querySelectorAll<HTMLButtonElement>('.tag-chip__remove');
		(buttons[Math.min(index, buttons.length - 1)] ?? input).focus();
	}
</script>

<ul role="list" class="flex flex-wrap gap-2" bind:this={list}>
	{#each filters as filter, index (filter)}
		<li><TagChip label={filter} onRemove={() => remove(index)} /></li>
	{/each}
</ul>
<input bind:this={input} placeholder="Add a filter" />
```

Backspace and Delete on a focused remove button call `onRemove` too.

#### Sizes

`md` (32 px) suits filter bars and article footers. `sm` (24 px) suits table cells and the
inside of a multi-select field. On touch screens interactive chips of either size grow to 44 px
tall, so space chip rows with at least `gap-2`.

## Props

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `label` | `string` | yes |  | Chip text. One line; long labels truncate at 256 px from the sm breakpoint, at the column width below it, and show the full text as a tooltip. |
| `href` | `string` | no |  | Makes the label a link to a tag page. Ignored when onToggle is set. |
| `onRemove` | `() => void` | no |  | When provided, a remove button follows the label and calls this on click, Backspace or Delete. |
| `removeLabel` | `string` | no | `'Remove {label}'` | Accessible name for the remove button; {label} is replaced with the label. Translate it with the page. |
| `selected` | `boolean` | no | `false` | Selected state: the accent fill and a check. Bindable; a toggle chip flips it and exposes it as aria-pressed. |
| `onToggle` | `(selected: boolean) => void` | no |  | Makes the chip a toggle button and reports the new selected state. |
| `disabled` | `boolean` | no | `false` | Disables the toggle and the remove button, and turns a link into plain text. |
| `size` | 'sm' \| 'md' | no | `'md'` | sm is 24 px tall with 12 px text, md is 32 px with 14 px text. Interactive chips grow to 44 px on touch screens. |
| `icon` | `Snippet` | no |  | Leading decorative icon, 14 or 16 px, hidden from assistive technology. Replaced by a check while selected. |
| `class` | `string` | no |  | Extra classes on the chip, for margins or a narrower width. |

## Customization

Retone through five --tag-chip-\* variables; change heights and padding in the SIZES map in the source.

- Brand colour: set --tag-chip-accent and --tag-chip-on-accent. The accent fills selected chips and draws the focus ring; nothing else uses it.
- Resting chips are --tag-chip-ink text on a --tag-chip-hairline ring. Hover and press tints are mixed from the ink, so they follow it.
- Cream or tinted page: the chip paints no fill at rest, so it takes the page colour; set --tag-chip-hairline to rgb(0 0 0 / 0.16) if the ring gets lost.
- Dark page: --tag-chip-ink #fafafa, --tag-chip-muted #a1a1aa, --tag-chip-hairline rgb(255 255 255 / 0.14), --tag-chip-accent #fafafa and --tag-chip-on-accent #09090b.
- Shape: the chip is a pill. For 6 px corners change rounded-full to rounded-md on the chip and its main element, and to rounded-sm on the remove circle.
- Label width: change sm:max-w-64 on .tag-chip\_\_label to truncate earlier or later; below sm the label is capped only by its column.
- Lists: put chips in a ul with role=list and flex flex-wrap gap-2, one li per chip, so screen readers announce the count.

| Token | Public CSS variable |
| --- | --- |
| `accent` | `--tag-chip-accent` |
| `onAccent` | `--tag-chip-on-accent` |
| `ink` | `--tag-chip-ink` |
| `muted` | `--tag-chip-muted` |
| `hairline` | `--tag-chip-hairline` |

## Accessibility

- The link or toggle and the remove button are siblings inside a plain span, never nested, so each is its own tab stop.
- The remove button is named 'Remove \<label\>' by default; pass removeLabel in the page's language, keeping {label}.
- Backspace and Delete on a focused remove button remove the chip, as Enter and Space do.
- After a removal the button that had focus is gone. Move focus to the next chip's remove button, the previous one when it was last, or a heading or input when none are left.
- Toggle chips are native buttons with aria-pressed. Selection is shown by the fill and a check mark, not colour alone.
- Remove targets are at least 24 by 24 px, and interactive chips are 44 px tall under a coarse pointer.
- Put a group of chips in a list with a visible label (aria-labelledby) so the group and its count are announced.
- Default colours clear 4.5:1 for text at rest and selected; check yours after setting the tokens.

Known limitations:

- The chip does not move focus after removal; the page must, because only the page knows what comes next.

## License

- Declared source: MIT
- Default license approval is pending. See https://pagesugar.com/docs/license.

## Source

- Palette: Neutral (`neutral`)
- Entry: `TagChip.svelte`
- Suggested directory: `src/lib/components/tag-chip-01`
- Files: 1
- Artifact digest: `sha256-f7988aa2f9e33643cec0d587b6d427aa1eab1d56290a62f5973da8fb9b2e1f1b`

Paths below are relative to the suggested directory. Copy the files as they are;
they import nothing from this site.

#### `TagChip.svelte`

Role: entry · 10803 bytes · SHA-256 `976a0e21ed645d2e1d4cc12c5145576616041461506cdb10f32a0fa66d7a10e3`

```svelte
<script lang="ts" module>
	export type TagChipSize = 'sm' | 'md';
</script>

<script lang="ts">
	import type { Snippet } from 'svelte';

	interface Props {
		/** Chip text. One line; long labels truncate and show the full text as a tooltip. */
		label: string;
		/** Makes the label a link to a tag page. Ignored when onToggle is set. */
		href?: string;
		/** When provided, a remove button follows the label and calls this. */
		onRemove?: () => void;
		/** Accessible name for the remove button; {label} is replaced with the label. */
		removeLabel?: string;
		/** Selected state. Bindable; a toggle chip flips it and reports it through aria-pressed. */
		selected?: boolean;
		/** Makes the chip a toggle button that reports the new selected state. */
		onToggle?: (selected: boolean) => void;
		/** Disables the toggle, the link and the remove button. */
		disabled?: boolean;
		/** sm is 24 px tall for dense rows and inputs; md is 32 px. */
		size?: TagChipSize;
		/** Leading decorative icon. A toggle chip shows a plus here at rest and a check when selected. */
		icon?: Snippet;
		/** Extra classes on the chip, for margins or a different label width. */
		class?: string;
	}

	let {
		label,
		href,
		onRemove,
		removeLabel = 'Remove {label}',
		selected = $bindable(false),
		onToggle,
		disabled = false,
		size = 'md',
		icon,
		class: className
	}: Props = $props();

	/*
	 * The chip is a span holding one main element (a span, a link or a toggle button) and an
	 * optional remove button beside it, never inside it. The main element carries the chip's
	 * padding, so its hit area is the whole chip up to the remove button. Both reach over the
	 * one-pixel border (-my-px), so their targets are the chip's full outer height.
	 */
	const mode = $derived(onToggle ? 'toggle' : href && !disabled ? 'link' : 'static');
	const removable = $derived(Boolean(onRemove));

	/*
	 * The leading slot is the state mark: a toggle always has one (plus, or the consumer's icon,
	 * swapped for a check when selected), so selecting never changes the chip's width.
	 */
	const marked = $derived(mode === 'toggle' || selected || Boolean(icon));

	const SIZES: Record<
		TagChipSize,
		{
			root: string;
			main: string;
			bare: string;
			mark: string;
			end: string;
			trail: string;
			remove: string;
			x: string;
			glyph: string;
			go: string;
		}
	> = {
		sm: {
			root: 'h-6 text-xs leading-4',
			main: 'gap-1',
			bare: 'ps-2.5',
			mark: 'ps-2',
			end: 'pe-2.5',
			trail: 'pe-2',
			remove: 'w-6',
			x: 'size-4',
			glyph: 'size-3.5',
			go: 'size-2.5'
		},
		md: {
			root: 'h-8 text-sm leading-5',
			main: 'gap-1.5',
			bare: 'ps-3',
			mark: 'ps-2.5',
			end: 'pe-3',
			trail: 'pe-2.5',
			remove: 'w-8',
			x: 'size-5',
			glyph: 'size-4',
			go: 'size-3'
		}
	};

	const step = $derived(SIZES[size] ?? SIZES.md);

	/* A link says so at rest with a small trailing chevron, unless a remove button ends the chip. */
	const trailed = $derived(mode === 'link' && !removable);
	const interactive = $derived(mode !== 'static' || removable);
	const removeName = $derived(removeLabel.replaceAll('{label}', () => label));

	let truncated = $state(false);

	/** Shows the full label as a tooltip only while the chip is too narrow to show it. */
	function watchTruncation(node: HTMLElement) {
		const check = () => (truncated = node.scrollWidth > node.clientWidth);
		check();
		if (typeof ResizeObserver === 'undefined') return;
		const observer = new ResizeObserver(check);
		observer.observe(node);
		return () => observer.disconnect();
	}

	function toggle() {
		if (disabled) return;
		selected = !selected;
		onToggle?.(selected);
	}

	function remove(event: MouseEvent) {
		event.preventDefault();
		event.stopPropagation();
		if (!disabled) onRemove?.();
	}

	function removeByKey(event: KeyboardEvent) {
		if (event.key !== 'Backspace' && event.key !== 'Delete') return;
		event.preventDefault();
		if (!disabled) onRemove?.();
	}

	const mainClass = $derived([
		'tag-chip__main -my-px inline-flex min-w-0 items-center self-stretch rounded-full outline-none',
		step.main,
		marked ? step.mark : step.bare,
		!removable && (trailed ? step.trail : step.end),
		mode !== 'static' && 'min-w-6 pointer-coarse:min-w-11',
		mode === 'toggle' && 'cursor-pointer disabled:cursor-not-allowed',
		mode === 'link' && 'no-underline'
	]);
</script>

{#snippet content()}
	{#if marked}
		<span
			class={['tag-chip__mark grid shrink-0 place-items-center', step.glyph]}
			aria-hidden="true"
		>
			{#if selected}
				<svg
					viewBox="0 0 16 16"
					fill="none"
					stroke="currentColor"
					stroke-width="1.75"
					stroke-linecap="round"
					stroke-linejoin="round"
				>
					<path d="M3.5 8.25 6.5 11l6-6.5" />
				</svg>
			{:else if icon}
				{@render icon()}
			{:else}
				<svg
					viewBox="0 0 16 16"
					fill="none"
					stroke="currentColor"
					stroke-width="1.75"
					stroke-linecap="round"
				>
					<path d="M8 3.5v9M3.5 8h9" />
				</svg>
			{/if}
		</span>
	{/if}
	<span
		class="tag-chip__label min-w-0 truncate sm:max-w-64"
		title={truncated ? label : undefined}
		{@attach watchTruncation}>{label}</span
	>
	{#if trailed}
		<svg
			class={['tag-chip__go shrink-0 rtl:-scale-x-100', step.go]}
			viewBox="0 0 12 12"
			fill="none"
			stroke="currentColor"
			stroke-width="1.5"
			stroke-linecap="round"
			stroke-linejoin="round"
			aria-hidden="true"
		>
			<path d="M4.75 3 7.75 6l-3 3" />
		</svg>
	{/if}
{/snippet}

<span
	class={[
		'tag-chip relative inline-flex max-w-full min-w-0 items-center rounded-full border align-middle font-medium whitespace-nowrap',
		step.root,
		interactive && 'pointer-coarse:h-11',
		selected
			? 'tag-chip--selected border-(--_accent) bg-(--_accent) text-(--_on-accent)'
			: 'border-(--_hairline) text-(--_ink)',
		disabled && 'tag-chip--disabled opacity-50',
		className
	]}
>
	{#if mode === 'toggle'}
		<button type="button" class={mainClass} aria-pressed={selected} {disabled} onclick={toggle}>
			{@render content()}
		</button>
	{:else if mode === 'link'}
		<a class={mainClass} {href}>{@render content()}</a>
	{:else}
		<span class={mainClass}>{@render content()}</span>
	{/if}
	{#if removable}
		<button
			type="button"
			class={[
				'tag-chip__remove group/remove -my-px -me-px inline-flex shrink-0 cursor-pointer items-center justify-center self-stretch rounded-full outline-none disabled:cursor-not-allowed pointer-coarse:w-11',
				step.remove
			]}
			aria-label={removeName}
			{disabled}
			onclick={remove}
			onkeydown={removeByKey}
		>
			<span
				class={[
					'tag-chip__x grid place-items-center rounded-full text-(--_mark) group-focus-visible/remove:outline-2 group-focus-visible/remove:outline-offset-2 group-focus-visible/remove:outline-(--_ring)',
					step.x
				]}
				aria-hidden="true"
			>
				<svg
					class="size-3"
					viewBox="0 0 12 12"
					fill="none"
					stroke="currentColor"
					stroke-width="1.5"
					stroke-linecap="round"
				>
					<path d="M3.5 3.5l5 5M8.5 3.5l-5 5" />
				</svg>
			</span>
		</button>
	{/if}
</span>

<style>
	/*
	 * Public tokens: set --tag-chip-* on the chip or any ancestor to retone it. The accent is the
	 * selected fill, the focus ring and nothing else; at rest a chip is ink on a hairline.
	 */
	.tag-chip {
		--_accent: var(--tag-chip-accent, #18181b);
		--_on-accent: var(--tag-chip-on-accent, #ffffff);
		--_ink: var(--tag-chip-ink, #18181b);
		--_muted: var(--tag-chip-muted, #52525b);
		--_hairline: var(--tag-chip-hairline, rgb(0 0 0 / 0.12));

		/* Formulas: the hover and press tints, mixed from the colour the chip sits in. */
		--_ease: cubic-bezier(0.2, 0, 0, 1);
		--_ring: var(--_accent);
		--_mark: var(--_muted);
		--_fill-hover: color-mix(in srgb, var(--_ink) 6%, transparent);
		--_fill-press: color-mix(in srgb, var(--_ink) 10%, transparent);
		--_x-hover: color-mix(in srgb, var(--_ink) 9%, transparent);
		--_x-press: color-mix(in srgb, var(--_ink) 15%, transparent);

		/*
		 * One home for the chip's own states: the transition and its reduced-motion override.
		 * The press scale further down only applies when motion is allowed.
		 */
		@media (prefers-reduced-motion: no-preference) {
			transition:
				background-color 150ms var(--_ease),
				border-color 150ms var(--_ease),
				color 150ms var(--_ease),
				scale 80ms var(--_ease);
		}
	}

	.tag-chip--selected {
		--_ring: var(--_on-accent);
		--_mark: currentColor;
		/* Small steps toward the on-accent colour, so the label keeps 4.5:1 on a blue accent. */
		--_fill-hover: color-mix(in srgb, var(--_accent) 90%, var(--_on-accent));
		--_fill-press: color-mix(in srgb, var(--_accent) 84%, var(--_on-accent));
		--_x-hover: color-mix(in srgb, var(--_on-accent) 18%, transparent);
		--_x-press: color-mix(in srgb, var(--_on-accent) 28%, transparent);
	}

	.tag-chip__mark,
	.tag-chip__go {
		color: var(--_mark);
	}

	/* Icons passed in as a snippet fill the mark box and follow its colour. */
	.tag-chip__mark :global(:where(svg)) {
		width: 100%;
		height: 100%;
	}

	/*
	 * Relations: the chip answers to its main element. Hover, press and the focus ring are drawn
	 * on the whole chip when the link or toggle inside it is the target, and never when only
	 * the remove button is, which draws its own.
	 */

	.tag-chip:has(> .tag-chip__main:focus-visible) {
		outline: 2px solid var(--_accent);
		outline-offset: 2px;
	}

	@media (hover: hover) {
		.tag-chip:not(.tag-chip--disabled):has(> :is(a, button).tag-chip__main:hover) {
			background-color: var(--_fill-hover);
		}

		.tag-chip--selected:not(.tag-chip--disabled):has(> :is(a, button).tag-chip__main:hover) {
			border-color: var(--_fill-hover);
		}

		.tag-chip:not(.tag-chip--disabled) .tag-chip__remove:hover .tag-chip__x {
			background-color: var(--_x-hover);
			color: currentColor;
		}
	}

	.tag-chip:not(.tag-chip--disabled):has(> :is(a, button).tag-chip__main:active) {
		background-color: var(--_fill-press);
	}

	@media (prefers-reduced-motion: no-preference) {
		.tag-chip:not(.tag-chip--disabled):has(> :is(a, button).tag-chip__main:active) {
			scale: 0.98;
		}
	}

	.tag-chip:not(.tag-chip--disabled) .tag-chip__remove:active .tag-chip__x {
		background-color: var(--_x-press);
		color: currentColor;
	}

	@media (prefers-reduced-motion: no-preference) {
		.tag-chip__x {
			transition:
				background-color 150ms var(--_ease),
				color 150ms var(--_ease),
				scale 80ms var(--_ease);
		}

		.tag-chip:not(.tag-chip--disabled) .tag-chip__remove:active .tag-chip__x {
			scale: 0.9;
		}
	}

	/*
	 * Forced colours drop fills and repaint text, borders and outlines in system colours. The
	 * check mark carries the selected state there; a Highlight border marks it as well.
	 */
	@media (forced-colors: active) {
		.tag-chip--selected {
			border-color: Highlight;
		}
	}
</style>
```

## Artifacts

### Neutral (`neutral`) (default)

- Artifact digest: `sha256-f7988aa2f9e33643cec0d587b6d427aa1eab1d56290a62f5973da8fb9b2e1f1b`
- Entry: `TagChip.svelte`
- Receipt: https://pagesugar.com/artifacts/cmp_tag_chip_01/1.0.0/neutral/sha256-f7988aa2f9e33643cec0d587b6d427aa1eab1d56290a62f5973da8fb9b2e1f1b/manifest.json
- Bundle: https://pagesugar.com/artifacts/cmp_tag_chip_01/1.0.0/neutral/sha256-f7988aa2f9e33643cec0d587b6d427aa1eab1d56290a62f5973da8fb9b2e1f1b/bundle.zip (6774 bytes, sha256 `dde1904c1eb967d2f9c7e52f0d44e9a012363006bb8429db470e16abbd89520b`)

Files:

- `TagChip.svelte` (entry, 10803 bytes): https://pagesugar.com/artifacts/cmp_tag_chip_01/1.0.0/neutral/sha256-f7988aa2f9e33643cec0d587b6d427aa1eab1d56290a62f5973da8fb9b2e1f1b/source/TagChip.svelte

### Blue selected (`blue`)

- Artifact digest: `sha256-7456ad87f51622f2805f83f6c01b857e0da27b3e8196d5a2d568e51a34a2aea1`
- Entry: `TagChip.svelte`
- Receipt: https://pagesugar.com/artifacts/cmp_tag_chip_01/1.0.0/blue/sha256-7456ad87f51622f2805f83f6c01b857e0da27b3e8196d5a2d568e51a34a2aea1/manifest.json
- Bundle: https://pagesugar.com/artifacts/cmp_tag_chip_01/1.0.0/blue/sha256-7456ad87f51622f2805f83f6c01b857e0da27b3e8196d5a2d568e51a34a2aea1/bundle.zip (6774 bytes, sha256 `e42d167096c7d589ddf92709022b5cde9569ef753fd144c0dd000214ffdb6eb3`)

Files:

- `TagChip.svelte` (entry, 10803 bytes): https://pagesugar.com/artifacts/cmp_tag_chip_01/1.0.0/blue/sha256-7456ad87f51622f2805f83f6c01b857e0da27b3e8196d5a2d568e51a34a2aea1/source/TagChip.svelte
