FilterChips
Toggleable chips for search and filtering.
Live demo
01Multi-select with live filtering
- A modest case for monochrome UI design
- Owning your design tokens engineering, design
- Why we picked SvelteKit over Next engineering
Single-select
Active sort: newest
Removable chips
Active: design, product, marketing
Custom palette & sizes
Implementation
02<FilterChips />Each chip is a real <button> with aria-pressed, so the toggle state is announced correctly and Tab/Space/Enter all work without extra plumbing. Multi-select keeps the selected array in sync with bind:selected; single-select replaces the array on every click. Removable chips show an Γ control and emit onRemove so you can double-bind to a parent "active filters" row.
Logic explainer
03What Does It Do? (Plain English)
A row of small toggleable pills used to filter content β blog tags, product facets, search categories, active filters in a dashboard. Each chip is an independent boolean by default (multi-select), so users can combine filters freely. Switch the mode to single-select and chips behave like radio buttons. An optional "All" reset chip clears the selection in one tap, and an optional Γ on each chip lets users dismiss filters individually without scrolling back to the list.
Think of it as a row of light switches versus a single mode dial. By default, every chip is its own switch. In single mode, the row becomes one dial with N positions β the joined "you can only pick one" feel of SegmentedControl, but with un-joined chips that wrap onto multiple rows.
How It Works (Pseudo-Code)
state:
selected[] = bindable array of chip values currently active
options[] = chip definitions
mode = 'multi' | 'single'
events:
on chip click(value):
if mode === 'single':
selected = isSelected(value) ? [] : [value] // toggle or replace
else:
selected = isSelected(value)
? selected.filter(v => v !== value) // remove
: [...selected, value] // append
fire onChange(selected)
on Γ click(value):
event.stopPropagation // don't toggle the chip
selected = selected.filter(v => v !== value)
fire onRemove(value)
fire onChange(selected)
on 'All' chip click:
selected = []
fire onChange([])
render:
for each option:
<button aria-pressed={isActive} onclick={() => toggle(value)}>
label, optional count, optional Γ (if removable && isActive)The mode prop changes the toggle behaviour but not the rendering β both modes use the same chip layout. Single-mode behaves like radios that allow zero selection (clicking the active chip clears it).
The Core Concept: aria-pressed Over a Hidden Checkbox
A common mistake is to wrap a hidden <input type="checkbox"> per chip. It works, but it's the wrong semantic. A checkbox implies a form field β the toggle is committing a value to be submitted later. Chips are immediate filters β toggling one re-renders the result list right now, no submit button.
The right pattern is <button aria-pressed>:
<button
type="button"
aria-pressed={isActive}
onclick={toggle}
>
{label}
</button>aria-pressed is the WAI-ARIA pattern for toggle buttons β buttons that flip between two states. AT announces "Design, toggle button, pressed" or "Design, toggle button, not pressed". The whole row is wrapped in <div role="group" aria-label={ariaLabel}> so AT users get context for what the buttons control.
The trade-off: like Switch, you don't get free <form> submission. For modern apps that submit JSON, this is fine.
Inner Γ Without Bubbling
The removable Γ is a tricky case: it's inside the chip's click target, but clicking it must not fire the chip's toggle. The standard fix is event.stopPropagation() inside the Γ handler:
function remove(value, event) {
event.stopPropagation(); // don't toggle the chip
selected = selected.filter(v => v !== value);
onRemove?.(value);
onChange?.(selected);
}The Γ is rendered as a <span role="button"> rather than a nested <button> because nested buttons are invalid HTML β the inner button would be parsed out of the outer one. Keyboard handling on the span is wired manually:
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
remove(value, e);
}
}}Tab order: first the chip, then the Γ. Users can Tab to a chip, Space to toggle, Tab again to reach its Γ, Space to remove. The aria-label on the Γ is "Remove {chip label}" so AT users know which chip the Γ dismisses.
State Flow Diagram
βββββββββββββββββββββββββββββββ
β selected = [] β
β no chips active β
ββββββββββββββ¬ββββββββββββββββββ
β
βββββββββββββββββββΌβββββββββββββββββββ
β β β
click chip click Γ on click 'All'
(multi) active chip reset chip
β β β
βΌ βΌ βΌ
βββββββββββββββββββββββββββββββ
β selected = [...] β
β chips active per state β
β onChange(selected) fires β
ββββββββββββββ¬ββββββββββββββββββ
β
β click another chip:
β multi β append/remove
β single β replace or clear
βΌ
back to selected updated
Keyboard:
Tab : move between chips and Γs
Space : toggle chip / fire Γ
Enter : same as SpaceProps Reference
| Prop | Type | Default | Description |
|---|---|---|---|
options |
{ value: string; label: string; count?: number }[] |
required | Chip data. Optional count renders as a small badge next to the label. |
selected |
string[] |
[] |
Active chip values. Use bind:selected for two-way sync. |
mode |
'multi' | 'single' |
'multi' |
Selection behaviour. Single replaces; multi toggles independently. |
size |
'sm' | 'md' | 'lg' |
'md' |
Chip padding + font size. |
removable |
boolean |
false |
Show Γ on each active chip. |
onRemove |
(value: string) => void |
β | Fires when Γ is clicked (in addition to onChange). |
showAll |
boolean |
false |
Show 'All' reset chip at the start of the row. |
allLabel |
string |
'All' |
Label for the reset chip. |
activeBg |
string |
'#1f2937' |
Active chip background colour. |
activeText |
string |
'#ffffff' |
Active chip text colour. |
ariaLabel |
string |
'Filters' |
Group label. |
onChange |
(selected: string[]) => void |
β | Fires whenever selection changes (toggle or remove). |
class |
string |
'' |
Extra classes on the wrapper. |
Edge Cases
| Situation | Behaviour |
|---|---|
mode='single' and user clicks the currently-active chip |
selected becomes [] β the chip deselects. Single-mode allows zero selection (unlike a true radio group). |
removable={true} but a chip is not active |
The Γ is hidden β only active chips show the Γ (a chip that's not selected has nothing to remove). |
| 30+ chips on a narrow screen | The row wraps onto multiple lines (flex-wrap: wrap). Consider switching to a multi-select dropdown above ~12 chips. |
User has prefers-reduced-motion: reduce |
The 150 ms hover/active transition is removed; state changes are instant. |
options includes a chip whose value already in selected is removed from options |
The chip disappears from the row; the value stays in selected (component doesn't auto-prune). Parent can reconcile if needed. |
count is 0 |
Renders as 0 next to the label. Pass undefined (omit) to hide the count badge entirely. |
| User clicks the Γ via keyboard (Tab to it, Space) | onkeydown calls preventDefault so the page doesn't scroll on Space, then dispatches remove. |
| Multiple FilterChips on the same page | Each has its own group; toggles don't cross-interfere. Use distinct ariaLabel so AT announces them clearly. |
Dependencies
- Svelte 5.x β
$bindable,$props. One toggle handler, one remove handler. - Zero external dependencies. Native
<button>, scoped CSS, inline Γ SVG.
File Structure
src/lib/components/FilterChips.svelte # implementation
src/lib/components/FilterChips.md # this file (rendered inside ComponentPageShell)
src/lib/components/FilterChips.test.ts # vitest unit tests
src/routes/filterchips/+page.svelte # demo pageAPI
04| Prop | Type | Default | Description |
|---|---|---|---|
options | Array<{ value, label, count? }> | β | Required. Chips to render. |
selected | string[] | [] | Bindable list of selected values. |
mode | 'multi' | 'single' | 'multi' | Selection model. |
size | 'sm' | 'md' | 'lg' | 'md' | Chip height and padding. |
removable | boolean | false | Render an Γ handle on active chips. |
showAll | boolean | false | Prepend an "All" reset chip. |
allLabel | string | 'All' | Override the reset chip label. |
activeBg / activeText | string | β | Custom palette for selected chips. |
onChange | (selected) => void | β | Fires whenever the selected list changes. |
onRemove | (value) => void | β | Fires when a removable chip's Γ is clicked. |
ariaLabel | string | 'Filters' | Accessible group label for the chip set. |