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.
cmp_tag_chip_01 Preview
Fit to the available width. The frame follows the height of its content; previews taller than the maximum auto-height scroll inside it.
Give this component to your coding agent Copy a prompt that fetches this exact version and palette through the PageSugar MCP server.
Using the PageSugar MCP server, fetch component cmp_tag_chip_01 version 1.0.0 with variant "blue", first inspect its requirements and license status and confirm this project uses Svelte 5 and Tailwind CSS 4. Retrieve every manifest file, including binary assets and any manifest-only response files, preserving relative paths. Then integrate the source and follow its usage notes. Run project checks, review the browser result and report anything unverified. Do not substitute another version or invent missing files.Not connected yet? Set up the MCP server
Code
- Palette
- Blue selected
- Version
- 1.0.0
- Digest
Full digest
sha256-7456ad87f51622f2805f83f6c01b857e0da27b3e8196d5a2d568e51a34a2aea1
<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, #1d4ed8);
--_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>
Usage#
On this pageRender 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.
- Suggested location
src/lib/components/tag-chip-01- Required props
label
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.mdshows the focus pattern. onTogglewins 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-mutedand--tag-chip-hairlineto light values, and pick an accent and on-accent that still clear 4.5:1 together.
Example
<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" />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.
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:
<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:
<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 and content inputs#
On this page| Name | Type | Required | Default | Description |
|---|---|---|---|---|
label | string | Yes | None | 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 | None | Makes the label a link to a tag page. Ignored when onToggle is set. |
onRemove | () => void | No | None | 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 | None | 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 | None | Leading decorative icon, 14 or 16 px, hidden from assistive technology. Replaced by a check while selected. |
class | string | No | None | Extra classes on the chip, for margins or a narrower width. |
Customization#
On this pageRetone through five --tag-chip-* variables; change heights and padding in the SIZES map in the source.
- Brand colour: set
--tag-chip-accentand--tag-chip-on-accent. The accent fills selected chips and draws the focus ring; nothing else uses it. - Resting chips are
--tag-chip-inktext on a--tag-chip-hairlinering. 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-hairlineto rgb(0 0 0 / 0.16) if the ring gets lost. - Dark page:
--tag-chip-ink#fafafa,--tag-chip-muted#a1a1aa,--tag-chip-hairlinergb(255 255 255 / 0.14),--tag-chip-accent#fafafaand--tag-chip-on-accent#09090b. - Shape: the chip is a pill. For 6 px corners change
rounded-fulltorounded-mdon the chip and its main element, and torounded-smon the remove circle. - Label width: change
sm:max-w-64on .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=listand flexflex-wrapgap-2, one li per chip, so screen readers announce the count.
Public CSS variables
| Variable | Token |
|---|---|
--tag-chip-accent | accent |
--tag-chip-on-accent | onAccent |
--tag-chip-ink | ink |
--tag-chip-muted | muted |
--tag-chip-hairline | hairline |
Accessibility#
On this page- 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
removeLabelin 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.
Release details#
On this page- Integration
- Local interaction
- Requires client-side JavaScript to be interactive
- Server-side rendering supported
- Dependencies
- No additional runtime packages beyond Svelte and Tailwind CSS
- License
MIT. Default license approval is pending; see the license status before adopting the source.
- Version history
- 1.0.0 (Published) Current release · 21 October 2026
Only the current release is available. Keep downloaded source and its receipt if you need to use it again later.