Skip to content
TrademarkTrademark
Docs
Components

Nav, NavItem and NavGroup

Updated: October 23, 2026
View on GitHub

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.

A row

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.

With an avatar

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.

Type
Default
labelrequired
The text.
ReactNode—
icon
Leading icon. Leave it off inside a NavGroup, where the rail carries the hierarchy instead.
IconName—
prefix
Any node before the label, such as an Avatar. Decorative: hidden from assistive tech, so the row is named by its label.
ReactNode—
badge
A pill at the end of the row. A number is a count: above 9 it prints 9+, with the real number on the label for assistive tech. Text such as "New" prints as given.
numberReactNode
—
indicator
A dot at the end of the row, when there is no number worth showing.
boolean—

A group

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.

Type
Default
labelrequired
The group's own text.
ReactNode—
icon
Leading icon, matching the NavItems beside it.
IconName—
childrenrequired
The rows inside.
React.ReactNode—
open
Open state, when the host owns it — to keep one group open at a time, say.
boolean—
onToggle
Called on the header. Only needed alongside open.
() => void—
defaultOpen
Starting state when the group manages its own.
booleanfalse
selected
Mark the header itself as current, for a group that is also a link.
booleanfalse
…and every FlexProps propinherited—

One look for the whole sidebar

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.

Type
Default
density
Row height and text size: compact (32px, small label), normal (40px) or spacious (48px).
normalcompactspacious
normal
variant
How the current page stands out: solid fills it, subtle fills it and dims the rest, minimal uses weight and contrast only.
solidsubtleminimal
solid
rail
The vertical line down an open NavGroup. Off, the indent stays.
booleantrue
collapsed
Icons only. Labels, badges and headings fade out but stay the rows' accessible names; groups close.
booleanfalse
expandOnHover
With collapsed, open over the page on a resting mouse or keyboard focus, without moving the layout.
booleanfalse
expandedWidth
How wide expandOnHover opens: rem, or a spacing token. 16 by default.
numberSpacingToken
16
…and every FlexProps propinherited—
Type
Default
label
The heading over the rows. Leave it off for an untitled block.
ReactNode—
action
A control at the end of the heading, such as an add button.
ReactNode—
…and every FlexProps propinherited—

An icon rail

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.

Which row is current

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.

Edit this page on GitHub