Skip to content
Folio

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.

Under the provider's `breakpoint` it is a drawer, not a column: fixed against its own edge, over a scrim, and inert while closed. Merely translating it off-screen is not closed — it keeps focus and it is still read aloud, so a shut drawer puts its whole index between the reader and the page they were on. The scrim is a real button, because tapping beside a drawer is how a drawer is closed and that gesture has to exist for a keyboard too.

fixed resolves against the viewport unless an ancestor has a transform, filter or perspective, which makes that ancestor the containing block instead. Do not put one on an element wrapping the provider, or the drawer opens inside it.

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.
PageSidebarInset, the column beside the rail. It is `min-w-0` — the half every hand-written version forgets, and the reason one wide table inside pushes the page past the viewport and takes the rail’s width with it. Under variant="inset" it is also the panel: the rail becomes the ground, and this draws the bordered --paper surface sitting on it.
DrawerWhat the rail becomes under the provider’s breakpoint (768px by default): fixed against its own edge, over a scrim, and inert while closed. Nothing to render — the same <Sidebar> is both — but it is a different component to a reader, and the collapsible setting does not apply there.

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.
  • Wrap the page in SidebarInset rather than a hand-written flex column. It carries min-w-0, which is what stops one wide table inside the page from pushing the whole layout past the viewport, and it is the other half of variant="inset".
  • Pass persist with a key when the rail is an application’s own. A reader who put the rail away did not mean “until the next page”. Only the docked state is kept: restoring an open drawer is a page that loads with its navigation over the top of itself.
  • Give the provider a ground for variant="floating" and variant="inset" — bg-(--stone) on the element holding it. Both draw a --paper panel, and a panel on the same colour as the thing behind it is a border with nothing on either side of it.

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.
  • Do not put a transform, filter or perspective on an element wrapping the provider. The drawer is fixed, and any of those makes that ancestor its containing block — so it opens inside the wrapper instead of against the edge of the window.
  • Do not build the phone drawer yourself around this. It already is one under breakpoint, with the scrim, the inert closed state and the close-on-navigate; a second one outside gives the page two drawers and one of them has no scrim.

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.

a drawer on a phone

Under `breakpoint` the rail is not a narrower rail, it is a different component: an overlay that covers the page from its own edge, closes onto a scrim, and is `inert` while shut. That last part is the one everybody skips. A drawer merely translated off-screen still holds focus and is still read aloud, so a closed one puts its whole index between the reader and the page they were on — which is why closing it here removes it from the document rather than moving it. Two settings make this a demonstration rather than a description. `contained` points the overlay at the frame instead of the window, because `fixed` resolves against the viewport wherever the markup sits and a drawer inside a preview would otherwise open across the page it is previewed on. And the breakpoint is the widest step there is, so the state being documented is the state on screen — this frame is never wider than `xl`. A row closes it. Following a link inside an overlay and leaving the overlay up is a reader landing somewhere they cannot see.

Overview

The page keeps the whole frame: at this width the rail takes no room until it is asked for, and gives it all back the moment a row is taken.

inset floating and the other edge

Three arrangements of the same two pieces, and the difference between them is which one is the panel. `flush` is a column and a page divided by a hairline — one surface, one line. `floating` lifts the RAIL off the ground as its own bordered panel. `inset` is the same gesture the other way up: the rail becomes the ground and `SidebarInset` draws the page as the panel. That is why the setting lives on the provider rather than on either piece — it is one decision that both of them have to make the same way, and a `variant` on the rail alone would let a caller set half of it. Both lifted variants need a ground, so the frame is `--stone`. A `--paper` panel on a `--paper` page is a border with nothing on either side of it. `side="end"` is logical, not "right": it is the right in this document and the left in an Arabic one, and the hairline, the collapse glyph and the row tooltips all move with it rather than being written twice.

flush

The page. Under inset this is the panel and the rail is the ground.

floating

The page. Under inset this is the panel and the rail is the ground.

inset · side="end"

The page. Under inset this is the panel and the rail is the ground.

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
breakpointSidebarBreakpoint | null'md'The step below which the rail becomes a drawer over the page. A rail is a column when there is a column's worth of room and an overlay when there is not; below this the rail is `fixed`, covers the page from its own edge, and closes onto a scrim. `null` pins it as a column at every width, for a layout that is never narrow — an embedded console, a preview frame. Named, not a number: the switch is a media query in the stylesheet, and a class built from a runtime number is a class the compiler never emitted. It also puts the rail on the same four steps as the page around it.
collapsibleSidebarCollapsible'icon'What closing does to the rail. See SidebarProps.collapsible.
containedbooleanfalseThe drawer covers its nearest positioned ancestor rather than the window. For a rail inside a bounded frame — a device preview, an embedded console, an example on a documentation page. `fixed` resolves against the viewport wherever the markup sits, so without this the drawer inside a 400px preview opens across the whole page it is previewed on. Give the frame `relative`.
defaultOpenbooleantrue
onOpenChange(open: boolean) => void
openbooleanControlled open state. Leave off to let the provider own it.
persiststring | nullnullA `localStorage` key under which the DOCKED state is remembered. A reader who put the rail away did not mean "until the next page". Only the docked state is kept: an open drawer is a thing a reader did to this screen, and restoring it on the next visit is a page that opens with its navigation over the top of itself. Read after mount, never during render — a value from storage in the first pass is a hydration mismatch, and the markup the server sent is the one the client has to agree with.
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.
sideSidebarSide'start'Which edge the rail is on. Logical: `end` is the right in LTR, the left in RTL.
variantSidebarVariant'flush'How the rail meets the page. `flush` is a column with a hairline down its inner edge — the rail and the page are one surface divided by a line. `floating` lifts the rail off that surface as its own bordered panel with the page ground showing around it. `inset` is the same gesture the other way up: the rail sits on the page ground and `SidebarInset` draws the CONTENT as the panel. `floating` and `inset` both need a ground to sit on — put `bg-(--stone)` on the element holding the provider, or they are a panel on the same colour as the thing behind it.

SidebarInset

The page beside the rail.

Every layout built on this component was writing the same flex min-w-0 flex-1 flex-col by hand, and min-w-0 is the half everybody forgets: a flex child's floor is its content, so one wide table inside pushes the whole page past the viewport and takes the rail's width with it.

It is also the other half of variant="inset". There the rail is the ground and this is the panel — a bordered --paper surface with the ground showing around it — which is why the variant lives on the provider rather than on either piece: one setting, two components, and no way to set half of it.

A <div>, not a <main>. What goes in here is usually a masthead AND the page under it, and only one of those is the main landmark.

Takes no props of its own.

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.

--bar-h is the floor, not a number of its own. This block and the masthead beside it meet at a corner, and their two bottom rules are read as one line — so a floor typed here is a floor that has to be retyped there, and the two were 56px and 64px for exactly that reason. The token also survives a rail that pins its own density: --bar-h is declared on the root, so its var(--control-h-md) resolves there and inherits as one answer, which is what keeps a compact rail's head level with a comfortable page's bar.

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
iconRemixiconComponentTypeDrawn 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'
export type SidebarSide = 'start' | 'end'
export type SidebarVariant = 'flush' | 'inset' | 'floating'
export type SidebarBreakpoint = 'sm' | 'md' | 'lg' | 'xl'

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.
  • The closed drawer is inert, not merely translated off-screen. Off-screen it still holds focus and is still read aloud, so a shut drawer puts its whole index between the reader and the page they were on.
  • The scrim is a button with a name, because tapping beside a drawer is how a drawer is closed — and a gesture that exists only for a pointer is one a keyboard cannot make.