Bridge view
Sidebar Nav
A persistent navigation region pinned to a viewport edge — typically the inline-start (visual left in LTR) on desktop, collapsing to a drawer on mobile. Holds links to independent pages or top-level sections of an application. Distinct from Tabs (in-page content switching), MenuButton (transient action surface), and Drawer-as-navigation (the modal on-demand variant of this pattern). SidebarNav is the canonical pattern for multi-section apps with persistent global navigation.
When to use
Use
For persistent global navigation in multi-section apps — dashboards, admin panels, content management systems, documentation sites with multi-page topics. The user sees all top-level destinations at all times (or reveals them via the collapsible toggle), navigates between them via clicks/Enter, and the URL changes on activation.
Avoid
For in-page content switching — that is `Tabs`. For transient action menus — that is `MenuButton`. For contextual disclosure of secondary content — that is `Disclosure` or `Accordion`. For mobile-only navigation — combine with `Drawer[variant=navigation]` for the collapsible-on-narrow-viewports pattern.
Versus related
- tabs
`SidebarNav` navigates between independent pages (URLs change, content reloads); `Tabs` switches between content panels in the same page (URL stays the same in canonical implementations). The semantic distinction drives the role and the DOM: sidebar uses `<nav>` with anchor links; tabs use `tablist` with buttons.
- drawer
`Drawer[variant=navigation]` is the temporary modal-on-mobile counterpart of SidebarNav. They compose: persistent SidebarNav on desktop, Drawer for the same nav on mobile (collapses below `breakpoint.md`). The drawer-variant is invoked by a trigger (hamburger MenuButton).
- menu-button
`MenuButton` is a transient action surface invoked on demand; `SidebarNav` is a persistent navigation region always visible (or collapsibly visible). The role differentiates: `role="menu"` for MenuButton, `<nav>` for SidebarNav. Sidebar styled as menu-button is a common antipattern.
- accordion
`Accordion` discloses content within a single page; `SidebarNav` navigates between independent pages. Sidebars may contain accordion-style collapsible groups for nested navigation, but the leaf items are still anchor links — the accordion is structural within the sidebar, not the sidebar itself.
Figma↔Code mismatches
Where designer and developer worlds typically misalign on this component.
- 01 Figma
SidebarNav drawn looking like vertical Tabs
CodeA nav landmark with anchor links navigating to URL routes
ConsequenceDesigners may use SidebarNav and Tabs interchangeably for "vertical list of links". Implementations following the Figma file may ship `role="tablist"` for what is semantically navigation; SR users hear "tab" and expect in-page panel switching, but activating navigates to a different URL. The mental model breaks.
CorrectDistinguish at the canonical level: SidebarNav navigates between independent pages (URLs change); Tabs switch between content panels in the same page (URL stays). The semantic distinction drives the role (`<nav>` vs `tablist`) and the DOM (anchors vs buttons-with-aria- controls).
- 02 Figma
Collapsed variant drawn with icons but no tooltip on hover
CodeEach item in collapsed variant has a Tooltip showing the label on hover or focus
ConsequenceDesigners compose collapsed-variant mocks with icon-only items. Implementations following the design ship without hover tooltips; pointer users hovering an icon see no label, must guess from the icon glyph. Keyboard users see no label either (focus does not reveal anything beyond the visible icon).
CorrectCollapsed-variant items always have a Tooltip on hover/ focus revealing the full label. The label remains in the DOM (visually-hidden) for SR users; the Tooltip is a sighted-user affordance that surfaces it visually. Document the pairing canonically.
- 03 Figma
Active page indicated by font-weight change alone
CodeActive page indicated by `aria-current="page"` PLUS background fill PLUS leading accent strip
ConsequenceDesigners use a single visual change (bold text) to mark active. Implementations following the design fail WCAG 1.4.1 (Use of Color and 1.3.3 Sensory Characteristics) — bold-only differentiation is not enough for SR users (without `aria-current`) and is unreliable for users with dyslexia or reduced contrast vision.
CorrectTriple-layer active-state differentiation: visual (background fill + leading accent stripe + text colour shift), accessible state (`aria-current="page"`), and typography (font-weight increase). The `aria-current="page"` is the source of truth; visuals reinforce.
- 04 Figma
Sidebar drawn at fixed-width with no overflow handling
CodeSidebar with internal scroll for overflow; main content scrolls independently
ConsequenceDesigners fix the sidebar at one screen-height (typical desktop mock at 1080p). Implementations following the design break on shorter viewports — items below the fold are clipped, with no scroll affordance. Mobile especially affected.
CorrectSidebar's internal item region scrolls independently from main content. Footer (when present) pins to the bottom; items between header (if present) and footer scroll. Document the canonical scroll-behaviour: never truncate items silently.
Variants, properties, states
Variants
Structurally different versions of the component.
expandedcollapsed Properties
The same component, parameterised.
| Property | Type |
|---|---|
collapsible | boolean |
hasFooter | boolean |
density | comfortable | compact |
States
Browser/user-driven (interactive) vs. app-driven (data).
| Kind | States |
|---|---|
interactive | hoverfocus-visibleactive |
data | idlecurrent-page |
Figma ↔ Code property map
| Figma | Type | Code | Notes |
|---|---|---|---|
Variant | Variant | variant | Maps expanded / collapsed. |
Collapsible | Boolean | collapsible | Toggles the user-facing collapse affordance (typically a chevron-button at the sidebar's inline-end edge). |
Density | Variant | density | comfortable / compact. |
Has Footer | Boolean | footer | — |
Has Header | Boolean | header | Optional top region for logo / app name. Separate from anatomy because it is not always present and is not a single canonical slot. |
Item Count | Variant | items.length | Figma exposes 4/8/12/16+ item counts for preview; code accepts arbitrary item arrays. |
Has Groups | Boolean | groups | Toggles whether items render as flat list or are categorised into labelled groups. |
Active Item | Variant | activeItemId | Figma exposes "first / second / third item active" as Variant for preview; in code this is route-derived (current URL) or controlled prop. |
State transitions
| From | To | Trigger |
|---|---|---|
idle | current-page | Page navigation lands the user on the route owned by this nav item. The item's `aria-current="page"` is set; visual treatment changes to indicate current-page state. Other items in the sidebar lose their current-page state (only one can be active at a time). |
current-page | idle | User navigates to a different page. The previously- current item loses `aria-current="page"`; the new current item gains it. |
Figma anatomy
| Slot | Figma type | Hint |
|---|---|---|
root | frame | Full-block-size auto-layout vertical frame; pinned to inline-start of viewport |
group | frame | Vertical frame with optional label heading and items inside |
group-label | text | Heading text style; uppercase or small-caps for category dividers |
item | instance | Nav item component instance with active and inactive variants |
item-icon | instance | Icon component instance per item; required in collapsed variant |
item-label | text | Item text style; visibility property bound to expanded state |
badge | instance | Badge component instance; visibility bound to "has notification" state |
footer | frame | Auto-layout vertical frame pinned to bottom of sidebar |
Code anatomy
| Slot | Code slot | Semantic |
|---|---|---|
root | root | nav-landmark |
group | group | group-region |
group-label | group-label | heading-or-strong |
item | item | link |
item-icon | item-icon | presentational-or-img |
item-label | item-label | text |
badge | badge | presentational-or-status |
footer | footer | contentinfo-region |
Cross-framework expression
| Framework | Structure mechanism | Variant mechanism |
|---|---|---|
| Web Components | A `<ui-sidebar>` host wrapping a `<nav>` with internal `<ul>` of items; named slots for groups, footer, and the optional collapsible toggle | attributes (`variant="collapsed"`, `collapsible`, `density="comfortable"`, `has-footer`); `data-state="expanded|collapsed"` for CSS |
| React | Custom `<Sidebar>` component composing `<nav>`, item lists, and footer; React Aria does not ship a SidebarNav primitive but provides `useNavigationItem` for nav-item semantics; routing libraries (Next.js, React Router) integrate via active-route-aware item components | props with class-variance-authority for variant / density; `collapsible` boolean drives the toggle; controlled expansion state for animations |
| Angular (signals) | Angular Material `MatSidenav` plus `MatSidenavContainer` provides the layout pattern; or a custom directive on `<nav>` plus signal-based state | input<'expanded' | 'collapsed'>(); `[collapsible]`, `[density]` host bindings |
| Vue | Custom `<SidebarNav>` SFC composing `<nav>` with named slots for groups and footer; Vue Router's `<router-link>` provides active-route handling | defineProps with literal-union types; `:variant`, `:collapsible` props |
Events
variantChangeitemActivate
Performance thresholds
maxItemsBeforeScrollvisible-item-count≥12itemsAbove ~12 items visible without scrolling, the sidebar becomes scannable only by reading top-to-bottom; users cannot recall the structure. For deep navigation hierarchies, group items into collapsible sections (using nested Disclosure or Accordion patterns inside the sidebar) or split into multiple navs. Above 12 items, internal scroll is required; the canonical reference recommends scrollable item region with pinned header/footer.
navItemPaintBudgetper-item-paint-cost≥1msEach sidebar item's paint cost should stay under 1ms to keep the total sidebar paint within the 16ms 60fps frame budget at typical item counts (12–40 items). Items with rich content (avatars, badges, multi-line labels) may exceed this; profile and simplify if sidebar paint dominates initial render.
Internationalisation
RTL · mirroring
Sidebar pinning moves from inline-start (visual left in LTR) to inline-start (visual right in RTL). Item icon position reverses logically. Collapse-toggle chevron points away from the sidebar (right in LTR, left in RTL); the chevron glyph itself rotates to follow. Notification badges remain at inline-end of items (visual right in LTR, visual left in RTL).
Text expansion
Item labels grow significantly in DE / RU / FI — German "Einstellungen" is twice as wide as English "Settings". Sidebar inline-size grows to fit the longest label or truncates with ellipsis (full text in `aria-label` or via Tooltip). Collapsed variant is unaffected by text expansion (icons are direction-neutral and same width). Group labels follow item-label expansion patterns.
Accessibility
| Slot | Accessibility hint | |
|---|---|---|
root | Apply `<nav>` element with `aria-label="Primary"` (or equivalent — for apps with multiple navigations, label each distinctly). The nav landmark allows SR users to jump here via landmark navigation. Avoid stacking multiple `aria-label="Navigation"` regions on the same page; each should have a distinguishing label. | |
group | Group is a structural grouping. The group label inside is a heading element (commonly h3 or h4 depending on document outline) when the label conveys hierarchical structure; or `aria-labelledby` referencing a non-heading label. | |
group-label | Heading element when the label carries hierarchical meaning. Visually-hidden in collapsed variant via clip-path or sr-only class — never `display: none` (which removes from SR tree). | |
item | Real `<a href="...">` with the target route. The currently-active item carries `aria-current="page"` — SR announces "current page" before the link text. Avoid `aria-current="true"` (less specific) or bespoke `data-active` (no SR signal). Disabled links are rendered as plain text or as `<a>` with `aria-disabled="true"` plus removed `href`. | |
item-icon | Decorative when paired with a visible label (`aria-hidden="true"`). For collapsed variant where the label is visually-hidden, the icon is still decorative because the visually-hidden label remains in the DOM for SR users — the icon never needs an aria-label for collapsed-variant nav items. | |
item-label | Plain text. The nav item's accessible name. In collapsed variant, hide visually with sr-only / clip- path technique — NEVER `display: none` (which removes from accessible tree, leaving an icon-only link with no accessible name). | |
badge | For numeric badges, append the count to the link's accessible name via visually-hidden text or `aria-label` composition ("Inbox, 3 unread"). Decorative-only badges (a pulse for "new") use `aria-hidden="true"` with the link's accessible name carrying the meaning explicitly ("Settings (new)"). | |
footer | Footer content is part of the sidebar's nav landmark but acts as a sub-region. For user-account flyouts, the footer typically hosts a MenuButton (avatar + dropdown). For static info, plain content is sufficient. |
Accessibility acceptance
Keyboard walk
| Keys | Expected |
|---|---|
F6 (when supported by browser/OS) | Cycles focus to landmark regions including the nav. Allows keyboard users to reach the sidebar without tabbing through earlier content. |
Tab | Focus enters the first item in the sidebar. Subsequent Tab moves to the next focusable item; continues through the entire sidebar (groups, items, footer) in DOM order, then exits to the next document focusable. |
Enter (focus on item) | Activates the link — navigates to the `href`. Spacebar does not activate (anchors are Enter-only by native convention). |
ArrowDown / ArrowUp (focus on item) | Optional per APG — Polaris and Spectrum bind ArrowKeys for sidebar navigation; canonical reference marks this as enhancement, not required. Tab is the canonical key for moving between items. |
Escape (focus inside sidebar, mobile drawer mode) | Closes the drawer-variant sidebar; focus restores to the trigger that opened it. For non-drawer (persistent desktop) sidebar, Escape is a no-op. |
Screen-reader announcements
| Trigger | Expected |
|---|---|
| Focus enters the sidebar | SR announces "Primary navigation" (or whatever the nav's `aria-label` is) followed by the focused item. For F6 landmark navigation, the announcement precedes any item interaction. |
| Focus enters the current-page item | SR announces "<item label>, current page, link". The current-page portion comes from `aria-current="page"`. Other items announce as "<label>, link" without the current-page prefix. |
| Item activated, navigation occurs | New page loads; focus typically returns to the sidebar item that was clicked (browsers vary). SR announces the new page's title and main heading; navigation is implicit. |
| Item with notification badge | SR announces "<item label>, <count> unread, link" or "<item label> (new), link" depending on whether the badge is numeric or pulse-only. Driven by visually- hidden text composed into the link's accessible name, not by `aria-label` (which would lose the link text). |
axe-core rules to assert
aria-allowed-rolearia-required-attraria-currentcolor-contrastlink-namelandmark-uniqueregion
Common mistakes
#sidebar-no-aria-current
Active page not marked with `aria-current`
The visual current-page state is shown (highlight, icon-fill change, accent stripe) but `aria-current` is missing from the active link. SR users hear no indication of which page they are currently on; users relying on screen reader for orientation are lost.
The active item carries `aria-current="page"` (most specific value for navigation links). SR announces "current page" before the link text. Update on every navigation.
#sidebar-collapsed-display-none-label
Collapsed variant uses `display: none` to hide labels
Implementation hides labels in collapsed variant via `display: none`. The labels are removed from the accessibility tree; SR users hear icon-only links with no accessible name (the icon's aria-hidden is correct but now there is nothing else for the link's name).
Hide labels via the `sr-only` / clip-path technique that keeps content in the accessibility tree but visually removes it. The link's accessible name remains the label text; SR users hear "Settings, link" while pointer users see only the icon.
#sidebar-as-menubutton
Sidebar implemented as a Menu (role="menu")
Implementation uses `role="menu"` and `role="menuitem"` on the sidebar items. SR users hear "menu" and expect menu-keyboard semantics (ArrowKeys auto-activate); activation navigates to a URL, breaking the menu's "select one command" mental model. Tab is captured by menu semantics rather than allowing escape.
SidebarNav uses `<nav>` with anchor links (or a `<ul>` of `<li>` containing `<a>`). Not menu, not menuitem, not menubutton. Tab navigates through items as independent links; ArrowKeys are not bound canonically (Polaris and Spectrum do bind them as an enhancement, but it is optional, not required by APG).
#sidebar-no-skip-link
Sidebar precedes main content without skip-to-main
Sidebar appears first in the DOM. Keyboard users tabbing into the page must traverse every sidebar link before reaching main content. On a sidebar with 20+ items, this adds 20+ Tab presses for every interaction with main content.
Document-level "Skip to main content" link as the first focusable element on the page (typically visually-hidden until focused). Document this as a sibling of the SidebarNav; it is the page's responsibility, not the sidebar's, but the sidebar amplifies the need.
#sidebar-mobile-no-collapse
Sidebar always rendered on mobile
Sidebar takes 240px of viewport width on a 360px-wide mobile device. Main content gets 120px and is unusable. Or sidebar collapses to icon-only without trigger to re-expand.
On mobile (and below `breakpoint.md`), sidebar collapses to an off-canvas Drawer pattern — invisible by default, summoned via a trigger (typically a hamburger menu-button in the page header). The Drawer becomes the sidebar at narrow widths. Document the responsive transition to Drawer in `responsive.breakpoints`.