Chip
The interactive cousin of the badge. Filter chips toggle, input chips are removable tokens, and both are 28px so they never read as buttons.
Also called Filter Chip, Token, Facet, Removable Tag — in this system all of them are Chip.
Everything below is the real component. Change the controls, tab through it, and turn on Inspector Mode to read any value off the screen.
Filter chips
Independent toggles with a live result count. The count is in an aria-live region, so screen-reader users hear the effect of the filter they just applied.
Input chips
Values committed into a field. Enter or comma adds, the × removes, and Backspace on an empty field removes the last one.
Applied filters
When filters live in a panel, echo them above the results as removable chips. Users need to see what is narrowing the list without reopening anything.
Avatars, icons and counts
A chip can carry a leading avatar or icon and a trailing count. Anything more than that and it should be a row in a list.
Interaction states
Every state a user can put this component into, rendered side by side. If a state is missing here, it is missing in production too.
Every part, every measurement, and the reason it is that number.
Selected with a leading icon and a remove button, next to an unselected chip. Both are 28px tall so a wrapped row stays on a consistent baseline.
- Height28px (md), 24px (sm)
Between a badge and a button. Interactive but clearly secondary to the page’s actual actions.
- Padding10px, 6px when adorned
Reduced on the side that carries an icon or avatar, so the adornment does not push the label off-centre.
- Selected stateTint + accent border + accent text
Three changes at once. A tint alone is too subtle in a wrapped row of a dozen chips.
- Remove button18px, its own focus target
A separate focusable element with its own accessible name — "Remove Production", not "Remove". It must stop propagation so removing does not also toggle.
- Row gap8px
Enough that the pills read as separate objects rather than one long bar, and enough that adjacent focus rings do not collide.
- ShapeFully rounded
Shared with badges. The height difference and the hover state are what distinguish them, not the silhouette.
Values are read live from the running stylesheet, so this table can never drift from the code. Click any value to copy it.
Color
| Token | Value | Used for |
|---|---|---|
| --ds-surface | — | Unselected fill |
| --ds-border | — | Unselected border |
| --ds-layer-hover | — | Hover fill |
| --ds-accent-subtle | — | Selected fill |
| --ds-accent-border | — | Selected border |
| --ds-accent-text | — | Selected label |
| --ds-fg-secondary | — | Unselected label |
Spacing
| Token | Value | Used for |
|---|---|---|
| padding-x | Horizontal padding | |
| gap | Icon or avatar to label | |
| row gap | Between chips |
Radius
| Token | Value | Used for |
|---|---|---|
| full | — | Pill shape |
Typography
| Token | Value | Used for |
|---|---|---|
| --text-label | Label |
Motion
| Token | Value | Used for |
|---|---|---|
| duration | Hover and selection transition |
Pick a size from this table. Do not invent a new one — a fourth height is how a design system starts dying.
| Size | Height | Padding | Radius | Icon | Label gap | Type | Touch target | When to use |
|---|---|---|---|---|---|---|---|---|
| Small | 24px | 0 8px | full | 12px | — | 12px | 44px (padded) | Inside inputs as tokens, and in dense table toolbars. |
| Medium | 28px | 0 10px | full | 13px | — | 13px | 44px (padded) | The default. Filter bars and applied-filter rows. |
| With remove | 28px | 0 4px 0 10px | — | — | 6px | — | — | Right padding drops to make room for the 18px remove button. |
| With avatar | 28px | 0 10px 0 6px | — | — | 6px | — | — | Left padding drops so the 20px avatar sits flush inside the pill. |
if (e.key === 'Backspace' && draft === '')
removeLast()Not a checklist to run at the end. These are the requirements the component was built from.
Contrast
- The unselected border must reach 3:1 — it is the only thing showing an interactive object is present.
- Selected chips change fill, border and text colour together, so the state survives greyscale.
- The remove × must reach 3:1 against the chip fill. At 11px it is easy to make it too faint.
Keyboard
| Tab | Moves to each chip, then to its remove button. Both are focusable. |
| Space / Enter | Toggles a filter chip. |
| Backspace / Delete | On a focused removable chip, removes it and moves focus to the next one. |
| Backspace | In a chip input with an empty field, removes the last token. |
| ← / → | Optional roving focus across a long chip row, so Tab does not stop twenty times. |
Screen readers
- A filter chip announces as "Production, toggle button, pressed". The word "pressed" is what aria-pressed buys you.
- After removing a chip, announce what happened: "Production removed, 2 filters remaining".
- In a chip input, announce the token count so the user knows how many recipients they have added without tabbing through all of them.
Focus & touch
- The chip and its remove button are separate focus stops. Removing a chip must move focus to the next chip, or to the input if it was the last one — never to <body>.
- A 28px chip needs a padded 44px target. In a wrapped row, keep the row gap at 8px so adjacent chips are not mis-tapped, and make the remove button at least 24px on touch.
| Attribute | Applied to | Notes |
|---|---|---|
| aria-pressed | Filter chips | They are independent toggles. aria-selected would imply a single-selection listbox. |
| aria-label | The remove button | Must include the value: "Remove Production". A bare "Remove" is useless in a row of twelve. |
| aria-live="polite" | The result count | Announces the effect of the filter. Without it, a screen-reader user toggles a chip and hears nothing. |
| role="group" | A filter bar | With aria-label="Filters", so the whole bar can be skipped or targeted. |
| aria-describedby | A chip input | Points at the "Enter or comma to add" instruction. |
Example usage
1import { Chip } from '@/ui/Display'23// Filter chips: independent toggles, aria-pressed4{filters.map((f) => (5 <Chip6 key={f.id}7 selected={active.includes(f.id)}8 onClick={() => toggle(f.id)}9 >10 {f.label}11 </Chip>12))}1314// Always announce the effect15<p aria-live="polite" className="sr-only">16 Showing {results.length} of {total} results17</p>1819// Input chips: Enter or comma commits, Backspace removes the last20<input21 value={draft}22 onChange={(e) => setDraft(e.target.value)}23 onKeyDown={(e) => {24 if (e.key === 'Enter' || e.key === ',') {25 e.preventDefault()26 commit()27 }28 if (e.key === 'Backspace' && draft === '') {29 setTokens((t) => t.slice(0, -1))30 }31 }}32 onBlur={commit} // never lose what they typed33/>3435// Removing must move focus somewhere sensible36function remove(id: string, index: number) {37 setTokens((t) => t.filter((x) => x.id !== id))38 const next = chipRefs.current[index + 1] ?? inputRef.current39 next?.focus()40}Framework-free HTML
<div role="group" aria-label="Filters">
<button type="button" class="ds-chip" aria-pressed="true">Production</button>
<button type="button" class="ds-chip" aria-pressed="false">Staging</button>
</div>
<!-- Removable token. Two targets, two names. -->
<span class="ds-chip ds-chip--selected">
<img class="ds-chip__avatar" src="…" alt="" />
ada@example.com
<button type="button" class="ds-chip__remove" aria-label="Remove ada@example.com">
<svg aria-hidden="true">…</svg>
</button>
</span>
<p class="sr-only" role="status" aria-live="polite">
Showing 31 of 248 deployments
</p>CSS
.ds-chip {
display: inline-flex;
align-items: center;
gap: 6px;
block-size: 28px; /* between a badge (22) and a button (36) */
padding-inline: 10px;
border: 1px solid var(--ds-border);
border-radius: 999px;
background: var(--ds-surface);
color: var(--ds-fg-secondary);
font-size: 13px;
font-weight: 500;
transition: all 120ms var(--ease-standard);
}
.ds-chip:hover:not(:disabled) {
border-color: var(--ds-border-strong);
background: var(--ds-layer-hover);
color: var(--ds-fg);
}
/* Three changes at once — a tint alone is too subtle in a row of twelve */
.ds-chip[aria-pressed='true'],
.ds-chip--selected {
border-color: var(--ds-accent-border);
background: var(--ds-accent-subtle);
color: var(--ds-accent-text);
}
.ds-chip:focus-visible {
outline: 2px solid var(--ds-focus-ring);
outline-offset: 2px;
}
/* Adornments reduce padding on their own side only */
.ds-chip:has(.ds-chip__avatar) { padding-inline-start: 4px; }
.ds-chip:has(.ds-chip__remove) { padding-inline-end: 4px; }
.ds-chip__remove {
inline-size: 18px;
block-size: 18px;
border-radius: 999px;
display: grid;
place-items: center;
}
.ds-chip__remove:hover { background: var(--ds-layer-active); }
@media (pointer: coarse) {
.ds-chip__remove { inline-size: 24px; block-size: 24px; }
}Component API
Chip
| Prop | Type | Default | Description |
|---|---|---|---|
| selected | boolean | false | Sets aria-pressed and the selected skin. |
| onRemove | () => void | — | Adds a remove button with its own accessible name and focus stop. |
| icon | ReactNode | — | Leading glyph, 13px. |
| avatar | ReactNode | — | Leading avatar. Reduces the left padding to 6px. |
| size | 'sm' | 'md' | 'md' | 24px or 28px tall. |
| as | 'button' | 'span' | 'button' | 'span' for a static token that is only removable, not toggleable. |
| disabled | boolean | false | Only meaningful when as="button". |
Professional tips
- Order filter chips by usage, not alphabetically. The two or three that everyone uses should be first and never move.
- Persist the active filter set in the URL. Filters that vanish on refresh cannot be shared or bookmarked, which is most of what filters are for.
- When a filter yields zero results, keep the chips visible and put the "clear filters" action inside the empty state. The user needs the escape hatch where they are looking.
- Chip inputs should accept a pasted comma- or newline-separated list and split it into tokens. People paste ten emails at once far more often than they type them.
Performance
- Debounce the query that filter chips trigger by about 200ms. Toggling three chips quickly should be one request, not three.
- For a very long chip row, use roving tabindex so Tab does not stop twenty times before reaching the results.
- Avoid animating chip layout on toggle. A wrapped row reflows when a chip changes width, and animating that reflow is both expensive and disorienting.
Common mistakes
- Using aria-selected instead of aria-pressed, which makes assistive tech describe a multi-filter bar as a single-choice list.
- Letting the remove button’s click bubble to the chip, so removing also toggles the filter.
- Losing focus to <body> after removing a chip, which drops a keyboard user back to the top of the page.
- Giving every remove button the accessible name "Remove", so a screen-reader user hears twelve identical buttons.
- Making a chip look selected on hover, so users cannot tell which filters are actually active while the pointer is in the bar.
Real-world recommendations
- Filter chips work up to about eight options. Past that, users stop scanning and start missing filters — move to a panel with grouped facets and echo the applied set as chips.
- In an email or invite field, validate tokens as they commit and mark invalid ones in red rather than refusing them. People fix a visible mistake; they get stuck on a field that will not accept input.
- Log which filter combinations are used. The top three usually deserve to become saved views, which removes the filtering step entirely.
- On mobile, a horizontally scrolling chip row is acceptable if the first chip is partially cut off — that overflow cue is what tells users to scroll.