Sidebar
Persistent navigation beside the content: a product’s top-level destinations, always visible and always in the same place. It is a large-screen layout — where the column will not fit, the same destinations move into a Drawer or another mobile pattern.
Also called Side Nav — in this system that is Sidebar.
Everything below is the real component. Change the controls, tab through it, and turn on Inspector Mode to read any value off the screen.
Related components
A Sidebar hands over to, or sits beside, these. Each is its own component with its own rules — this page covers the persistent column and links out for the rest.
- DrawerTemporary navigation over the content, dismissed after use. What a sidebar most often becomes on a small screen.
- App BarThe top row, and the trigger that opens navigation once the sidebar has given up its column.
- Tree ViewGenuinely hierarchical data — files, org charts, category trees. Built for arbitrary depth, which navigation is not.
- Bottom NavigationThree to five top-level destinations within thumb reach on touch.
- MenuA short list of destinations or commands hung off a trigger rather than pinned to the layout.
- TabsMoving between views of one screen. A sidebar moves between screens.
- Command PaletteKeyboard-first jumping to any destination. Complements a sidebar in a large product; does not replace it.
- Grid & LayoutThe shell the sidebar is one track in — breakpoints, content width and gutters.
A navigation drawer is a Drawer holding navigation, and it is documented there. A navigation rail is this component at its collapsed width — the Rail variant below — rather than a separate one.
Variants
Three densities for three situations, all showing the same destinations. Expanded is the default; compact buys rows at the cost of breathing room; the rail buys width at the cost of labels.
A rail only works when its icons are already familiar. Every button needs an accessible name and a tooltip, and anything ambiguous should stay expanded — see Icons.
Small screens
A persistent column needs a screen wide enough to spare it. Below roughly 1024px it usually gives way: the same destinations move into a temporary Drawer opened from the App Bar, or — for three to five of them — into Bottom Navigation.
The switch is a layout decision, so it belongs to the shell rather than to this component — see Grid & Layout for the breakpoints, Drawer for the panel it becomes, and Bottom Navigation for the touch alternative. Keep the destinations and their order identical across the swap; only the container changes.
Resizing (optional)
Worth adding where names are user-generated and vary in length — repositories, customers, file paths. Plenty of products do without it, and a fixed width is not a defect.
| Bounds | 208–400px | Somewhere to stop. Unbounded dragging ends at a width the handle cannot be found in. |
| Reset | Double-click | One gesture back to the default, so experimenting is free. |
| Keyboard | Arrow keys | The handle takes focus and moves in steps. Without this it is pointer-only. |
| Persistence | Per user | A width that resets on every visit makes the handle decorative. |
| Touch | Hidden | A drag target this thin is not usable with a finger. |
This Bible’s own sidebar is resizable — drag its right edge, or focus the handle and use the arrow keys.
Interaction states
Every state a user can put this component into, rendered side by side. If a state is missing here, it is missing in production too.
Every part, every measurement, and the reason it is that number.
A brand row, a scrolling navigation region with two collapsible groups, and a pinned account row. Only the middle region scrolls.
- Width268px default
A system default, not a universal number: it holds a two-word label, an icon and a count without truncating. 208–400px is the range worth designing within — narrower and labels clip, wider and the column starts competing with the content.
- Row height32px · 28px compact
Denser than a button, because a list of destinations is scanned vertically rather than acted on one at a time. Rows grow to 44px on coarse pointers.
- Label--text-ui · 15px / 21px
The same size every navigation surface uses, and it does not shrink to fit more rows in. Compact steps to 13px; nothing in the sidebar goes below that. See Typography.
- Current destinationTint + 2px marker
The requirement is aria-current plus a cue that survives greyscale. This system spends a background tint and a 2px marker, absolutely positioned so the row’s box never changes; a left border or a heavier weight would meet the same requirement.
- Group heading12px / 600, uppercase
Visually secondary to the destinations under it, but still readable text: 12px is the floor of the scale, and the muted foreground it uses clears 4.5:1.
- Indentation14px per level
Enough to read as nesting without eating the label. Every level costs width, which is the practical limit on how deep navigation can usefully go.
- Pinned footerOutside the scroll
Account and settings stay reachable however long the list gets. They are what people look for when everything else has failed.
Values are read live from the running stylesheet, so this table can never drift from the code. Click any value to copy it.
Color
| Token | Value | Used for |
|---|---|---|
| Planes | ||
| --ds-surface | — | Sidebar background |
| --ds-border-subtle | — | Inline-end edge, section dividers |
| Interaction | ||
| --ds-layer-hover | — | Hover fill |
| --ds-layer-selected | — | Current-destination tint |
| --ds-accent | — | Current marker, and the resize handle on hover |
| --ds-focus-ring | — | Focus outline, 2px inset |
| Foreground | ||
| --ds-fg | — | Current destination, brand |
| --ds-fg-secondary | — | Inactive labels |
| --ds-fg-muted | — | Icons, group headings, footer metadata |
Spacing
| Token | Value | Used for |
|---|---|---|
| Layout | ||
| width | 208–400px is the useful range | |
| row height | Default and compact; 44px on coarse pointers | |
| indent | Nesting | |
Radius
| Token | Value | Used for |
|---|---|---|
| Layout | ||
| --radius-sm | Row corners | |
Typography
| Token | Value | Used for |
|---|---|---|
| Foreground | ||
| --text-ui | Destination labels | |
| --text-label | Labels in the compact density | |
| --text-overline | Group headings | |
Motion
| Token | Value | Used for |
|---|---|---|
| Interaction | ||
| --ease-standard | Hover, and group expand | |
Pick a size from this table. Do not invent a new one — a fourth height is how a design system starts dying.
| Size | Height | Type | Min width | Max width | Touch target | When to use |
|---|---|---|---|---|---|---|
| Expanded | 32px rows | 15px label | 268px | — | — | The default. Icon, label and count with no truncation. |
| Compact | 28px rows | 13px label | 240px | — | — | Twelve or more destinations, or a dense internal tool. 13px is as low as a destination label goes. |
| Rail | 44px targets | — | 56–80px | — | — | Icons only, each with a name and a tooltip. Only for icons the audience already knows. |
| Range | — | — | 208px | 400px | — | The band worth designing within. Below it labels clip; above it the column competes with the content. |
| Touch | 44px rows | — | — | — | 44px | Coarse pointers, wherever the sidebar survives as a column — usually a tablet. |
Not a checklist to run at the end. These are the requirements the component was built from.
Contrast
- Inactive labels use --ds-fg-secondary. Muted is reserved for icons, group headings and metadata, and even there it must clear 4.5:1 — a group heading is readable text, not decoration.
- The current-destination marker must reach 3:1 against the sidebar background: it is a non-text indicator carrying meaning.
- The edge between the sidebar and the content needs enough contrast to read as a boundary in both themes, or the two planes merge.
Keyboard
| Tab | Enters the navigation and moves through the destinations. Every row is reachable — nothing in the sidebar is pointer-only. |
| Enter | Follows the focused destination. Space also activates a group header, which is a button rather than a link. |
| ↑ / ↓ | Optional: with roving focus, moves between destinations while the nav holds one tab stop. Useful once the list is long; not required. |
| ← / → | Collapses and expands a group when focus is on its header, matching the disclosure pattern. |
| Arrows on the handle | Where resizing exists, the handle takes focus and resizes in steps. Home returns to the default. |
| Skip link | The first tab stop on the page jumps past the navigation. Without it, every keyboard user crosses the whole list to reach the content. |
Screen readers
- Mark groups up as lists so their size is announced: "Build, list of 3 items".
- The skip link must clear the sidebar as well as the app bar. Twenty destinations is a real barrier between a keyboard user and the page.
- In the rail density nothing is visible but glyphs, so the accessible name carries the whole meaning. Check it reads as a destination — "Deployments", not "rocket".
Focus & touch
- The focus ring is never removed, only restyled — 2px, inset, so it is not clipped by the sidebar’s own edge. Scroll the current destination into view on navigation, or someone deep in a long list loses their place on every trip.
- Rows grow to 44px on coarse pointers, which clears the 24×24 that WCAG 2.5.8 asks for at AA with room to spare, and the resize handle is hidden entirely — a drag target that thin cannot be used with a finger. Below the layout breakpoint the column usually gives way to a Drawer, where the same rules apply.
| Attribute | Applied to | Notes |
|---|---|---|
| <nav aria-label="Main"> | The navigation region | A named landmark. A page with more than one nav needs a distinct label on each. |
| aria-current="page" | The current destination | The value is "page". This is what tells assistive technology where the user is; styling alone does not. |
| aria-expanded + aria-controls | Group headings | On the header button, pointing at the container it shows and hides. |
| aria-label | Rail buttons | Required in the collapsed density — the visible label is gone, so the accessible name is all that is left. |
| title | Truncated labels | So the full destination name is available on hover. Not a substitute for an accessible name. |
| role="separator" | The resize handle, if present | With aria-orientation="vertical", aria-valuenow, aria-valuemin and aria-valuemax, so its position is announced as it moves. |
Example usage
Real links, a named landmark, groups as disclosures, and a footer outside the scroll region. Resizing is bolted on, not built in — leave it out and nothing else changes.
1<aside2 style={{ inlineSize: width }}3 className="flex h-dvh flex-col border-e border-line-subtle bg-surface"4>5 <BrandRow />67 {/* Only this region scrolls. */}8 <nav aria-label="Main" className="min-h-0 flex-1 overflow-y-auto p-2">9 {groups.map((g) => (10 <section key={g.id}>11 <button12 aria-expanded={!collapsed.includes(g.id)}13 aria-controls={`group-${g.id}`}14 onClick={() => toggleGroup(g.id)}15 className="text-overline uppercase text-fg-muted"16 >17 <ChevronRight aria-hidden />18 {g.title}19 </button>2021 {/* A list, so the number of destinations is announced. */}22 <ul id={`group-${g.id}`} hidden={collapsed.includes(g.id)}>23 {g.items.map((item) => (24 <li key={item.id}>25 <NavItem26 href={item.href} {/* a link, not a button */}27 icon={item.icon}28 label={item.label}29 count={item.count}30 active={item.id === currentId} {/* sets aria-current */}31 />32 </li>33 ))}34 </ul>35 </section>36 ))}37 </nav>3839 <AccountRow /> {/* outside the scroll container, so it never scrolls away */}40</aside>4142// Keep the current destination visible when the list is long.43useEffect(() => {44 navRef.current45 ?.querySelector('[aria-current="page"]')46 ?.scrollIntoView({ block: 'nearest' })47}, [currentId])CSS
The row, the current state, and the two responsive rules. Every value is a token.
.ds-sidebar {
display: flex;
flex-direction: column;
block-size: 100dvh;
inline-size: var(--sidebar-width, 268px);
border-inline-end: 1px solid var(--ds-border-subtle);
background: var(--ds-surface);
}
.ds-nav-item {
position: relative;
display: flex;
align-items: center;
gap: 10px;
block-size: 32px;
padding-inline: 10px;
border-radius: var(--radius-sm);
/* Navigation text, same as every other nav surface — see Typography. */
font: var(--text-ui);
color: var(--ds-fg-secondary);
transition: background-color 100ms var(--ease-standard);
}
.ds-nav-item:hover { background: var(--ds-layer-hover); color: var(--ds-fg); }
.ds-nav-item:focus-visible {
outline: 2px solid var(--ds-focus-ring);
outline-offset: -2px; /* inset, so the sidebar's edge cannot clip it */
}
/* Current destination: a tint plus a marker, so it survives greyscale. The
marker is absolutely positioned, which keeps the row's box unchanged and
stops labels shifting when the current item moves. */
.ds-nav-item[aria-current='page'] {
background: var(--ds-layer-selected);
color: var(--ds-fg);
font-weight: 500;
}
.ds-nav-item[aria-current='page']::before {
content: '';
position: absolute;
inset-inline-start: 0;
inset-block-start: 50%;
translate: 0 -50%;
inline-size: 2px;
block-size: 15px;
border-start-end-radius: 999px;
border-end-end-radius: 999px;
background: var(--ds-accent);
}
/* Touch: bigger rows, and no drag handle. */
@media (pointer: coarse) {
.ds-nav-item { block-size: 44px; }
.ds-resize-handle { display: none; }
}
/* Narrow: the column gives way, and the same destinations open in a Drawer. */
@media (max-width: 1023px) {
.ds-sidebar { display: none; }
}Component API
NavItem
| Prop | Type | Default | Description |
|---|---|---|---|
| label* | ReactNode | — | Truncates with an ellipsis. Pass a title so the full name is available on hover. |
| href | string | — | Renders an anchor instead of a button. Prefer it for destinations — links support middle-click, open-in-new-tab and copy. |
| icon | ReactNode | — | 16px. Muted at rest, accent when current. |
| active | boolean | false | Sets aria-current="page" and applies the current-destination treatment. |
| count | number | — | Trailing count. Use it when the number is actionable rather than merely large. |
| depth | number | 0 | Indent level, 14px each. Prefer shallow structures; for genuinely deep hierarchies use Tree View. |
| compact | boolean | false | 28px rows with a 13px label, for the compact density. |
Professional tips
- Order destinations by how often they are used, and then leave the order alone — position is what people learn first.
- Show a count when the number is actionable. "Deployments 3" where 3 means "needs review" is useful; a running total is decoration.
- A large product may benefit from search or a command palette alongside the sidebar. That is a convenience for power users, not evidence that the navigation is broken.
- Persist width and collapsed groups per user. State that resets on every visit makes the controls that set it pointless.
Performance
- The sidebar renders on every route. Keep route-specific state out of it and memoise it, or each navigation re-renders the whole list.
- Drive a resize with a pointermove listener writing a CSS variable rather than React state — a state update per mousemove drops frames on a long list.
- Prefetch on hover. Sidebar links are the most predictable navigation in a product, which makes them the cheapest to guess right.
- For very long navigation, content-visibility: auto on collapsed groups skips laying out what is not shown.
Common mistakes
- Buttons instead of links, which breaks middle-click, open-in-new-tab and copy-link.
- A border for the current state instead of a positioned marker, so every label shifts by a pixel when the current item changes.
- No aria-current, leaving the current destination visible to sighted users only.
- Forgetting to scroll the current destination into view, so a long list loses the user’s place on every navigation.
- A rail whose icons are not familiar, which converts a navigation column into a row of quizzes.
Real-world recommendations
- Watch someone use the product for a week. If they still read the labels rather than pointing, either the order is unstable or the names are not distinct.
- Usage data settles ordering arguments faster than opinion does, and it is the honest way to decide what belongs at the top.
- Resizing is used by a minority, but that minority skews heavily towards the people who live in the product all day.
- Check the sidebar at 200% browser zoom. It is the surface most likely to squeeze the content column to nothing.