Navigation

Summary

Wayfinding parts beyond the shell itself: breadcrumbs above page titles, pagination under collections, and the generic nav lists (vertical nav and the horizontal navgroup) for settings pages and content-area sub-navigation. Type-ahead search is Quicksearch; expandable hierarchies are the Tree. Sidebar nav groups — the shell sidebar's collapsible sections — are documented below, and sit inside a full dashboard on the Shell page.

When to use

ComponentUse it for
BreadcrumbsLocation in a hierarchy ≥2 levels deep; current page is text with aria-current="page".
PaginationChunked collections. Use the simple Newer/Older form for feeds.
QuicksearchType-ahead over a known set: pages, projects, people. Soma owns keys/open/close; you render items.
TreeExpandable hierarchies: resources, files, component inventories.
Sidebar nav groupsCollapsible sections in the shell sidebar — a product area disclosing its pages ("DevOps Agent ▾"). This site's own sidebar is built from them.

Examples

Vertical nav list

A generic list for settings pages and in-widget navigation, with section headings:

Horizontal navgroup

Content-area sub-navigation — primary links start, secondary flush end:

Sidebar nav groups (live)

Collapsible sections for the shell sidebar. An open group's toggle lifts to full ink with a tree guide beside its children, and the group holding the current page keeps its toggle in primary — click the toggles:

Breadcrumbs — two levels

The minimum that earns a trail:

Breadcrumbs — deep trail

Chevron separators come from the icon set and mirror in RTL:

Breadcrumbs — overflow (middle-collapse, live)

Constrained to 320px. The middle of the trail collapses into a generated "…" menu; the first item and the current page always stay. Resize the window and the trail re-computes:

Pagination — full

Numbered pages; ellipsis for gaps, disabled chevron at the edge:

Pagination — buttons (app-driven, live)

Buttons instead of links when paging swaps content in place:

Current page: 1. The click handler moves aria-current and toggles aria-disabled at the edges (wiring in the JavaScript section).

Pagination — simple

Newer / Older for feeds and timelines, no page numbers:

Breadcrumbs

A labelled <nav> with an ordered list: every ancestor is a link, the current page is plain text with aria-current="page" (never a link to itself). Chevron separators are generated from the icon set and flip direction under RTL; nothing separator-like belongs in the markup:

<nav class="soma-breadcrumbs" aria-label="Breadcrumbs">
  <ol>
    <li><a href="/projects">Projects</a></li>
    <li><a href="/projects/nware">Nware</a></li>
    <li aria-current="page">Soma</li>
  </ol>
</nav>

Long trails wrap onto further lines rather than truncating. Where wrapping is unwanted (page headers, tight panels), opt into the JS middle-collapse instead. Add data-soma-collapse and the overflowing middle of the trail folds into a "…" menu (see Breadcrumb overflow under JavaScript below). Place the trail directly above the .soma-page-title; a single-level page earns no trail at all.

Pagination

Items are anchors (server-rendered paging), buttons (in-place paging) or spans (the ellipsis); the styling is identical. The current page carries aria-current="page" (solid primary), unavailable ends carry aria-disabled="true" (dimmed, mouse-inert), and gaps are a .soma-pagination-ellipsis span. Chevron-only items need an aria-label:

<nav class="soma-pagination" aria-label="Pagination">
  <a href="?page=1" aria-disabled="true" aria-label="Previous page">
    <span class="soma-icon soma-icon-chevron-left"></span></a>
  <a href="?page=1" aria-current="page">1</a>
  <a href="?page=2">2</a>
  <span class="soma-pagination-ellipsis">…</span>
  <a href="?page=12">12</a>
  <a href="?page=2" aria-label="Next page">
    <span class="soma-icon soma-icon-chevron-right"></span></a>
</nav>

The button form for app-driven paging — same classes, no hrefs to dead-end:

<nav class="soma-pagination" aria-label="Results pagination">
  <button type="button" aria-disabled="true" aria-label="Previous page">
    <span class="soma-icon soma-icon-chevron-left"></span></button>
  <button type="button" aria-current="page">1</button>
  <button type="button">2</button>
  <button type="button" aria-label="Next page">
    <span class="soma-icon soma-icon-chevron-right"></span></button>
</nav>

Feeds and timelines take the simple two-item form (Newer / Older, no page numbers):

<nav class="soma-pagination" aria-label="Feed pagination">
  <a href="?before=…"><span class="soma-icon soma-icon-chevron-left"></span>Newer</a>
  <a href="?after=…">Older<span class="soma-icon soma-icon-chevron-right"></span></a>
</nav>

Nav lists

Two generic primitives share the .soma-nav-item link style; neither is welded to the shell. The vertical list stacks items with optional small-caps .soma-nav-heading section labels, for settings side-navs and in-widget navigation:

<nav class="soma-nav-vertical" aria-label="Settings">
  <span class="soma-nav-heading">Account</span>
  <a class="soma-nav-item" aria-current="page" href="/profile">Profile</a>
  <a class="soma-nav-item" href="/security">Security</a>
  <span class="soma-nav-heading">Workspace</span>
  <a class="soma-nav-item" href="/members">Members</a>
</nav>

The navgroup is a tinted horizontal strip for content-area sub-navigation (the level between tabs and the app navbar) — primary links at the start, a secondary group flush at the end:

<nav class="soma-navgroup" aria-label="Application">
  <div class="soma-navgroup-primary">
    <a class="soma-nav-item" aria-current="page" href="/overview">Overview</a>
    <a class="soma-nav-item" href="/deployments">Deployments</a>
  </div>
  <div class="soma-navgroup-secondary">
    <a class="soma-nav-item" href="/settings">Settings</a>
  </div>
</nav>

Mark the current item with aria-current="page" (preferred: state and styling from one attribute) or .soma-active; both render identically. Give every <nav> an aria-label so multiple landmarks on one page stay distinguishable.

Sidebar nav groups

The shell sidebar's collapsible sections: a toggle styled as a sidebar link that discloses a set of child links. The toggle is a real <button> carrying aria-controls and aria-expanded; the items panel mirrors the state on aria-hidden. Declare the initial state on either side — auto-init normalises the other. Child links must carry .soma-sidebar-sublink: it provides the indent that separates them from sibling toggles. The leading icon is optional; the geometry works with or without it. The example widget above wires exactly this markup:

<aside class="soma-sidebar" aria-label="Demo sidebar">
  <nav class="soma-sidebar-nav" aria-label="Demo agents">
    <span class="soma-sidebar-heading">Agents</span>
    <div class="soma-sidebar-group">
      <button class="soma-sidebar-link soma-sidebar-group-toggle"
              aria-controls="navdemo-devops" aria-expanded="true">
        <span class="soma-icon soma-icon-apps"></span>DevOps Agent
      </button>
      <div class="soma-sidebar-group-items" id="navdemo-devops" aria-hidden="false">
        <a class="soma-sidebar-link soma-sidebar-sublink soma-active"
           aria-current="page" href="#">Dashboard</a>
        <a class="soma-sidebar-link soma-sidebar-sublink" href="#">Deployments</a>
        <a class="soma-sidebar-link soma-sidebar-sublink" href="#">IaC Plans</a>
      </div>
    </div>
    <div class="soma-sidebar-group">
      <button class="soma-sidebar-link soma-sidebar-group-toggle"
              aria-controls="navdemo-network" aria-expanded="false">
        <span class="soma-icon soma-icon-refresh"></span>Network Agent
      </button>
      <div class="soma-sidebar-group-items" id="navdemo-network" aria-hidden="true">
        <a class="soma-sidebar-link soma-sidebar-sublink" href="#">Topology</a>
        <a class="soma-sidebar-link soma-sidebar-sublink" href="#">Policies</a>
      </div>
    </div>
  </nav>
</aside>

Three wayfinding states come free, all CSS. An open group's toggle lifts from muted to full text ink and a hairline tree guide ties its children to it. The group whose child link carries .soma-active paints its toggle in primary ink — the section you are in stays visible however many groups are open. Mark the current page's link with .soma-active (plus aria-current="page" for semantics); this site's own sidebar is the reference consumer of all three states.

CSS classes

ClassEffect
.soma-breadcrumbsThe trail; chevron separators come from the icon set and mirror in RTL. Current page: li[aria-current="page"], darker and medium-weight.
.soma-breadcrumbs-moreGenerated by Soma.breadcrumbs: the trail item holding the "…" dropdown trigger. Not for authoring; open state gets the primary-subtle fill.
.soma-paginationItem row; anchors, buttons and spans style alike (32px targets). Current page: aria-current="page"; disabled ends: aria-disabled="true".
.soma-pagination-ellipsisThe non-interactive gap marker between page numbers.
.soma-nav-itemThe shared link style of both nav lists: hover and current state get the primary-subtle fill; current also goes semibold.
.soma-nav-verticalGeneric vertical nav list (not welded to the shell); holds .soma-nav-heading section labels + .soma-nav-item links.
.soma-nav-headingSmall-caps section label between item groups in a vertical list; every heading after the first draws a hairline separator above itself.
.soma-navgroupContent-area sub-navigation bar (the level between tabs and the navbar): a tinted strip with -primary and -secondary groups of .soma-nav-item; the secondary group is pushed flush to the end.
.soma-sidebar-groupWrapper for one collapsible sidebar section: the toggle plus its items panel. The group whose items contain a .soma-active link paints its toggle in primary ink (the active trail).
.soma-sidebar-group-toggleThe disclosure <button>, styled as a sidebar link with a trailing chevron; needs aria-controls pointing at the items panel's id. Open (aria-expanded="true") rotates the chevron and lifts the label to full text ink.
.soma-sidebar-group-itemsThe child-link panel; aria-hidden="true" hides it. While open, a hairline tree guide runs under the toggle's icon column beside the children.
.soma-sidebar-sublinkRequired on the child links: slightly smaller size and the indent that separates children from sibling toggles.

JavaScript

Pagination and the nav lists ship no JS — the state hooks are ARIA attributes. Four navigation behaviours ARE JS-driven: breadcrumb overflow and sidebar nav groups (both below), the navbar's overflow "More" menu (live demo and API on the Navbar page) and the resizable sidebar — demoed live on the Shell page's dashboard shell.

Breadcrumb overflow — Soma.breadcrumbs

Middle-collapse for long trails, opt-in per trail. When the items exceed the container's width, the middle of the trail moves into a generated "…" dropdown placed after the first item. The first item and the current page never collapse, and as many further trailing items as fit stay visible. A ResizeObserver on the <nav> re-computes on every container resize. Items are moved, never cloned, so their listeners and state survive the round-trip in and out of the menu.

<nav class="soma-breadcrumbs" aria-label="Breadcrumbs" data-soma-collapse>
  <ol>
    <li><a href="/">Home</a></li>
    <li><a href="/projects">Projects</a></li>
    <li><a href="/projects/nware">Nware</a></li>
    <li aria-current="page">Soma</li>
  </ol>
</nav>
MemberDescription
Soma.breadcrumbs(el)Get or create the singleton for a .soma-breadcrumbs element / selector and collapse it to fit. Throws when nothing matches or the class is missing; a trail without an <ol>/<ul> is inert.
data-soma-collapseAuto-init hook: every .soma-breadcrumbs[data-soma-collapse] is bound on DOM ready. Trails without the attribute keep the default CSS wrapping.
.refresh()Re-measure and redistribute items between the trail and the menu. Runs automatically on container resize; call it after content changes inside items (e.g. a renamed project). Returns the instance (chainable).
.destroy()Restore the full trail in source order and remove the generated trigger and menu. The item set is snapshotted at init — after adding or removing trail items, destroy() and re-init.
i18n breadcrumbs.moreThe trigger's accessible name (the visible label is the "…" glyph), localised via Soma.i18n, translated in every built-in pack.

Trails of fewer than three items never collapse (there would be nothing to put in the menu). The generated trigger is a standard dropdown2 trigger: keyboard and screen-reader behaviour (arrows, Home/End, Escape, menu roles) comes from that component unchanged.

// Explicit binding (or let data-soma-collapse auto-init it):
const bc = Soma.breadcrumbs('#trail');

bc.refresh();   // after changing text inside the items
bc.destroy();   // restore the full trail (re-init after items change)

The live overflow demo above, exactly as this page wires it:

Soma.breadcrumbs('#bc-collapse');   // idempotent alongside data-soma-collapse

Responsive navbar — Soma.responsiveNavbar

Overflow handling for the app navbar's primary link list. Items that don't fit collapse into a generated, localised "More" dropdown. The component, its slots and markup contract, a live overflow demo, and the full API live on the dedicated Navbar page.

Sidebar resize — Soma.sidebarResize

A drag handle for the shell sidebar. The chosen width is applied to the enclosing .soma-shell grid template (the sidebar is a grid column — resizing the element alone would not move the content edge; without a shell ancestor the element's own width is set instead) and persisted per id in localStorage.

MemberDescription
Soma.sidebarResize(el, options)Get or create the singleton for the sidebar element / selector; appends the .soma-sidebar-handle and restores the persisted width (silently; no event, nothing written). Throws when nothing matches.
options.idRequired: the persistence key; the stored entry is soma-sidebar-width:<id>. Omitting it throws.
options.minWidthNumber or fn() => number. Default 180.
options.maxWidthNumber or fn() => number. Default: a third of the viewport width, computed at each clamp; functions re-evaluate, so viewport-relative bounds track window resizes.
options.onResizefn(width), called on every applied resize (drag moves, keyboard steps, setWidth), not on the initial restore.
.setWidth(px)Set the width programmatically. Clamped to the bounds, persisted, onResize + event fired. Returns the instance (chainable).
.destroy()Remove the handle and reset the shell grid (or element width). The persisted value remains for the next init.
Event soma-sidebar-resizeBubbling CustomEvent on the sidebar element, detail { width }; fires with onResize, including continuously during a drag.

The handle is a focusable role="separator" (vertical orientation, localised sidebar.resize label) whose aria-valuenow/-valuemin/-valuemax track the width:

KeyAction
/ ±10px. Arrows are physical: the sidebar grows toward the content edge, so under RTL the roles swap.
Shift + /±50px.
Home / EndJump to the minimum / maximum width.
// Attach once; the width stored under "soma-sidebar-width:console"
// is restored silently on init.
const sb = Soma.sidebarResize('.soma-sidebar', {
  id: 'console',
  minWidth: 200,
  maxWidth: () => Math.round(window.innerWidth / 3),  // numbers or functions
  onResize: (width) => console.log('sidebar', width),
});

sb.setWidth(280);   // clamped, persisted, fires the event

Listen without holding the instance — the event bubbles:

document.querySelector('.soma-sidebar')
  .addEventListener('soma-sidebar-resize', (e) => {
    chart.reflow();               // e.detail.width is the new pixel width
  });

Forget the operator's stored width:

localStorage.removeItem('soma-sidebar-width:console');
sb.destroy();   // removes the handle and restores the shell grid

Sidebar nav groups — auto-init

No constructor and no instance API — the markup plus ARIA is the whole contract. Every .soma-sidebar-group-toggle[aria-controls] is bound on load; for markup injected later, bind the container with Soma.scan(container). Each click flips aria-expanded/aria-hidden and fires a bubbling event:

EventDetail
soma-sidebar-group-toggleBubbling CustomEvent on the toggle; detail.toggle (the button), detail.items (the panel), detail.open (boolean, the new state).

Persistence is deliberately yours: listen once on the sidebar and store what is open, then render the stored groups with aria-expanded="true" on the next page. This site's sidebar does exactly this:

document.querySelector('.soma-sidebar')
  .addEventListener('soma-sidebar-group-toggle', () => {
    const open = [...document.querySelectorAll('.soma-sidebar-group-toggle')]
      .filter((t) => t.getAttribute('aria-expanded') === 'true')
      .map((t) => t.getAttribute('aria-controls'));
    localStorage.setItem('app-nav-open', JSON.stringify(open));
  });

The live pagination example above, exactly as this page wires it:

const pg = document.querySelector('#pg-buttons');
const out = document.querySelector('#pg-out');
const pages = [...pg.querySelectorAll('button:not([aria-label])')];
const prev = pg.querySelector('[aria-label="Previous page"]');
const next = pg.querySelector('[aria-label="Next page"]');

const setPage = (n) => {
  pages.forEach((b) => {
    if (Number(b.textContent) === n) b.setAttribute('aria-current', 'page');
    else b.removeAttribute('aria-current');
  });
  prev.setAttribute('aria-disabled', String(n === 1));
  next.setAttribute('aria-disabled', String(n === pages.length));
  out.textContent = String(n);
};

pg.addEventListener('click', (e) => {
  const btn = e.target.closest('button');
  if (!btn || btn.getAttribute('aria-disabled') === 'true') return;
  const current = Number(out.textContent);
  if (btn === prev) setPage(current - 1);
  else if (btn === next) setPage(current + 1);
  else setPage(Number(btn.textContent));
});