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
| Situation | Guidance |
|---|---|
| Filter toggles above a table or list | The home turf: KEV-listed, Suppressed, per-environment. Independent on/off facets that compose (AND/OR is your query's business). |
| Shareable filter state | Reflect pressed chips in the URL query string (encodeURIComponent each value) so a filtered view can be linked, bookmarked, and restored on load. |
| User-managed tags | Not a chip; use a label (linkable, removable). |
| Read-only status | Not a chip — use a badge; it must never look pressable. |
Examples
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
| Class | Effect |
|---|---|
.soma-chip | The 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-badge | Match-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