Skip to content

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.

Live preview

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.

Playground
Acme
ALAda LovelaceMaintainer

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.

ExpandedIcon, label and count. The default.
Acme
ALAda LovelaceMaintainer
CompactSame content, denser rows. Long navigation, dense tools.
Acme
ALAda LovelaceMaintainer
RailIcons only, labels on hover and focus. Space is scarce.

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.

WideThe column fits, so it stays — no gesture to reach any destination.
Acme
ALAda LovelaceMaintainer
NarrowThe column would take most of the screen, so navigation moves into a Drawer behind a trigger.
Acme

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.

Bounds208–400pxSomewhere to stop. Unbounded dragging ends at a width the handle cannot be found in.
ResetDouble-clickOne gesture back to the default, so experimenting is free.
KeyboardArrow keysThe handle takes focus and moves in steps. Without this it is pointer-only.
PersistencePer userA width that resets on every visit makes the handle decorative.
TouchHiddenA 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.

Default15px label, 32px row
Hover--ds-layer-hover
Currentaria-current="page"
Focus visible2px ring, inset
With countOnly when actionable
NestedOne level in, 14px
Compact28px row, 13px label
TruncatedEllipsis plus a title
Workspace
Group heading12px / 600, muted
Workspace
Group collapsedaria-expanded="false"
Rail itemNamed and tooltipped
Resize handleOptional. 1px, 9px target

Anatomy

Every part, every measurement, and the reason it is that number.

Acme
ALAda LovelaceMaintainer

A brand row, a scrolling navigation region with two collapsible groups, and a pinned account row. Only the middle region scrolls.

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

  6. 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.

  7. 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.

Design tokens used

Values are read live from the running stylesheet, so this table can never drift from the code. Click any value to copy it.

Color

TokenValueUsed 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

TokenValueUsed for
Layout
width208–400px is the useful range
row heightDefault and compact; 44px on coarse pointers
indentNesting

Radius

TokenValueUsed for
Layout
--radius-smRow corners

Typography

TokenValueUsed for
Foreground
--text-uiDestination labels
--text-labelLabels in the compact density
--text-overlineGroup headings

Motion

TokenValueUsed for
Interaction
--ease-standardHover, and group expand

Recommended sizes

Pick a size from this table. Do not invent a new one — a fourth height is how a design system starts dying.

SizeHeightTypeMin widthMax widthTouch targetWhen to use
Expanded32px rows15px label268px——The default. Icon, label and count with no truncation.
Compact28px rows13px label240px——Twelve or more destinations, or a dense internal tool. 13px is as low as a destination label goes.
Rail44px targets—56–80px——Icons only, each with a name and a tooltip. Only for icons the audience already knows.
Range——208px400px—The band worth designing within. Below it labels clip; above it the column competes with the content.
Touch44px rows———44pxCoarse pointers, wherever the sidebar survives as a column — usually a tablet.

Do

<a href="/deployments" aria-current="page"><button onClick={() => go("/deployments")}>
Render destinations as linksA link can be middle-clicked, opened in a new tab, copied and prefetched; a button can do none of those, and people do all of them in navigation. Reserve buttons for controls that act rather than navigate.
Mark the current destination in more than colouraria-current="page" is what tells assistive technology where the user is, and a cue that survives greyscale is what tells everyone else. A tint plus a marker, a border, or a heavier weight all qualify.
ALAda Lovelace
Keep the footer out of the scroll regionLong navigation scrolls. Account and settings are what people reach for when they are lost, so they should not be at the bottom of a list that has scrolled away.
Build
Prefer a shallow structure, and know when it is really a treeGroups plus destinations is what most products need, and it is what people learn by position. Some professional tools genuinely navigate a hierarchy — an org chart, a file system, a category tree — and once nesting is unbounded, Tree View is the component built for it: roving focus, arbitrary depth, virtualisation.

Don't

Do not signal the current destination with colour aloneIt fails for anyone with a colour-vision deficiency, in greyscale, and in bright sunlight — and on its own it gives assistive technology nothing at all. Add aria-current, and a second visual cue.
Do not ship a rail without namesA column of unlabelled icons turns recognition into guesswork and hovering. Every rail button needs an accessible name and a tooltip; if the icons are not already familiar to the audience, stay expanded.
Monday: Dashboard · Deployments · TeamFriday: Team · Dashboard · Deployments
Do not reorder destinations between visitsWithin a week people navigate by position rather than by reading. A list that sorts itself by recency or usage takes that away, and every trip becomes a search.
DeploymentsMonitoring11px — see Readable Type
Do not shrink the labels to fit more rowsDensity is bought with row height and grouping, not with type size. Navigation labels are read from the corner of the eye by someone deciding where to go, which is the worst possible place to save a pixel.

Accessibility

Not a checklist to run at the end. These are the requirements the component was built from.

1.3.1Info and RelationshipsA2.1.1KeyboardA2.4.1Bypass BlocksA4.1.2Name, Role, ValueA2.4.7Focus VisibleAA3.2.3Consistent NavigationAA2.5.8Target Size (Minimum)AA

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

TabEnters the navigation and moves through the destinations. Every row is reachable — nothing in the sidebar is pointer-only.
EnterFollows 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 handleWhere resizing exists, the handle takes focus and resizes in steps. Home returns to the default.
Skip linkThe 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.
AttributeApplied toNotes
<nav aria-label="Main">The navigation regionA named landmark. A page with more than one nav needs a distinct label on each.
aria-current="page"The current destinationThe value is "page". This is what tells assistive technology where the user is; styling alone does not.
aria-expanded + aria-controlsGroup headingsOn the header button, pointing at the container it shows and hides.
aria-labelRail buttonsRequired in the collapsed density — the visible label is gone, so the accessible name is all that is left.
titleTruncated labelsSo the full destination name is available on hover. Not a substitute for an accessible name.
role="separator"The resize handle, if presentWith aria-orientation="vertical", aria-valuenow, aria-valuemin and aria-valuemax, so its position is announced as it moves.

Code

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.

tsx
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.

css
.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

PropTypeDefaultDescription
label*ReactNode—Truncates with an ellipsis. Pass a title so the full name is available on hover.
hrefstring—Renders an anchor instead of a button. Prefer it for destinations — links support middle-click, open-in-new-tab and copy.
iconReactNode—16px. Muted at rest, accent when current.
activebooleanfalseSets aria-current="page" and applies the current-destination treatment.
countnumber—Trailing count. Use it when the number is actionable rather than merely large.
depthnumber0Indent level, 14px each. Prefer shallow structures; for genuinely deep hierarchies use Tree View.
compactbooleanfalse28px rows with a 13px label, for the compact density.

Notes

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.