Skip to content
misoto22 design

Navigation

Sidebar

A navigation rail down the side of an application.

Usage

When to reach for it

A whole application’s navigation, in a column that stays. A list of links inside a page is NavItem on its own; a strip of panels is Tabs.
TSX
import { Sidebar } from '@misoto22/design'

Notes

A navigation rail down the side of an application.

A <nav>, not an <aside>. The element decides the landmark, and a rail of links announced as "complementary" is not the one a screen reader user jumps to when they go looking for the navigation.

Composed rather than configured: a rail is a header, a scrolling middle and a footer, and every product wants different things in all three. What this owns is the part that is the same everywhere — the width, the edge, the scroll behaviour, and what happens when it closes.

Closing has three shapes, and collapsible on the provider picks one. icon keeps the rail and drops the labels, which is right when the rows are a fixed set a reader learns the shape of. offcanvas takes the whole rail away, which is right when the rows are a long index nobody memorises. none is a rail that does not close.

The trigger belongs INSIDE the rail — SidebarHeader places it — rather than out in an application's masthead. A control that hides a thing should live on the thing: in the masthead it is one more anonymous icon in a row of them, and nothing connects it to the column it operates.

Anatomy

Sidebar anatomy
ElementDescription
ProviderrequiredSidebarProvider. It holds whether the rail is open and binds the shortcut that changes it, and it sits ABOVE both the rail and the content beside it — the page has to reserve the rail’s width, and a state that lived inside the rail could only ever be read downwards. It also supplies the tooltip provider a collapsed rail needs, so the icon state works without the app being told to wrap itself in one.
RailrequiredSidebar. A <nav>, not an <aside>: the element decides the landmark, and a rail of links announced as “complementary” is not the one a reader jumps to when they go looking for the navigation. Its width animates between --sidebar-w and --sidebar-w-icon while the column inside stays full width, so the rows do not reflow under the wipe.
HeaderSidebarHeader. The brand, the workspace, the switcher — and where SidebarTrigger belongs. A control that hides a thing lives on the thing; out in an application’s masthead it is one more anonymous icon with nothing connecting it to the column it operates.
ContentSidebarContent. The scrolling middle, and the only part that scrolls.
GroupSidebarGroup: a heading, an optional count, an optional action, and rows behind a hairline. The heading is the same size as its rows and outranks them by weight and one step of ink — smaller than what it contains, it reads as a footnote over a list rather than as a title over its own contents.
RowSidebarItem, which is NavItem plus the two things a rail adds: a trailing slot, and an answer for the state with no room for words. Collapsed, the label leaves the layout and becomes the row’s tooltip.
BranchSidebarBranch: a row that opens onto more rows, behind the same hairline a group draws and one indent further in. It is the line between a place and a heading — a workspace that contains projects is a place containing places, and it carries an icon and a state the way its children do, which a Group has neither of. Two levels is what the indent has room for; a third in a 16rem column is a horizontal scrollbar with an outline in it.
FooterSidebarFooter. The utilities a rail ends on, kept out of the index above it.

Best practices

Do

  • Give every row an icon if the rail collapses to icons. The icon is the whole of what a collapsed row shows, and SidebarItem keeps the label drawn on a row without one rather than leaving a blank line — which is a rail that is half collapsed.
  • Choose collapsible by what the rows ARE. icon suits a fixed set a reader learns the shape of; offcanvas suits a long index nobody memorises, where a column of unrecognisable glyphs is worse than no column.
  • Reach for SidebarBranch when the thing is a PLACE that contains places, and for SidebarGroup when it is a heading over a set. A group has no icon and no state because it is not somewhere you can be; a branch has both because it is.
  • Put SidebarTrigger in the header. It is where the component expects it and where a reader looks for it, and it is the difference between a control that belongs to the rail and one that has wandered into the masthead.

Don’t

  • Do not reach for it for navigation inside a page. This is an application landmark that owns a whole edge of the window; a set of links in a column is NavItem, and putting those in a rail gives a page two navigation landmarks competing for the same reader.
  • Do not pass shortcut and then bind Cmd+B yourself. Two handlers on one chord toggle twice and land back where they started, which reads as a rail that ignores its own shortcut. Pass shortcut={null} where the app owns it.
  • Do not nest a branch inside a branch. The indent is sized for two levels at this width, and a third takes the words with it — what a reader gets is an outline with a horizontal scrollbar under it.
  • Do not control open without onOpenChange. The trigger and the shortcut then both do nothing, and the state that looks broken is the one the caller froze.

Examples

default

A rail is composed, not configured: a header, a scrolling middle and a footer, and every product wants different things in all three. What the component owns is the part that is the same everywhere — the width, the edge, the scrolling, and what happens when it closes. Everything else here is composition. The badge beside Agents is a Badge, the count on Inbox is a string, the action at the foot is a Button; none of them is a prop this component had to invent. Teamspaces nests, because a workspace that contains projects is a place containing places rather than a heading over a list — which is the line between SidebarBranch and SidebarGroup. Press the button in the header, or Cmd+B, and watch the labels go: every row stays reachable, because each keeps its label as a tooltip rather than becoming an unnamed glyph.

The page, beside the rail.

offcanvas

`collapsible` is a choice about the ROWS, not about the animation. Icons suit a fixed set a reader learns the shape of — a workspace, a mail client, five places they visit every day. A long index nobody memorises is better gone entirely: a column of unrecognisable glyphs takes width and answers nothing, which is what offcanvas is for. The trigger stays in the header either way, so it goes with the rail rather than sitting out in a masthead with nothing connecting it to the column it operates.

The page, with the rail away.

Parts

Composed at the call site rather than configured through props, so a layout this component did not anticipate is still expressible.

useSidebar

The rail's own state, for anything that has to answer to it.

A page beside the rail needs it to reserve the right width; a control inside needs to know whether its label is being drawn.

This one THROWS outside a provider, and the parts above do not, and the difference is who made the mistake. A part rendered on its own is somebody writing <Sidebar> to see what it looks like; a call to this hook is code asking for state that nothing is keeping, and returning a plausible default there is a layout that is wrong in one state and right in the other with nothing to say which.

Takes no props of its own.

SidebarProvider

Holds whether the rail is open, and binds the shortcut that changes it.

Separate from Sidebar itself because the answer is needed on BOTH sides of the layout: the rail draws itself from it, and the content beside the rail reserves width from it. A state that lived inside the rail could only ever be read downwards.

SidebarProvider props
PropTypeDefaultDescription
childrenrequiredReactNode
collapsibleSidebarCollapsible'icon'What closing does to the rail. See SidebarProps.collapsible.
defaultOpenbooleantrue
onOpenChange(open: boolean) => void
openbooleanControlled open state. Leave off to let the provider own it.
shortcutstring | null'b'The keyboard shortcut that toggles the rail, as a single letter. `b`, with the platform's own modifier, because that is what every editor and every application shell already uses for the same thing. Pass `null` to bind nothing — an app that already owns that chord should not have it taken twice, and a rail that cannot be closed does not need a shortcut for closing it.

SidebarHeader

The block at the top of the rail: a brand, a workspace, a switcher.

It is also where SidebarTrigger belongs, and the layout assumes one: anything passed as children takes the space and the trigger sits at the inline end of the row.

Takes no props of its own.

SidebarContent

The scrolling middle. Everything that is a list of places goes here.

Takes no props of its own.

SidebarFooter

The block at the bottom: the utilities, the account, the thing a rail ends on.

A separate landmark from the content above it because it is a separate kind of thing — a reader scanning the index does not want Trash and Help in it.

Takes no props of its own.

SidebarSeparator

A rule between blocks of rows, inset to the rail's own padding.

Takes no props of its own.

SidebarTrigger

The control that opens and closes the rail.

Its accessible name changes with what it will DO, and aria-expanded reports what is true now — a button permanently called "Toggle sidebar" tells a screen reader user nothing about which way it will go.

SidebarTrigger props
PropTypeDefaultDescription
labels{ open: string; close: string }{ open: 'Open the sidebar', close: 'Close the sidebar' }What the button announces. Both states, because it says both things.

Also accepts everything in ComponentProps<'button'>. Those are forwarded to the underlying element and are not listed row by row.

SidebarGroup

A labelled block of rows, optionally foldable.

The heading is the same SIZE as the rows beneath it and outranks them by weight and by ink. Both halves of that are corrections. Smaller, it inverted the hierarchy it exists to express — a group read as a footnote over a list rather than as a title over its own contents. In the MONO face, which is where it went next, ten of them stacked in a column read as a terminal listing: mono is this system's voice for code, metadata and figures, and a navigation heading is none of those. Rank belongs to weight and to a step up the ink ladder, which are the two signals that can outrank a row without changing what kind of thing it is.

An open group draws a hairline down its rows. Fifty rows under seven headings have nothing in them saying which heading any given row belongs to — only the distance to the last one, which is gone the moment the list is scrolled.

The whole block hides when the rail is collapsed to icons: a heading with no room for its own word is two or three letters and a number, and the rows are still there underneath as icons.

SidebarGroup props
PropTypeDefaultDescription
childrenrequiredReactNode
labelrequiredstringThe heading over the rows.
actionReactNodeA control on the heading row — a menu, an "add" button. Sits between the label and the count, and is NOT rendered inside the heading's own button: a control nested in a control is one the keyboard reaches by pressing the thing it is inside.
badgeReactNodeA mark that belongs to the GROUP — "Beta", "3 new". Beside the label, not out at the end with the count: it qualifies the words, and a qualifier that has drifted to the other side of the row reads as a second, unrelated fact.
classNamestring
collapsiblebooleantrueWhether the group folds at all. A group of two rows usually should not.
countnumberHow many rows are inside, printed on the far side of the heading.
defaultOpenbooleantrue

SidebarBranch

A row that opens onto more rows.

The thing a rail is for and the thing a flat list of groups cannot do: a workspace with projects in it, a folder with documents in it, a service with its environments. SidebarGroup is a HEADING over a set — it is not itself a place, and it has no icon and no state. This is a place that contains places, so it is a row like any other and it carries the same icon, trailing slot and hover as its children.

The children sit behind the same hairline a group draws, one indent further in, so nesting reads as depth rather than as two unrelated lists. Two levels is what the indent has room for at this width; a third is a tree, and a tree in a 16rem column is a horizontal scrollbar with an outline in it.

Collapsed to icons the row becomes its icon and the children are not drawn — there is nowhere for an indent to go, and a nested icon under an unnested one is two glyphs with no visible relationship.

SidebarBranch props
PropTypeDefaultDescription
childrenrequiredReactNode
labelrequiredstringThe row's own words, and the name of the group it opens.
classNamestring
defaultOpenbooleanfalse
iconLucideIconDrawn at the start of the row, and the whole of the row when collapsed.
onOpenChange(open: boolean) => void
openboolean
trailingReactNodeA count or a state at the end of the row.

SidebarItem

One row.

NavItem with the two things a RAIL adds: a trailing slot, and an answer for the state where there is no room for words. Collapsed to icons the label is removed from the layout rather than hidden with CSS — a sr-only label still occupies the flex row's gap — and moves into a tooltip, because an icon on its own is a guess for everyone and unusable for a screen reader.

A row with no icon keeps its label collapsed, since hiding it would leave a blank row: the icon is what makes the collapsed state legible, and a rail that collapses needs one on every row.

SidebarItem props
PropTypeDefaultDescription
trailingReactNodeA count or a state on the far side of the row. Hidden with the label.

Also accepts everything in NavItemProps. Those are forwarded to the underlying element and are not listed row by row.

Types

TSX
export type SidebarCollapsible = 'icon' | 'offcanvas' | 'none'

Keyboard

Sidebar keyboard interactions
KeyDoes
⌘BCtrl BOpens and closes the rail.
EnterSpaceOn a group heading, folds or unfolds it.
TabMoves through the rows in the order they are drawn.

Accessibility

  • label is required, and names the landmark. A page with two navigations in it announces two things called “navigation” unless each says which it is.
  • The trigger’s name says what it will DO and aria-expanded reports what is true now, so it is never the permanently ambiguous “Toggle sidebar”.
  • A collapsed row keeps its label as its accessible name, through a tooltip — an icon alone is a guess for a sighted reader and nothing at all for a screen reader.
  • A collapsed group keeps its heading as the group’s name even though the words are not drawn.
  • The current row carries aria-current="page", not only a darker ground.