Skip to main content
View as Markdown

Sidebar Navigation

The sidebar layout component — declarative navigation groups, fetched entries, expanding sections, the current-entry mark, and the icon rail.

A vertical navigation panel, usually paired with a container main region to form an app shell. Compose it statically from children, or declare its shape with groups and let it render as one navigation landmark of real links.

Path Kind Values Default Description
trackNavigation boolean Re-derive the current-entry mark on the client after a same-document navigation (default: false)

groups

Path Kind Values Default Description
groups array Labelled navigation groups rendered inside the sidebar
groups[] object A labelled block of sidebar entries, authored and/or fetched from an endpoint
groups[].label string Group heading; accepts a $t: translation key. Omit for a group that states no category name and contributes neither heading nor landmark
groups[].landmark string Named navigation landmark this group belongs to; groups sharing it are wrapped in one nav. Accepts a $t: key
groups[].headingLevel enum 2, 3, 4, 5, 6 Heading level for the group label; requires landmark
groups[].items array Authored entries, rendered before any fetched ones
groups[].items[] object A single link inside a sidebar navigation group, optionally expandable
groups[].items[].label string Entry label; accepts a $t: translation key
groups[].items[].icon string Lucide icon name rendered before the label
groups[].items[].activeMatch enum exact, prefix How the entry matches the request path to mark itself current: 'exact' (default) or 'prefix'
groups[].items[].badge string | object Literal marker text, or a read endpoint + value path for a live count
groups[].items[].badge.endpoint string Read endpoint whose response carries the badge count
groups[].items[].badge.valuePath string Dot path to the count in the response envelope (default: 'total')
groups[].items[].props object Properties for component templates, supporting variable references. A className or class value is validated as a Tailwind class list: arbitrary values are allowed, but url(, image-set(, attr(, expression( and @import are refused inside one.
groups[].items[].showWhen object Render this entry only while the reader is inside the named section; omitted, it is always part of the navigation
groups[].items[].showWhen.section string Path of the section this entry belongs to; the entry renders only at it or under it
groups[].items[].href string Destination path; omit it on an entry with children to make the row a toggle that goes nowhere
groups[].items[].children array Authored sub-entries; declaring any makes this entry a disclosure
groups[].items[].children[] object (truncated) One link inside a sidebar entry disclosure, optionally holding a nested list
groups[].items[].source object Rows source for an entry's disclosure, with the copy for its loading, error and empty states
groups[].items[].source.endpoint string Read endpoint path to fetch rows from (e.g. /api/admin/automations/runs)
groups[].items[].source.rowsKey string Key of the rows array in the response envelope (default: 'items')
groups[].items[].source.param string Route parameter substituted into the endpoint's :param placeholder. Must be declared by the host page's path.
groups[].items[].source.idKey string Key of each row's unique id (default: 'id')
groups[].items[].source.totalKey string Key of the total-count in the envelope; falls back to rows length if absent
groups[].items[].source.query object Static query params merged into every request to the endpoint
groups[].items[].source.bindTo string ID of a sibling shared filter/period selector whose published params are merged into every request to endpoint (the dynamic counterpart to the static query)
groups[].items[].source.sharedFilter object (truncated) Companion to bindTo: the shared selector params merged (dynamically) into every request to endpoint, alongside the static query. Inert without bindTo.
groups[].items[].source.labelKey string Row key whose value becomes each entry's label
groups[].items[].source.hrefTemplate string Entry href with {field} placeholders filled from the row
groups[].items[].source.itemProps object Properties for component templates, supporting variable references. A className or class value is validated as a Tailwind class list: arbitrary values are allowed, but url(, image-set(, attr(, expression( and @import are refused inside one.
groups[].items[].source.loadingLabel string Line shown while the children load; accepts a $t: key (default: "Loading…")
groups[].items[].source.errorLabel string Line shown when the children could not be loaded; accepts a $t: key (default: "Couldn't load the list.")
groups[].items[].source.emptyLabel string Line shown when the endpoint returned no rows; accepts a $t: key (default: "No items.")
groups[].items[].defaultExpanded boolean Whether the disclosure starts open (default: false)
groups[].items[].expandLabel string Toggle name while shut; must carry {label} (default: "Expand {label}"). Accepts a $t: key
groups[].items[].collapseLabel string Toggle name while open; must carry {label} (default: "Collapse {label}"). Accepts a $t: key
groups[].items[].childrenProps object Properties for component templates, supporting variable references. A className or class value is validated as a Tailwind class list: arbitrary values are allowed, but url(, image-set(, attr(, expression( and @import are refused inside one.
groups[].source object Rows-envelope system source for a sidebar group, projected to entries via labelKey + hrefTemplate
groups[].source.endpoint string Read endpoint path to fetch rows from (e.g. /api/admin/automations/runs)
groups[].source.rowsKey string Key of the rows array in the response envelope (default: 'items')
groups[].source.param string Route parameter substituted into the endpoint's :param placeholder. Must be declared by the host page's path.
groups[].source.idKey string Key of each row's unique id (default: 'id')
groups[].source.totalKey string Key of the total-count in the envelope; falls back to rows length if absent
groups[].source.query object Static query params merged into every request to the endpoint
groups[].source.bindTo string ID of a sibling shared filter/period selector whose published params are merged into every request to endpoint (the dynamic counterpart to the static query)
groups[].source.sharedFilter object Companion to bindTo: the shared selector params merged (dynamically) into every request to endpoint, alongside the static query. Inert without bindTo.
groups[].source.sharedFilter.params array Request-param keys this subscriber consumes from the shared publisher's value bag (omit to merge the full bag verbatim)
groups[].source.sharedFilter.params[] string One request-param key, as the bound publisher names it
groups[].source.labelKey string Row key whose value becomes each entry's label
groups[].source.hrefTemplate string Entry href with {field} placeholders filled from the row
groups[].source.itemProps object Properties for component templates, supporting variable references. A className or class value is validated as a Tailwind class list: arbitrary values are allowed, but url(, image-set(, attr(, expression( and @import are refused inside one.

rail

Path Kind Values Default Description
rail object Render the sidebar as an icon rail below the named breakpoint; omitted, it renders in full at every width
rail.below enum sm, md, lg, xl, 2xl Breakpoint at and above which the full sidebar returns; the rail applies at every width below it

groups

Assembling navigation from children means every app writes its own tree of containers, headings and anchors, each with slightly different accessibility. groups declares the shape instead.

A group needs at least one of items and source; declaring neither is refused at startup, because a heading with nothing under it is never what was meant. Declaring both is supported and useful — the authored entries come first.

source is the same rows-envelope binding a data component uses, plus two projections that turn a row into an entry: labelKey names the row key holding the label, and hrefTemplate is a path whose {field} placeholders are filled from the row.

app.yaml
- type: sidebar
  groups:
    - label: Overview
      items:
        - { label: Home, href: /, icon: home }
        - { label: Activity, href: /activity }
    - label: Products
      items:
        - { label: All products, href: /products }
      source:
        endpoint: /api/tables/products/records
        rowsKey: records
        labelKey: name
        hrefTemplate: /products/{name}

Marking the current entry

An entry can say how it recognises itself as the page you are on. The match runs on the server, and the matching entry carries aria-current="page" — so "where am I" is answerable by a screen reader, not only by a colour. activeMatch: exact is the default and marks when the request path equals href; prefix marks when it equals href or begins with href/.

Use prefix on a section entry so it stays marked while a visitor is inside it — /tables keeps its mark at /tables/customers. Do not use it on a root entry: href: / under prefix matching marks every page in the app.

Badging an entry

badge takes either a literal string — a status word your config can state truthfully, like Beta — or { endpoint, valuePath } for a count it cannot. valuePath is a dot path into the response, defaulting to total.

Sections inside one landmark

By default a group with a label is its own navigation landmark, named by that label. That is right when the groups are unrelated, and wrong when several of them are subdivisions of one navigation — a reader cycling landmarks then meets four "navigation"s where you meant one holding four headings.

landmark names the landmark a group belongs to; groups sharing a value are folded into one nav carrying it as the accessible name. headingLevel renders the group label as a real heading of that level, 2 to 6, instead of a landmark's name. Declaring neither leaves the group exactly as it rendered before these existed.

app.yaml
- type: sidebar
  groups:
    - label: Tables
      landmark: Data
      headingLevel: 2
      items: [{ label: Customers, href: /tables/customers }]
    - label: Files
      landmark: Data
      headingLevel: 2
      items: [{ label: Uploads, href: /files/uploads }]
    - label: Account
      items: [{ label: Settings, href: /settings }]

That is two landmarks: one named Data holding two headed sections, and one named Account by its own label.

Four shapes are refused at startup:

  • headingLevel without landmark. A group that is its own landmark is already named by its label, and a heading repeating it announces the same words twice to the same reader.
  • headingLevel on a group with no label. The heading's text is the label, so this would render an empty heading — a stop heading navigation offers that announces nothing. Reported by position, since a group with no label has no name to quote.
  • Groups sharing a landmark that are not listed together. The sidebar renders groups in declared order, so a landmark can only wrap an unbroken run of them, and silently reordering them would move entries you placed deliberately.
  • Two navigation landmarks sharing an accessible name, including a landmark colliding with the label of a group that is its own landmark.

headingLevel: 1 is refused by the schema: the page's own title is the h1, and a sidebar section is never the top heading of the document beside it.

A group with no label

Omit label and the group states no category name at all: it contributes neither a heading nor a landmark, and its entries render straight into the navigation root, above the first named group. A console's landing row is the way back out of every section rather than a section of its own.

Write it as a group rather than as an authored link child, for two reasons. Groups render before every authored child, so a child lands below the whole navigation and never above it. And everything the row needs is inherited from the navigation root: the shared entry styling, the current-entry mark with its aria-current, the rail's row rules, and the tracker that re-marks the current entry after a same-document navigation. A hand-written anchor outside that root gets none of it — in a rail it keeps a full-width label inside a 56px column.

A label-less group may still fetch from a source and may still declare a landmark. What it may not carry is headingLevel.

Naming a row with attributes

An entry carrying nothing but a label can only be reached by that label — so every assertion, analytics hook and operator runbook is coupled to display copy that translation will move. A props bag gives the row a name that does not move: items[].props lands on the entry's own link (or its toggle button, when the entry declares no href), items[].childrenProps on a disclosure's list element, and source.itemProps on each fetched entry, with {field} placeholders filled from the row exactly as hrefTemplate's are — and not percent-encoded, because it is an attribute rather than a URL.

The renderer computes href, class / className, aria-current, aria-expanded and aria-controls from the entry's own fields and state. Declaring any of them in a props bag is refused at startup, naming both the key and the entry, rather than being dropped in silence, which is what an attribute with two owners otherwise does.

Expanding an entry

A destination whose contents are themselves navigable — a records page over many tables, a files page over many buckets — otherwise costs a second click and a full page load just to discover what is under it.

A parent that declares an href stays a real link to its own page, with a small button carrying aria-expanded and aria-controls beside it. children holds authored sub-entries; source fetches them, on first expand and only once. defaultExpanded starts the disclosure open, expandLabel and collapseLabel name the toggle (Expand {label} and Collapse {label} by default), and childrenProps attributes the list element.

Declaring both children and source is refused at startup: only the fetched list has loading, error and empty states, and an authored one must not inherit them. Declaring defaultExpanded, expandLabel, collapseLabel or childrenProps on an entry with neither is refused too — there is nothing to expand.

Fetching on first expand rather than at render keeps the cost proportional: a sidebar of ten disclosures makes ten requests only if the reader opens all ten. A fetched list says which of its three states it is in, with copy you can override on the sourceloadingLabel (Loading…), errorLabel (Couldn't load the list.) and emptyLabel (No items.).

expandLabel and collapseLabel must carry {label}; one without it is refused at startup, because a fixed string gives every disclosure the same accessible name — which is exactly the "which one is this?" a name exists to answer.

The disclosure holding the current page is open on arrival, resolved on the server. That is independent of defaultExpanded: it is the navigation answering "where am I", not a default.

A parent that goes nowhere

Omit href and the whole row becomes the toggle — one button carrying the icon, the label, any badge and the chevron, with hover treatment and no selected state. href is optional only on a top-level entry, and only on one declaring children or source; sub-entries and leaf entries always require one.

Two consequences follow, and both are the point rather than a limitation. The row is never marked as the current page — aria-current says "this is the page you are on", and a row that goes nowhere can never be one — so activeMatch is refused on it. And its accessible name is its own label, read with the state aria-expanded already announces, so expandLabel and collapseLabel are refused for the same reason: on a row whose visible text is its name, a second string would shadow the words on the screen.

An entry with neither href nor children nor source is refused at startup, naming the entry and saying which it is missing. Such a row is neither a link nor a control — rendered, unclickable, and legal in every remaining field, which is exactly why nothing but this rule can catch it.

A third level

A sub-entry may carry children of its own — one more level, and the last. Use it when the destination is a single page holding many named parts: a component catalogue under a dozen headed categories, a settings page with a long rail of sections.

The third level is always open and carries no toggle. A disclosure inside a disclosure needs an accessible name saying which of two nestings it operates, and there is no brief wording that does. It takes none of the disclosure fields — defaultExpanded, expandLabel, collapseLabel and source all stay one level up — and a childrenProps declared without children is refused at startup. A fetched third level is not expressible either: a list with loading, error and empty states needs a disclosure to host them, which is what this level declines to be.

Showing an entry only inside its own section

showWhen is the one gate in the product that reads the request path. Give it a section, and the entry is part of the navigation at that path and under it, and absent everywhere else.

section is matched the way activeMatch: prefix matches: the request path equals it, or begins with it followed by /. So a row scoped to /design-system/ui-kit is still there at /design-system/ui-kit/button — a reader who drills into an object does not watch the navigation they arrived through disappear. It need not be any entry's own href, and it must start with /.

A gated entry is removed from the document, not hidden. A navigation that ships every row and hides most of them is one whose landmark, tab order and screen-reader reading all disagree with what is on the screen.

showWhen is available at any of the three levels. Pair it with a third level unless that list belongs in the chrome of every page — a third level that is always present is a sidebar that has become a site map.

trackNavigation

aria-current="page" is resolved on the server, which is right and sufficient for an app whose every navigation is a page load. An app that swaps its content region in place leaves the sidebar mounted and the server's mark frozen on the page the reader has already left — so the one element that answers "where am I" becomes the one element that is wrong.

trackNavigation: true re-derives the mark on the client after a same-document navigation. It is opt-in because it costs a client island, and an app doing only full page loads gains nothing from it.

The mark moves on two signals. popstate — browser back and forward — needs nothing from your app. The other is sovrium:navigated, the event an in-app swapper announces. Update window.location first, then dispatch a CustomEvent on document:

app.ts
history.pushState({}, '', '/products/widgets')
document.dispatchEvent(
  new CustomEvent('sovrium:navigated', { detail: { path: '/products/widgets' } })
)

That order is the contract, not a convention: the sidebar reads window.location itself, so detail.path is informational, and dispatching before the location is updated re-derives the mark onto the page the reader is leaving.

activeMatch is re-evaluated by the same rule on the client as on the server, so a prefix entry keeps its mark across a drill-in. A disclosure whose section becomes current opens with it — but is never re-opened after the reader has deliberately collapsed it.

rail — an icon rail on a narrow screen

A sidebar wide enough to read costs a laptop roughly a quarter of its width, and the surface beside it is usually the one the reader came for. rail keeps the navigation present at that width while giving the column back.

app.yaml
- type: sidebar
  rail: { below: xl }
  groups:
    - label: Data
      items:
        - { label: Tables, href: /tables, icon: table }
        - { label: Files, href: /files, icon: folder }

below is a strict lower bound: the rail applies at every width below the named breakpoint, and at it and above the sidebar renders exactly as it does without the key. So a console that wants a rail on a laptop and the full sidebar on a wide desktop names the breakpoint at which the desktop starts — xl — not the one at which the laptop does.

The tokens are responsive's: sm, md, lg, xl, 2xl. mobile is refused rather than accepted and ignored — it is the base rather than a breakpoint, so "below mobile" is the empty range and the rail would never apply, on every page, with nothing logged.

Below the breakpoint the sidebar takes a fixed 56px width, every entry centres its icon in it, and the entry labels, entry badges and group headings stop being painted. Give every entry an icon: a row with none is a 56px column of nothing.

The labels are not removed. They stay in the document and in the accessibility tree, and each row gains a title tooltip so a pointer reader can still name it. An entry whose accessible name changed with the viewport would be a link that resolves by name on a desktop and by nothing on a laptop — every deep link, runbook and test would hold at one width and silently fail at another while the page looked correct in both. Every row also keeps its tab stop and its aria-current="page" mark.

A rail is not a drawer. It stays present, stays a navigation landmark and stays reachable by keyboard in its declared order. An app that wants the sidebar to leave the layout on a phone and come back behind a button is describing a drawer, which is a different affordance with its own control. The two compose: a sidebar may be a rail from one breakpoint down and hidden behind a drawer from a narrower one, because the drawer is the frame's behaviour and the rail is the navigation's.

Omitting rail keeps today's rendering at every width.

Behaviour

Basic App Shell

  • A page with sidebar, header, and main components renders a dashboard-style layout
  • The sidebar renders as a fixed-width panel on the left side
  • The main content area fills remaining width beside the sidebar
  • Navigation items inside sidebar render as a vertical menu with labels and icons

App Shell Sidebar Layout

  • A page with sidebar + header + main renders a dashboard layout
  • Sidebar renders as a fixed-width panel on the left
  • Main content fills remaining width beside the sidebar
  • Navigation items inside sidebar render as a vertical menu
  • The mark moves to the entry matching an announced same-document navigation
  • The mark follows browser back and forward, with no cooperating swapper
  • activeMatch is re-evaluated by the same rule on the client as on the server
  • Without trackNavigation the server mark stays exactly where it was
  • A disclosure opens with its section and is never re-opened after a manual collapse

Sidebar Entry Disclosure

  • An entry with children renders a named toggle governing a shut list
  • Opening the toggle reveals the children and renames the control
  • The entry label stays a link to its own page while the toggle expands it
  • defaultExpanded starts the disclosure open, with the declared toggle names
  • A fetched list is requested on FIRST expand, and only once
  • A fetched list says which of its three states it is in
  • The disclosure of the current section is open on arrival, with its current child marked
  • An entry declaring both children and source is refused at boot
  • A toggle name carrying no {label} placeholder is refused at boot
  • A sub-entry renders its own children as an always-open list, with no second toggle
  • showWhen removes an entry outside its section and renders it inside, at any level
  • A sub-entry naming a list it does not have is refused at boot
  • An entry whose href declares a query is current only when the request carries it
  • A badge on an entry inside a disclosure survives hydration, at every depth
  • A disclosure entry's own source query reaches the fetch, as a group's does

Sidebar Entry Props

  • An authored entry carries its declared attributes on its own link
  • A fetched entry fills its {field} placeholders from its own row
  • A disclosure names its list and each of its children
  • A props key the renderer owns is refused at boot, naming the key and the entry
  • A renderer-owned key in an itemProps template is refused at boot
  • Authored groups render as a navigation landmark of real links under their headings
  • An entry icon renders as an inline SVG before its label
  • A sourced group lists one entry per row, after the authored entries
  • A group with neither items nor source is refused at boot, naming the group
  • Each group is its own named navigation landmark
  • The entry for the current page is marked, server-side
  • A prefix entry stays marked when the visitor drills in
  • An entry badge renders its literal, or a live count from its endpoint
  • A group source query reaches the fetch, so a filtered group renders filtered
  • Below the named breakpoint the sidebar is a fixed-width icon rail, every entry still drawing its icon
  • In the rail the entry labels and group headings stop being painted, and every entry keeps its accessible name and gains a pointer tooltip
  • At the breakpoint the full sidebar returns, and a sidebar declaring no rail never collapses (the control)
  • The rail keeps its keyboard order and its current-entry mark
  • An entry with children and no href renders as one toggle button, which opens and shuts its list without leaving the page
  • A toggle parent never carries the current-page mark, even while the reader is on one of its own children
  • A parent that declares an href still navigates, with its chevron beside it (the control)
  • An entry with neither an href nor children is refused at boot, naming the entry
  • A group with no label renders its entries above the first heading, contributing neither a heading nor a landmark
  • An entry in a label-less group takes the shared current-entry treatment, aria-current included
  • The same navigation with that group LABELLED gets its heading and its own landmark back (the control)
  • In a rail a label-less group's entries centre their icons and keep their accessible names
  • Groups sharing a landmark render as ONE navigation holding all their entries
  • A group with headingLevel announces its label as a real heading
  • A group declaring neither renders exactly as it always has
  • Two landmarks stay separate, each holding only its own sections
  • headingLevel outside a landmark is refused at boot, naming the group
  • Groups of one landmark split by another are refused at boot
  • A landmark name a self-landmarked group already answers to is refused at boot

Last updated September 23, 2026

This documentation was written with AI, so errors or outdated content are possible. Sovrium is in beta. Contributions and corrections are welcome.

Built with Sovrium