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.
<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.
<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.
| 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
| 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-groupis styling on whatever element you use. If the groups matter structurally, use nested<ul>elements witharia-labelledby, or real headings — a<p>in small caps groups things visually and not otherwise. -
The count in a
.pushis 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 thearia-label:aria-label="Inbox, 12 unread". -
Links are 40px tall, not 44.
min-block-size: 40pxis 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.
<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-shellif 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.