Sidebar

A column of destinations, optionally under group headings, for an application with more places to go than a nav bar can hold. It collapses to icons when its container gets narrow — keyed to the rail's own width rather than the window's, which is the interesting part.

When to use it

For the primary navigation of an application: a dashboard, an admin area, a settings screen. It suits eight to twenty destinations in two or three groups, which is exactly the range a horizontal bar handles badly.

For a marketing site or anything with five links, the nav bar is less structure for the same job.

A .push inside a link shoves the count to the far end
<nav class="sidebar card" aria-label="Sections" style="max-inline-size:16rem"> <a class="sidebar-link" href="#" aria-current="page"> <svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#home"></use></svg> <span>Overview</span> </a> <a class="sidebar-link" href="#"> <svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#mail"></use></svg> <span>Inbox</span> <span class="badge push">12</span> </a> <p class="sidebar-group">Workspace</p> <a class="sidebar-link" href="#"> <svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#users"></use></svg> <span>Members</span> </a> <a class="sidebar-link" href="#"> <svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#settings"></use></svg> <span>Settings</span> </a> </nav>
<nav class="sidebar card" aria-label="Sections" style="max-inline-size:16rem">
  <a class="sidebar-link" href="#" aria-current="page">
    <svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#home"></use></svg>
    <span>Overview</span>
  </a>
  <a class="sidebar-link" href="#">
    <svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#mail"></use></svg>
    <span>Inbox</span>
    <span class="badge push">12</span>
  </a>
  <p class="sidebar-group">Workspace</p>
  <a class="sidebar-link" href="#">
    <svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#users"></use></svg>
    <span>Members</span>
  </a>
  <a class="sidebar-link" href="#">
    <svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#settings"></use></svg>
    <span>Settings</span>
  </a>
</nav>

.sidebar paints nothing — no background, no border. It is a padded flex column, so pair it with a .card or your own surface if you want it to read as a panel.

Collapsing to icons

Below 15rem the labels are hidden, the icons centre, and the group headings shrink to nothing. What triggers it is a container query, not a media query:

@container shell (max-width: 15rem) {
  .sidebar-link span { display: none; }
  .sidebar-link { justify-content: center; }
  .sidebar-group { text-align: center; font-size: 0; }
}

That means it responds to how wide the rail is, not how wide the window is — so a sidebar you have dragged narrow collapses on a large monitor, and a sidebar in a wide layout stays expanded on a small one. It is the correct behaviour for a resizable rail and it is why this is not a @media rule.

It does nothing until you name the container. The query is against a container called shell, and Deck does not put one on the sidebar for you. Add .cq-shell to the element whose width should drive the collapse — the wrapper around the rail, usually, not the .sidebar itself. Without it, nothing collapses at any width and there is no error to tell you why.

A 4.5rem .cq-shell — note the aria-label on every link, for the reason below
<div class="cq-shell" style="inline-size:4.5rem"> <nav class="sidebar card" aria-label="Collapsed example"> <a class="sidebar-link" href="#" aria-current="page" aria-label="Overview"> <svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#home"></use></svg> <span>Overview</span> </a> <a class="sidebar-link" href="#" aria-label="Inbox"> <svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#mail"></use></svg> <span>Inbox</span> </a> <a class="sidebar-link" href="#" aria-label="Settings"> <svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#settings"></use></svg> <span>Settings</span> </a> </nav> </div>
<div class="cq-shell" style="inline-size:4.5rem">
  <nav class="sidebar card" aria-label="Collapsed example">
    <a class="sidebar-link" href="#" aria-current="page" aria-label="Overview">
      <svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#home"></use></svg>
      <span>Overview</span>
    </a>
    <a class="sidebar-link" href="#" aria-label="Inbox">
      <svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#mail"></use></svg>
      <span>Inbox</span>
    </a>
    <a class="sidebar-link" href="#" aria-label="Settings">
      <svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#settings"></use></svg>
      <span>Settings</span>
    </a>
  </nav>
</div>

The collapsed state loses the link names

This deserves its own heading because it is a real defect rather than a caveat. display: none removes the label from the accessibility tree, not just from the screen. A collapsed .sidebar-link contains an aria-hidden icon and nothing else, so its accessible name is empty — a screen reader announces "link" with no destination.

.sidebar-group is different and better: font-size: 0 hides the heading visually but leaves it in the tree, so the grouping still reads correctly.

Until the stylesheet handles it, put an aria-label on every .sidebar-link that duplicates its visible text. It costs nothing when expanded — an aria-label simply wins over the content — and it is the only thing standing between a collapsed rail and unusable navigation. Recorded in FINDINGS.md.

<!-- The label appears twice on purpose -->
<a class="sidebar-link" href="/inbox" aria-label="Inbox">
  <svg class="icon" aria-hidden="true"><use href="…#inbox"></use></svg>
  <span>Inbox</span>
</a>

A tooltip is not a substitute. It appears on hover and on keyboard focus, but it is not the element's accessible name, and it does nothing for a reader who never triggers it.

Classes

Generated from src/07-components.css by tools/docs/extract.mjs.

Classes, generated from the stylesheet source
Class Layer What it does Source
.sidebar deck.components Sets display, flex-direction, gap, padding. 07-components.css:845
.sidebar-group deck.components Sets font-size, font-weight, color, padding. 07-components.css:851
.sidebar-link deck.components Sets display, align-items, gap, min-block-size, and 7 more. 07-components.css:852

Tokens

Design tokens this component reads
Token Live Declared value
--text-xs .75rem
--text-sm .8125rem
--text-faint light-dark(var(--ink-500), var(--ink-500))
--text-muted light-dark(var(--ink-600), var(--ink-400))
--surface-hover light-dark(var(--ink-50), var(--ink-900))
--brand-soft light-dark(var(--brand-50), oklch(26% .05 var(--hue-brand)))
--brand-soft-text light-dark(var(--brand-800), var(--brand-200))
--r-sm 7px
--dur-1 110ms

Accessibility

  • Wrap it in <nav> with a label. Most applications have at least two navigation regions; unlabelled they are announced identically.
  • Label every link, always. See above. This is the one non-negotiable thing on this page.
  • Mark the current destination. aria-current="page" drives the styling as well as the announcement, so there is no way to have one without the other.
  • A group heading is not a real heading. .sidebar-group is styling on whatever element you use. If the groups matter structurally, use nested <ul> elements with aria-labelledby, or real headings — a <p> in small caps groups things visually and not otherwise.
  • The count in a .push is separate from the link name. "Inbox" and "12" are announced as two things; whether that reads as "Inbox, 12" or as two disconnected items varies. Where the number matters, put it in the aria-label: aria-label="Inbox, 12 unread".
  • Links are 40px tall, not 44. min-block-size: 40px is below the 44px target size Deck uses elsewhere via --tap. In a dense desktop rail that is a defensible trade; on a touch device it is a small target next to other small targets.

Right to left

The column mirrors correctly with no rule in src/19-logical.css: padding and the gap are symmetrical, and .push uses margin-inline-start: auto, so a trailing count moves to the correct end. An icon that has direction — a chevron — mirrors through the icon rules rather than anything here.

The count moves to the left, which is the end of the line
<nav dir="rtl" class="sidebar card" aria-label="الأقسام" style="max-inline-size:16rem"> <a class="sidebar-link" href="#" aria-current="page" aria-label="نظرة عامة"> <svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#home"></use></svg> <span>نظرة عامة</span> </a> <a class="sidebar-link" href="#" aria-label="الوارد، ١٢ غير مقروءة"> <svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#mail"></use></svg> <span>الوارد</span> <span class="badge push">١٢</span> </a> </nav>
<nav dir="rtl" class="sidebar card" aria-label="الأقسام" style="max-inline-size:16rem">
  <a class="sidebar-link" href="#" aria-current="page" aria-label="نظرة عامة">
    <svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#home"></use></svg>
    <span>نظرة عامة</span>
  </a>
  <a class="sidebar-link" href="#" aria-label="الوارد، ١٢ غير مقروءة">
    <svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#mail"></use></svg>
    <span>الوارد</span>
    <span class="badge push">١٢</span>
  </a>
</nav>

Reduced motion

.sidebar-link transitions its background and colour over 110ms, collapsed by the global reset under prefers-reduced-motion: reduce.

The collapse to icons is not animated at all — display: none cannot be transitioned, and the width change belongs to whatever is driving your .cq-shell. If you animate that width, that is your motion to guard.

Printing

.sidebar is in the "chrome that should never print" list in src/99-print.css and is dropped entirely. That is right for navigation: a printed page of destinations nobody can follow is wasted paper, and removing the rail lets the main column use the full width.

Overriding it

@layer app.components {
  /* Give the rail a surface without wrapping it in a card */
  .sidebar {
    background: var(--surface-2);
    border-inline-end: 1px solid var(--line);
    block-size: 100%;
  }

  /* Bigger touch targets on a rail that is used on tablets */
  .sidebar-link { min-block-size: var(--tap); }

  /* Collapse earlier than 15rem */
  @container shell (max-width: 12rem) {
    .sidebar-link span { display: none; }
  }
}

Widening the collapse threshold is additive — Deck's 15rem rule still applies below 15rem. To collapse later you have to undo the original, which is a case for changing the container width instead.

When not to use it

  • Not for five destinations. A nav bar costs less space and less structure.
  • Not without .cq-shell if you want it to collapse. The container query has nothing to match and fails silently.
  • Not collapsed without labels. See the collapsed state — the links lose their names.
  • Not for navigation inside a page. Sections of the current document are tabs or a table of contents.
  • Not on a phone. A rail takes a third of a narrow screen. Put the same list in a drawer and open it from the bar.
  • Not for a list of records. Conversations, files and search results are a list — they are content, not navigation, and they belong in the main column.