List group
Summary
Stacked rows with dividers: the table's little sibling for data
that isn't columnar. Each item is a <span>
(static), an <a> (navigates) or a
<button> (acts), with an optional leading icon
and a trailing badge that floats to the end. Selection is
.soma-active; unavailable rows carry
aria-disabled="true".
When to use
| Pattern | Use it for |
|---|---|
| List group | Row lists that aren't columnar: settings, inbox previews, pick lists, master panes in a master/detail layout. |
| Table | Data with multiple comparable columns; use a table, sortable if the operator reorders it. |
| Nav list | Pure wayfinding (settings side-nav, in-widget navigation); use .soma-nav-vertical on the Navigation page; it has section headings and aria-current styling. |
Examples
Choosing the item element
The list group styles whatever element carries
.soma-list-group-item: pick the one whose
semantics match the row. This is the whole accessibility story:
the right element brings the right role, keyboard behaviour and
announcement for free.
| Element | When | Behaviour |
|---|---|---|
<span> | Display-only rows: key/value facts, previews. | Not focusable, no hover/press styling. |
<a href> | The row navigates somewhere. | Hover tint, focus ring, opens in new tab / copies link like any anchor. |
<button type="button"> | The row acts in place — select, run, toggle. | Hover tint, focus ring, Space/Enter activation. |
Don't mix a clickable row with nested interactive children — a row is one target. If a row needs its own actions, that's a table with an actions column.
States
Both states are markup-only: the CSS reads them; your code (or the server) writes them:
.soma-active— the current/selected row: primary-subtle fill, and a leading icon re-tints primary. One row at a time.aria-disabled="true"— unavailable row: dimmed text andpointer-events: none, so mouse clicks are swallowed. The element stays focusable, which is deliberate: keyboard users can still discover the row and hear its state; guard activation in your handler (snippet below).
Composition
Inside a row, order is: optional leading
.soma-icon (auto-tinted subtle), the text, then an
optional trailing .soma-badge that floats to the
end via auto margin (counts, statuses). Row padding follows the
density tokens, so list groups tighten with
data-soma-density like table rows do. The natural
host is a widget body; the
<ul> carries no outer border of its own,
just hairline dividers between rows.
HTML
Static rows: spans inside the list:
<ul class="soma-list-group">
<li><span class="soma-list-group-item">Region: us-east</span></li>
<li><span class="soma-list-group-item">Tier: production</span></li>
</ul>
Link rows with the full composition of leading icon, text, trailing badge:
<ul class="soma-list-group">
<li><a class="soma-list-group-item" href="/inbox">
<span class="soma-icon soma-icon-mail"></span>Inbox
<span class="soma-badge soma-badge-primary">9</span></a></li>
<li><a class="soma-list-group-item" href="/alerts">
<span class="soma-icon soma-icon-triangle-alert"></span>Alerts
<span class="soma-badge soma-badge-danger">2</span></a></li>
</ul>
Button rows: in-place actions:
<ul class="soma-list-group">
<li><button class="soma-list-group-item" type="button">Export as CSV</button></li>
<li><button class="soma-list-group-item" type="button">Duplicate project</button></li>
</ul>
States: one selected row, one unavailable row:
<ul class="soma-list-group">
<li><a class="soma-list-group-item soma-active" href="#">Current</a></li>
<li><button class="soma-list-group-item" type="button" aria-disabled="true">
Unavailable action</button></li>
</ul>
CSS classes
| Class | Effect |
|---|---|
.soma-list-group | The <ul> container; rows get hairline dividers (none after the last). |
.soma-list-group-item | A row, inside each <li>, as <span> (static), <a> or <button> (hover/focus styling, pointer cursor). Optional leading .soma-icon; a trailing .soma-badge floats to the end. |
.soma-active | Current/selected row: primary-subtle fill; a leading icon re-tints primary. |
[aria-disabled="true"] | Unavailable row: dimmed, mouse interaction suppressed (pointer-events: none). Keep the element focusable so the state is discoverable, and ignore its activation in your handler. |
JavaScript
None shipped — the list group is CSS-only. Selection is your
page's concern: move .soma-active (and
aria-current for navigation) in the click handler.
One delegated listener on the list covers every row:
const list = document.querySelector('#projects');
list.addEventListener('click', (e) => {
const item = e.target.closest('.soma-list-group-item');
if (!item) return;
list.querySelectorAll('.soma-active').forEach((el) => el.classList.remove('soma-active'));
item.classList.add('soma-active');
showDetail(item.textContent.trim()); // your master/detail logic
});
Guard disabled rows: pointer-events: none swallows
mouse clicks, but a focused <button> or
<a> still activates from the keyboard; the
handler must check the attribute:
list.addEventListener('click', (e) => {
const item = e.target.closest('.soma-list-group-item');
if (!item || item.getAttribute('aria-disabled') === 'true') return;
// … act on the row …
});
The live example above, exactly as this page wires it:
const pick = document.querySelector('#lg-pick');
pick.addEventListener('click', (e) => {
const item = e.target.closest('.soma-list-group-item');
if (!item || item.getAttribute('aria-disabled') === 'true') return;
pick.querySelectorAll('.soma-active').forEach((el) => el.classList.remove('soma-active'));
item.classList.add('soma-active');
document.querySelector('#lg-pick-out').textContent = item.textContent.trim();
});