Filter chips

Summary

A chip is a toggleable filter pill: a <button> whose entire state lives on aria-pressed. Soma styles both states; your JS flips the attribute and re-filters whatever the chip governs. A nested .soma-badge shows how many rows the filter would match. Chips compose — a row of them above a table is a filter bar with no extra machinery.

When to use

SituationGuidance
Filter toggles above a table or listThe home turf: KEV-listed, Suppressed, per-environment. Independent on/off facets that compose (AND/OR is your query's business).
Shareable filter stateReflect pressed chips in the URL query string (encodeURIComponent each value) so a filtered view can be linked, bookmarked, and restored on load.
User-managed tagsNot a chip; use a label (linkable, removable).
Read-only statusNot a chip — use a badge; it must never look pressable.

Examples

Toggling

Click a chip; the demo JS flips aria-pressed:

Embedded counts

A nested badge previews the match count:

Disabled

A facet that doesn't apply keeps its footprint:

Governing a table

The pressed set filters the rows below, live:

ApplicationEnvironmentStatus
api-backendproductionhealthy
web-frontendproductiondegraded
api-backendstaginghealthy
billing-workerdevelopmentfailed

Each chip carries its value in data-env; rows show when their environment's chip is pressed (none pressed = show all). OR semantics here — AND is equally yours to define.

Pressed-state wiring

aria-pressed is the single source of truth: it is the accessible state (screen readers announce the chip as a pressed toggle button), the styling hook ([aria-pressed="true"] gets the primary-subtle fill), and the value your filter code reads. There is no class to keep in sync and nothing to initialise: render the attribute server-side and the chip is already correct. Give each chip a machine-readable value in a data-* attribute so the filter logic doesn't parse display text. A disabled facet keeps its footprint via the native disabled attribute.

Shareable filter state in the URL

A filter bar earns its keep when a filtered view can be linked. Serialise the pressed set into the query string (encodeURIComponent each value first, so a value containing the separator can't corrupt the list) and restore it on load before the first filter run:

// Serialise after every toggle…
function syncUrl(bar) {
  const pressed = [...bar.querySelectorAll('.soma-chip[aria-pressed="true"]')]
    .map((c) => encodeURIComponent(c.dataset.value));
  const url = new URL(location.href);
  if (pressed.length) url.searchParams.set('filters', pressed.join(','));
  else url.searchParams.delete('filters');
  history.replaceState(null, '', url);
}

// …and restore on load:
function restoreFromUrl(bar) {
  const active = (new URL(location.href).searchParams.get('filters') || '')
    .split(',').filter(Boolean).map(decodeURIComponent);
  bar.querySelectorAll('.soma-chip').forEach((c) => {
    c.setAttribute('aria-pressed', String(active.includes(c.dataset.value)));
  });
}

HTML

The minimal chip — a <button> with the state on aria-pressed:

<button class="soma-chip" aria-pressed="false">Suppressed</button>

Give each chip a machine-readable value for the filter logic:

<button class="soma-chip" aria-pressed="false" data-value="kev">KEV-listed</button>

With a match-count badge (see Badges for the bubble variants):

<button class="soma-chip" aria-pressed="true">KEV-listed
  <span class="soma-badge soma-badge-primary">12</span></button>

A disabled facet, dimmed but keeping its footprint in the bar:

<button class="soma-chip" aria-pressed="false" disabled>Archived</button>

CSS classes

ClassEffect
.soma-chipThe filter pill; always a <button>.
[aria-pressed="true"]Pressed state — primary-subtle fill. The attribute is both the styling hook and the accessible state; there is no class to keep in sync.
nested .soma-badgeMatch-count bubble inside the chip; see Badges for variants.
[disabled]Native disabled attribute: dimmed, no pointer events; keeps the pill's footprint in the filter bar.

JavaScript

None shipped. The chip is CSS-only. Toggling is one listener in your page because applying the filter is application-specific. Delegate it over the bar rather than binding per chip:

bar.addEventListener('click', (e) => {
  const chip = e.target.closest('.soma-chip');
  if (!chip) return;
  const on = chip.getAttribute('aria-pressed') === 'true';
  chip.setAttribute('aria-pressed', String(!on));
  applyFilter();          // …and syncUrl(bar) for shareable views
});

Reading the pressed set is one query; the attribute selector does the work:

const pressed = [...bar.querySelectorAll('.soma-chip[aria-pressed="true"]')]
  .map((c) => c.dataset.value);   // e.g. ['production', 'staging']

The live examples above, exactly as this page wires them:

// One delegated listener toggles every chip on the page; when the chip
// belongs to the filter bar, the table filter re-runs too.
const bar = document.getElementById('chip-filter-bar');
const rows = document.querySelectorAll('#chip-filter-table tbody tr');

function applyChipFilter() {
  const pressed = [...bar.querySelectorAll('.soma-chip[aria-pressed="true"]')]
    .map((c) => c.dataset.env);
  rows.forEach((row) => {
    const show = pressed.length === 0 || pressed.includes(row.dataset.env);
    row.style.display = show ? '' : 'none';
  });
}

document.querySelector('.docs-examples').addEventListener('click', (e) => {
  const chip = e.target.closest('.soma-chip');
  if (!chip) return;
  const on = chip.getAttribute('aria-pressed') === 'true';
  chip.setAttribute('aria-pressed', String(!on));
  if (bar.contains(chip)) applyChipFilter();
});

applyChipFilter();   // apply the initial pressed set