Skip to content

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.

Live preview

Everything below is the real component. Change the controls, tab through it, and turn on Inspector Mode to read any value off the screen.

Playground

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.

Showing 31 deployments matching 2 filters

Input chips

Values committed into a field. Enter or comma adds, the × removes, and Backspace on an empty field removes the last one.

Aada@example.comGgrace@example.com

Enter or comma to add. 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.

Applied:Environment: ProductionStatus: FailedLast 24 hours

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.

Default
Hover
Selected
Focus
Disabled
Removable
With icon
With avatar
Small
Read only
Staticas="span"
Truncated
Count

Anatomy

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.

  1. Height28px (md), 24px (sm)

    Between a badge and a button. Interactive but clearly secondary to the page’s actual actions.

  2. Padding10px, 6px when adorned

    Reduced on the side that carries an icon or avatar, so the adornment does not push the label off-centre.

  3. Selected stateTint + accent border + accent text

    Three changes at once. A tint alone is too subtle in a wrapped row of a dozen chips.

  4. 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.

  5. Row gap8px

    Enough that the pills read as separate objects rather than one long bar, and enough that adjacent focus rings do not collide.

  6. ShapeFully rounded

    Shared with badges. The height difference and the hover state are what distinguish them, not the silhouette.

Design tokens used

Values are read live from the running stylesheet, so this table can never drift from the code. Click any value to copy it.

Color

TokenValueUsed 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

TokenValueUsed for
padding-xHorizontal padding
gapIcon or avatar to label
row gapBetween chips

Radius

TokenValueUsed for
full—Pill shape

Typography

TokenValueUsed for
--text-labelLabel

Motion

TokenValueUsed for
durationHover and selection transition

Recommended sizes

Pick a size from this table. Do not invent a new one — a fourth height is how a design system starts dying.

SizeHeightPaddingRadiusIconLabel gapTypeTouch targetWhen to use
Small24px0 8pxfull12px—12px44px (padded)Inside inputs as tokens, and in dense table toolbars.
Medium28px0 10pxfull13px—13px44px (padded)The default. Filter bars and applied-filter rows.
With remove28px0 4px 0 10px——6px——Right padding drops to make room for the 18px remove button.
With avatar28px0 10px 0 6px——6px——Left padding drops so the 20px avatar sits flush inside the pill.

Do

31 of 248 deployments
Show the effect of the filterA chip that toggles with no visible consequence leaves the user unsure whether it did anything. A live result count is the cheapest possible feedback.
Give the remove button its own name"Remove" repeated twelve times is useless to a screen-reader user. "Remove Production" tells them exactly what disappears.
if (e.key === 'Backspace' && draft === '')
  removeLast()
Support Backspace in chip inputsEvery user has learned this from email "To" fields. It costs three lines and its absence is felt immediately, even by people who cannot say what is wrong.
Offer Clear all once several are activeRemoving six filters one at a time is six clicks and six re-renders. The escape hatch should appear as soon as more than one is active.

Don't

Do not use a chip for an actionChips set values. A chip that deletes something or navigates away breaks the mental model, and it is a small, low-contrast target for a consequential action.
0 results…and no visible filters
Do not let the active set become invisibleFilters applied in a hidden panel with no echo above the results is the classic "why is my list empty?" bug. The user has no way to see what is excluding everything.
Do not wrap chips onto four linesA filter bar taller than the results is not a filter bar. Past about eight, collapse into a "+5 more" popover or move to a proper filter panel.
Click anywhere to delete
Do not make the remove button the whole chipIf clicking anywhere removes it, the user cannot click the chip to edit or re-select. The × is a separate target for exactly that reason.

Accessibility

Not a checklist to run at the end. These are the requirements the component was built from.

1.4.11Non-text ContrastAA2.1.1KeyboardA2.5.8Target Size (Minimum)AA4.1.2Name, Role, ValueA

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

TabMoves to each chip, then to its remove button. Both are focusable.
Space / EnterToggles a filter chip.
Backspace / DeleteOn a focused removable chip, removes it and moves focus to the next one.
BackspaceIn 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.
AttributeApplied toNotes
aria-pressedFilter chipsThey are independent toggles. aria-selected would imply a single-selection listbox.
aria-labelThe remove buttonMust include the value: "Remove Production". A bare "Remove" is useless in a row of twelve.
aria-live="polite"The result countAnnounces the effect of the filter. Without it, a screen-reader user toggles a chip and hears nothing.
role="group"A filter barWith aria-label="Filters", so the whole bar can be skipped or targeted.
aria-describedbyA chip inputPoints at the "Enter or comma to add" instruction.

Code

Example usage

tsx
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

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

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

PropTypeDefaultDescription
selectedbooleanfalseSets aria-pressed and the selected skin.
onRemove() => void—Adds a remove button with its own accessible name and focus stop.
iconReactNode—Leading glyph, 13px.
avatarReactNode—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.
disabledbooleanfalseOnly meaningful when as="button".

Notes

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.