Every product built on Once UI grew its own sidebar, and they all converged on the same two rows: a link that knows whether it is the current page, and a collapsible group of those links indented behind a rail. These are those two.
They are rows, not a sidebar. Headers, footers, org switchers and storage meters differ per product and stay yours to compose.
badge prints a count and caps it at 9+; text such as "New" prints as given. indicator is the dot for unread with no number worth showing.
A link to a person, a team or a workspace can lead with its avatar. Pass it, or any other node, as prefix: it sits where the icon would, and stays out of the row's accessible name so the link reads as the label alone.
The rail is a vertical Line, not padding — indentation alone stops reading as hierarchy the moment a label wraps. A group manages its own open state unless you pass open and onToggle, which is what you do to open the group holding the current route.
Wrap the rows in Nav and set the look once. Three props cover the sidebars Once UI apps actually ship: how tight the rows are, how the current page stands out, and whether groups draw their rail. Every row inside follows, and NavSection gives each block its heading.
- density: compact rows are 32px with the small label, the size for dashboards. normal is 40px, spacious 48px. The names match the theme's density setting, so a compact app and a compact sidebar mean the same thing.
- variant: solid fills the current row. subtle fills it and dims the others, the docs look. minimal drops fills and marks the current row by weight and contrast; hover still tints.
- rail: the line down an open group. Off, the children keep the indent, so nothing shifts.
The rows work without a Nav too, at normal, solid, with the rail. An explicit size on a NavItem still beats the nav's density. A line between sections is just a Line.
An editor or a dense app often keeps only the icons. Set collapsed and the Nav narrows to one icon column: labels, badges and section headings fade out, groups close, and every row still answers to its label for a screen reader.
Add expandOnHover and the rail opens over the page, labels and all, while a mouse rests on it or the keyboard is in it, instead of a tooltip per icon. It keeps its collapsed width in the layout, so the panel beside it never moves. Touch never opens it, Escape closes it, and reduced motion drops the animation.
- The rail lifts above what follows it, so keep it out of an ancestor that clips overflow: a sidebar Column with overflowY="auto" cuts the open rail off at its edge.
- Give every row an icon. A collapsed row is its icon; one without shows as an empty pill.
- Without expandOnHover, collapsed is a static icon rail, for a host that toggles it from a button of its own.
This is the part worth taking from here rather than writing again.
pathname === href misses a nested route: on /settings/billing/invoices/42 nothing is highlighted. pathname.startsWith(href) overshoots the other way: /settings is a prefix of every child, so the parent lights up alongside them. The answer is the longest href that matches.
It returns null when nothing matches, and will not match a sibling that merely shares a prefix — /settings-v2 is not inside /settings.
- ToggleButton — what a NavItem is underneath
- Accordion — what a NavGroup is underneath
- SplitView — the other half of an app shell