Sidebar navigation
The module rail of an ERP — grouped links that collapse to icons when
the shell is narrow, not when the viewport is. This page's own
sidebar is the live demo; the embedded shell below proves the
container-query behaviour independently.
Not for navigating within one screen — this rail moves
the reader between destinations, and every entry is somewhere else. A
long record's own sections are not destinations: jumping to "Delivery"
should scroll, not navigate, and marking it
aria-current="page" would claim a page change that never
happened. That is object
page's anchor bar, or plain
tabs when the sections are
mutually exclusive.
Both rail states, in real shells
Shell wider than 56rem — labels visible.
Shell at 56rem or narrower — collapsed to icons.
These are iframes, and they have to be.
The rail collapses under @container bo-shell (max-width: 56rem),
so it answers to the width of its shell, not the viewport. A demo
inline on this page has no such container, so it rendered the expanded rail
at every width — including 390 — and the state the component is most
interesting for could not be shown at all. A frame gives the demo its own
document and its own container, so the two panes above differ by nothing but
how wide their shell is. Drag the browser narrow and they stay as they are;
that is the point.
Grouped sections in an embedded shell
Sections (__section +
__heading) group a real ERP's modules — headings hide with the
labels when this 700px shell collapses to icons. Container queries, not
media queries.
How many entries fit before the top
group scrolls away. The rail is one scrolling column, so a long
second group pushes the first out of view — and the first group is usually
the one you need from anywhere (the modules). Measured on a real suite
shell with 7 module entries: at a 900px-tall viewport the modules stay
visible up to 11 entries in the second group and are gone
by 17; on a 700px laptop the limit is 11. If a group needs
more than that, the answer is not a taller rail — move the long list into
the page (a tab strip or an
anchor bar belongs to the
screen, not the chrome), or split the module. A second, narrower icon rail
is what large suites use, and this framework deliberately does not ship one
yet: no screen here has hit the limit, and adding a column to
.bo-app-shell — the most-composed layout in the framework — on
a hypothetical is how a grid grows a slot nobody fills (roadmap 130.2,
GAP-1).
A label longer than the rail
A label wider than the rail wraps onto another line. It is not truncated and it does not widen the rail. Wrapping is the deliberate choice: an ellipsis hides the part of a name that distinguishes it — "Accessibility Conformance…" and "Accessibility Conformance Report" read alike — and letting the label push the rail wider puts a horizontal scrollbar inside the navigation, so a reader has to drag sideways to finish reading a link. Both were real: this rail spilled 15.7px and scrolled until it was fixed. Nothing here is configurable, so long module names are safe to use without measuring them first.
On a narrow screen you are seeing icons, not wrapped text, and that is the same rule as above rather than an exception to it: this demo sits inside the docs' own app shell, so when that shell is narrow the rail collapses to icon-only and every label is visually hidden — wrapping only applies where there are labels to wrap. Widen the window to see it.
Markup
<nav class="bo-sidebar-nav bo-app-shell__sidebar" aria-label="Main">
<div class="bo-sidebar-nav__section">
<div class="bo-sidebar-nav__heading">Purchasing</div>
<ul>
<li><a class="bo-sidebar-nav__link" href="/invoices" aria-current="page">
<span class="bo-icon bo-icon--invoice bo-sidebar-nav__icon" aria-hidden="true"></span>
<span class="bo-sidebar-nav__label">Invoices</span>
</a></li>
</ul>
</div>
</nav> Quick reference generated
| Class | Role |
|---|---|
| .bo-sidebar-nav | block (root) |
| .bo-sidebar-nav__heading | part: heading |
| .bo-sidebar-nav__icon | part: icon |
| .bo-sidebar-nav__label | part: label |
| .bo-sidebar-nav__link | part: link |
| .bo-sidebar-nav__section | part: section |
State attributes: aria-current
API generated from CSS
| Blocks | bo-sidebar-nav |
|---|---|
| Parts | bo-sidebar-nav__headingbo-sidebar-nav__iconbo-sidebar-nav__labelbo-sidebar-nav__linkbo-sidebar-nav__section |
| ARIA styled | aria-current |
| JS required | None — CSS-only. |
- The current page carries
aria-current="page"— that is the programmatic channel; the visual highlight is the other half. - Icons are
aria-hiddenand labels stay in the accessibility tree when the rail collapses, so a screen-reader user never loses the link text. - Collapse is driven by a named container query on the shell — the same sidebar behaves correctly inside a split view, which a media query cannot do.
- Sections (
__section+__heading) group modules; headings hide with the labels when the rail collapses.
Contrast — 22 AA pairs this component uses (generated, CI-gated)
| Text | on | Light | Dark |
|---|---|---|---|
| text-primary | bg-surface | 17.74 | 16.15 |
| text-primary | bg-canvas | 16.98 | 18.08 |
| text-secondary | bg-surface | 10.31 | 11.46 |
| text-secondary | bg-muted | 9.37 | 9.75 |
| text-muted | bg-surface | 7.56 | 6.65 |
| text-muted | bg-muted | 6.87 | 5.66 |
| bg-surface | text-primary | 17.74 | 16.15 |
| accent-text | accent-subtle | 7.27 | 8.38 |
| danger-text | bg-surface | 6.47 | 8.89 |
| success-text | bg-surface | 5.02 | 12.02 |
| accent | bg-surface | 5.47 | 9.07 |
| text-primary | bg-hover | 16.12 | 13.75 |
| text-primary | bg-selected | 17.01 | 11.86 |
| text-secondary | bg-selected | 9.88 | 8.41 |
| accent-text | bg-selected | 7.27 | 8.38 |
| text-primary | bg-surface-raised | 17.74 | 14.51 |
| text-secondary | bg-canvas | 9.86 | 12.82 |
| border-control | bg-surface | 4.83 | 6.65 |
| --bo-state-error-color | bg-surface | 4.83 | 6.1 |
| accent | accent-subtle | 5.25 | 6.66 |
| warning-strong | bg-surface | 5.02 | 7.86 |
| text-primary | bg-muted | 16.12 | 13.75 |
Maturity generated
- Introduced
- v0.3.0
- CSS floor
- Chrome/Edge 105 · Firefox 110 · Safari 16
- Alignment scored
- 2026-08-23
- Assistive-tech evidence
- None recorded
Pin this component's first release with npm i @busy-office/ui@0.3.0.
The floor is derived from this component's stylesheet alone — set by
@container — so it sits at or below the
whole framework's Chrome/Edge 119 · Firefox 129 · Safari 17.5.
No assistive-technology runtime result is recorded for any component: it needs a human listening to VoiceOver and NVDA on owner hardware; no gate in this repo can simulate announcement behaviour.
What automation covers instead is test:axe across every page at 1440 and 390, and check:forced-colors — see
accessibility.
Design-system alignment generated
100% (18/18) — how closely this component follows the design language. Each rule is scored 0-3 against a checkable citation; rules that don't apply leave the denominator. Scored 2026-08-23.
| Rule | Score | Evidence |
|---|---|---|
| Typography | 3 / 3 | --bo-density-font-size on links; the 0.05em letter-spacing is intrinsic (same basis as combobox/form) |
| Colour | 3 / 3 | zero raw colour; hover/current use state + accent tokens |
| Spacing | 3 / 3 | the 14rem rail and the 3.25rem collapsed width both carry their reason, the collapsed one DERIVED from the icon slot plus padding; the visually-hidden 1px box is named as the idiom it is (94.6b) |
| Interaction | 3 / 3 | @container bo-shell collapse hides labels VISUALLY (clip-path), not display:none, so every link keeps its accessible name |
| Content | 3 / 3 | opener carries the wrong-choice clause: "Not for navigating within one screen" — every rail entry is a destination, so a record's own sections should scroll, and aria-current="page" there would claim a page change that never happened; points at object-page's anchor bar and tabs. Blind re-scored 2026-08-23 (Polish round 10); both demos verified to use aria-current on genuine destinations, matching the clause. |
| Fit | 3 / 3 | the shell rail; po-app renders it from ONE shared shell, so every screen carries it, and it composes inside .bo-offcanvas for the drawer pattern |