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

PatternUse it for
List groupRow lists that aren't columnar: settings, inbox previews, pick lists, master panes in a master/detail layout.
TableData with multiple comparable columns; use a table, sortable if the operator reorders it.
Nav listPure wayfinding (settings side-nav, in-widget navigation); use .soma-nav-vertical on the Navigation page; it has section headings and aria-current styling.

Examples

Static rows

Span items — display only, no hover:

  • Region: us-east
  • Tier: production
  • Owner: platform-team

Link rows

Anchor items — each row navigates:

Button rows

Button items — each row acts in place:

Leading icons

An optional .soma-icon before the row text:

Trailing badges

A trailing .soma-badge floats to the end automatically:

Active row

.soma-active marks the current/selected row:

Disabled row

aria-disabled="true" — dimmed, activation suppressed:

Selection wiring (live)

A pick list; the click handler moves .soma-active:

Selected: nware-soma. Selection is your page's concern; the wiring is in the JavaScript section below.

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.

ElementWhenBehaviour
<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 and pointer-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

ClassEffect
.soma-list-groupThe <ul> container; rows get hairline dividers (none after the last).
.soma-list-group-itemA 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-activeCurrent/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();
});