Accessibility pattern · Navigation
Sidebar navigation
Groups open under real buttons with aria-expanded, and the open page is marked with aria-current="page" and more than color. Collapsed to a rail, the words are visually hidden rather than removed, so every icon is still a named link, and the same words appear as a tooltip on hover or focus.
- WCAG criteria
- 6
- Keyboard rules
- 5
- Checked with
- axe, keyboard and the inspector
Live demo
Try it
Use it with a mouse, a keyboard or a screen reader. The inspector beside it shows what the browser tells assistive technology as you go: focus, state changes and announcements.
The demo works without JavaScript only as far as its HTML does; the inspector needs JavaScript.
Keyboard
Keys it answers to
Every action works without a pointer.
| Key | What it does |
|---|---|
| Tab or ShiftTab | Moves through the links and group buttons; links in a closed group are skipped. |
| Enter or Space | On a group button, opens or closes its links. In the rail, it widens the sidebar and opens the group. |
| Enter | On a link, opens that page (in the demo, marks it as the current page). |
| Enter or Space | On Collapse sidebar, switches between the full sidebar and the icon rail; focus stays on the button. |
| Escape | In the rail, hides the tooltip that is showing, without moving focus. |
Screen readers
What it announces
Written from the roles, names and states in the markup.
| When | Expected announcement |
|---|---|
| Tab reaches the current page | Workspace, navigation. Invoices, current page, link |
| Tab reaches a closed group | Projects, button, collapsed |
| Collapse sidebar is pressed | Sidebar collapsed |
| Tab reaches a link in the rail | Customers, link |
| Expand sidebar is pressed | Sidebar expanded |
These are expected announcements, not recordings. Wording and order differ between screen readers and browsers.
Code
Copy the code
The exact files this demo runs on. The styles are served with a prefix that keeps this site's own styles out of the demo; what you copy is the original.
<div class="ap-sidebar" data-ap-sidebar>
<div class="ap-sidebar__frame">
<div class="ap-sidebar__panel">
<div class="ap-sidebar__brand">
<span class="ap-sidebar__logo" aria-hidden="true"><svg viewBox="0 0 24 24" focusable="false"><path d="m4 18 5.5-10 3.5 6 2-3.5L20 18Z"/></svg></span>
<span class="ap-sidebar__text" translate="no">Kiran Studio</span>
</div>
<nav class="ap-sidebar__nav" aria-label="Workspace">
<ul class="ap-sidebar__list">
<li><a class="ap-sidebar__link" href="/home/"><svg class="ap-sidebar__icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M4 10.5 12 4l8 6.5V19a1 1 0 0 1-1 1h-4.5v-5.5h-5V20H5a1 1 0 0 1-1-1Z"/></svg><span class="ap-sidebar__text">Home</span></a></li>
<li><a class="ap-sidebar__link" href="/inbox/"><svg class="ap-sidebar__icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M3.5 13H8l1.5 2.5h5L16 13h4.5"/><path d="M6 5h12l2.5 8v5a1 1 0 0 1-1 1h-15a1 1 0 0 1-1-1v-5Z"/></svg><span class="ap-sidebar__text">Inbox</span></a></li>
<li class="ap-sidebar__group">
<button type="button" class="ap-sidebar__toggle" aria-expanded="true" aria-controls="sb-billing"><svg class="ap-sidebar__icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M6 3.5h12v17l-3-2-3 2-3-2-3 2Z"/><path d="M9.5 8.5h5M9.5 12.5h5"/></svg><span class="ap-sidebar__text">Billing</span><svg class="ap-sidebar__chevron" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="m6 9 6 6 6-6"/></svg></button>
<ul class="ap-sidebar__sub" id="sb-billing">
<li><a class="ap-sidebar__sublink" href="/billing/invoices/" aria-current="page">Invoices</a></li>
<li><a class="ap-sidebar__sublink" href="/billing/payments/">Payments</a></li>
<li><a class="ap-sidebar__sublink" href="/billing/tax/">Tax reports</a></li>
</ul>
</li>
<li class="ap-sidebar__group">
<button type="button" class="ap-sidebar__toggle" aria-expanded="false" aria-controls="sb-projects"><svg class="ap-sidebar__icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M3.5 7.5a2 2 0 0 1 2-2h3.8l2 2h7.2a2 2 0 0 1 2 2V17a2 2 0 0 1-2 2h-13a2 2 0 0 1-2-2Z"/></svg><span class="ap-sidebar__text">Projects</span><svg class="ap-sidebar__chevron" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="m6 9 6 6 6-6"/></svg></button>
<ul class="ap-sidebar__sub" id="sb-projects" hidden>
<li><a class="ap-sidebar__sublink" href="/projects/website/">Website redesign</a></li>
<li><a class="ap-sidebar__sublink" href="/projects/app/">Mobile app</a></li>
<li><a class="ap-sidebar__sublink" href="/projects/brand/">Brand refresh</a></li>
</ul>
</li>
<li><a class="ap-sidebar__link" href="/customers/"><svg class="ap-sidebar__icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><circle cx="9.5" cy="8.5" r="3.5"/><path d="M3 19.5a6.5 6.5 0 0 1 13 0"/><path d="M16 5.2a3.5 3.5 0 0 1 0 6.6M18.5 14.4a6.5 6.5 0 0 1 2.5 5.1"/></svg><span class="ap-sidebar__text">Customers</span></a></li>
<li><a class="ap-sidebar__link" href="/settings/"><svg class="ap-sidebar__icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M4 7h8.5M16.5 7H20M4 17h2.5M10.5 17H20"/><circle cx="14.5" cy="7" r="2"/><circle cx="8.5" cy="17" r="2"/></svg><span class="ap-sidebar__text">Settings</span></a></li>
</ul>
</nav>
<button type="button" class="ap-sidebar__collapse">
<svg class="ap-sidebar__icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><rect x="3.5" y="4.5" width="17" height="15" rx="2"/><path d="M9 4.5v15"/><path class="ap-sidebar__flip" d="m15.5 10-2 2 2 2"/></svg>
<span class="ap-sidebar__text">Collapse sidebar</span>
</button>
</div>
<div class="ap-sidebar__main">
<p class="ap-sidebar__crumb" translate="no">Kiran Studio</p>
<h3 class="ap-sidebar__page">Invoices</h3>
<p class="ap-sidebar__hint">Pick a page in the sidebar, then collapse it to the icon rail and hover or Tab through the icons.</p>
<div class="ap-sidebar__mock" aria-hidden="true"><span></span><span></span><span></span></div>
</div>
</div>
<p class="ap-sidebar__status" role="status"></p>
</div>
/* Sidebar navigation. Uses the --ap-* design tokens. */
.ap-sidebar {
container-type: inline-size;
width: min(100%, 680px);
margin-inline: auto;
}
.ap-sidebar__frame {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
min-height: 520px;
border: 1px solid var(--ap-border);
border-radius: var(--ap-radius-lg);
background: var(--ap-surface);
box-shadow: var(--ap-shadow-md);
color: var(--ap-text);
}
/* ── The sidebar ── */
.ap-sidebar__panel {
display: flex;
flex-direction: column;
gap: 10px;
width: 236px;
padding: 14px 12px 12px;
border-inline-end: 1px solid var(--ap-border);
border-radius: var(--ap-radius-lg) 0 0 var(--ap-radius-lg);
background: var(--ap-surface-2);
transition: width 200ms var(--ap-ease);
}
.ap-sidebar__brand {
display: flex;
align-items: center;
gap: 10px;
min-height: 40px;
padding: 0 6px 6px;
font-weight: 700;
}
.ap-sidebar__logo {
display: grid;
place-items: center;
width: 32px;
height: 32px;
flex-shrink: 0;
border-radius: var(--ap-radius-sm);
background: var(--ap-accent);
color: var(--ap-on-accent);
}
.ap-sidebar__logo svg {
width: 18px;
height: 18px;
fill: currentColor;
}
.ap-sidebar__nav {
flex: 1;
padding-bottom: 10px;
border-bottom: 1px solid var(--ap-border);
}
.ap-sidebar__list,
.ap-sidebar__sub {
display: grid;
gap: 2px;
margin: 0;
padding: 0;
list-style: none;
}
.ap-sidebar__link,
.ap-sidebar__toggle,
.ap-sidebar__collapse {
position: relative;
display: flex;
align-items: center;
gap: 12px;
width: 100%;
min-height: var(--ap-target);
padding: 0 10px 0 12px;
border: 0;
border-radius: var(--ap-radius-sm);
background: transparent;
color: var(--ap-text-2);
font: inherit;
font-size: .9375rem;
font-weight: 500;
text-align: start;
text-decoration: none;
cursor: pointer;
transition: background-color var(--ap-duration) var(--ap-ease), color var(--ap-duration) var(--ap-ease);
}
.ap-sidebar__link:hover,
.ap-sidebar__toggle:hover,
.ap-sidebar__collapse:hover,
.ap-sidebar__sublink:hover {
background: var(--ap-surface-3);
color: var(--ap-text);
}
.ap-sidebar__link:focus-visible,
.ap-sidebar__toggle:focus-visible,
.ap-sidebar__collapse:focus-visible,
.ap-sidebar__sublink:focus-visible {
outline: 2px solid var(--ap-focus);
outline-offset: -2px;
}
.ap-sidebar__icon {
width: 20px;
height: 20px;
flex-shrink: 0;
fill: none;
stroke: currentColor;
stroke-width: 1.75;
stroke-linecap: round;
stroke-linejoin: round;
}
.ap-sidebar__flip {
transform-box: fill-box;
transform-origin: center;
transition: transform 200ms var(--ap-ease);
}
.ap-sidebar__text {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ap-sidebar__chevron {
width: 16px;
height: 16px;
flex-shrink: 0;
margin-inline-start: auto;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
transition: transform var(--ap-duration) var(--ap-ease);
}
.ap-sidebar__toggle[aria-expanded="true"] .ap-sidebar__chevron {
transform: rotate(180deg);
}
/* Nested links hang off a guide line. */
.ap-sidebar__sub {
position: relative;
margin: 2px 0 6px;
padding-inline-start: 22px;
}
.ap-sidebar__sub::before {
content: "";
position: absolute;
inset-block: 4px;
inset-inline-start: 21px;
border-inline-start: 1px solid var(--ap-border-strong);
opacity: .5;
}
.ap-sidebar__sublink {
position: relative;
display: flex;
align-items: center;
min-height: 38px;
padding: 0 10px 0 18px;
border-radius: var(--ap-radius-sm);
color: var(--ap-text-2);
font-size: .9rem;
text-decoration: none;
transition: background-color var(--ap-duration) var(--ap-ease), color var(--ap-duration) var(--ap-ease);
}
/* The open page: tinted fill, a bar and weight, not color alone. */
.ap-sidebar__link[aria-current="page"],
.ap-sidebar__sublink[aria-current="page"] {
background: var(--ap-accent-soft);
color: var(--ap-accent-soft-text);
font-weight: 650;
}
.ap-sidebar__link[aria-current="page"]::before,
.ap-sidebar__sublink[aria-current="page"]::before {
content: "";
position: absolute;
inset-block: 8px;
inset-inline-start: 0;
width: 3px;
border-radius: var(--ap-radius-full);
background: var(--ap-accent);
}
/* Its group is bold too, so a closed group still shows where you are. */
.ap-sidebar__group:has([aria-current="page"]) > .ap-sidebar__toggle {
color: var(--ap-text);
font-weight: 650;
}
.ap-sidebar__collapse {
color: var(--ap-text-3);
}
/* ── The page beside it ── */
.ap-sidebar__main {
min-width: 0;
padding: 26px 28px;
}
.ap-sidebar__crumb {
margin: 0 0 4px;
color: var(--ap-text-3);
font-size: .8125rem;
font-weight: 600;
}
.ap-sidebar__page {
margin: 0 0 8px;
font-size: 1.375rem;
font-weight: 700;
line-height: 1.25;
letter-spacing: -.01em;
}
.ap-sidebar__hint {
margin: 0 0 22px;
max-width: 36ch;
color: var(--ap-text-2);
line-height: 1.55;
}
.ap-sidebar__mock {
display: grid;
gap: 10px;
}
.ap-sidebar__mock span {
display: block;
height: 44px;
border: 1px solid var(--ap-border);
border-radius: var(--ap-radius-sm);
background: linear-gradient(90deg, var(--ap-surface-2) 0 30%, transparent 30% 36%, var(--ap-surface-2) 36% 58%, transparent 58%);
background-size: calc(100% - 24px) 10px;
background-position: 12px center;
background-repeat: no-repeat;
}
/* Text kept for screen readers but not shown. */
.ap-sidebar__status {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
/* ── The icon rail ── */
.ap-sidebar--rail .ap-sidebar__panel {
width: 68px;
}
.ap-sidebar--rail .ap-sidebar__link,
.ap-sidebar--rail .ap-sidebar__toggle,
.ap-sidebar--rail .ap-sidebar__collapse {
justify-content: center;
padding: 0;
}
.ap-sidebar--rail .ap-sidebar__brand {
justify-content: center;
padding-inline: 0;
}
.ap-sidebar--rail .ap-sidebar__chevron {
display: none;
}
.ap-sidebar--rail .ap-sidebar__flip {
transform: scaleX(-1);
}
/* A closed group that holds the open page keeps a bar in the rail. */
.ap-sidebar--rail .ap-sidebar__group:has([aria-current="page"]) > .ap-sidebar__toggle {
background: var(--ap-accent-soft);
color: var(--ap-accent-soft-text);
}
.ap-sidebar--rail .ap-sidebar__group:has([aria-current="page"]) > .ap-sidebar__toggle::before {
content: "";
position: absolute;
inset-block: 8px;
inset-inline-start: 0;
width: 3px;
border-radius: var(--ap-radius-full);
background: var(--ap-accent);
}
/* The words stay in the page, visually hidden... */
.ap-sidebar--rail .ap-sidebar__text {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
/* ...and the same element is the tooltip on hover or keyboard focus. */
.ap-sidebar--rail :is(.ap-sidebar__link, .ap-sidebar__toggle, .ap-sidebar__collapse):is(:hover, :focus-visible):not([data-quiet]) .ap-sidebar__text {
z-index: 3;
inset-inline-start: calc(100% + 10px);
top: 50%;
width: auto;
height: auto;
overflow: visible;
clip-path: none;
padding: 6px 10px;
border-radius: var(--ap-radius-sm);
background: var(--ap-text);
box-shadow: var(--ap-shadow-md);
color: var(--ap-surface);
font-size: .8125rem;
font-weight: 600;
line-height: 1.3;
transform: translateY(-50%);
animation: ap-sidebar-tip 120ms var(--ap-ease);
}
/* A bridge over the gap, so the pointer can move onto the tooltip. */
.ap-sidebar--rail .ap-sidebar__text::before {
content: "";
position: absolute;
inset-block: 0;
inset-inline-end: 100%;
width: 12px;
}
/* ── Narrow: the full sidebar sits above the page ── */
@container (max-width: 520px) {
.ap-sidebar:not(.ap-sidebar--rail) .ap-sidebar__frame {
grid-template-columns: minmax(0, 1fr);
}
.ap-sidebar:not(.ap-sidebar--rail) .ap-sidebar__panel {
width: auto;
border-inline-end: 0;
border-bottom: 1px solid var(--ap-border);
border-radius: var(--ap-radius-lg) var(--ap-radius-lg) 0 0;
}
.ap-sidebar__main {
padding: 20px;
}
}
@keyframes ap-sidebar-tip {
from { opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.ap-sidebar__panel,
.ap-sidebar__flip,
.ap-sidebar__chevron,
.ap-sidebar__link,
.ap-sidebar__toggle,
.ap-sidebar__collapse,
.ap-sidebar__sublink { transition: none; }
.ap-sidebar--rail .ap-sidebar__text { animation: none; }
}
/**
* Sidebar navigation: an app sidebar of links, with groups that open and
* close under disclosure buttons, and a button that shrinks it to an icon
* rail. In the rail every word stays in the page, visually hidden, and shows
* as a tooltip on hover or keyboard focus.
*
* Markup: [data-ap-sidebar] holding nav > ul of a.ap-sidebar__link and
* groups (button.ap-sidebar__toggle[aria-expanded][aria-controls] + ul), a
* button.ap-sidebar__collapse, an .ap-sidebar__page that names the open
* page, and a role=status element. The rail is the class ap-sidebar--rail.
*/
export function init(root) {
const collapse = root.querySelector(".ap-sidebar__collapse");
const collapseText = collapse.querySelector(".ap-sidebar__text");
const status = root.querySelector("[role=status]");
const pageName = root.querySelector(".ap-sidebar__page");
const groups = [...root.querySelectorAll(".ap-sidebar__toggle")];
const links = [...root.querySelectorAll(".ap-sidebar__link, .ap-sidebar__sublink")];
const listOf = (button) => document.getElementById(button.getAttribute("aria-controls"));
const isRail = () => root.classList.contains("ap-sidebar--rail");
let openBefore = null;
function setGroup(button, open) {
button.setAttribute("aria-expanded", String(open));
listOf(button).hidden = !open;
}
// The rail has no room for nested links, so its groups report collapsed
// (aria-expanded must match what is on screen). Their state comes back
// when the sidebar widens again.
function setRail(rail) {
if (rail === isRail()) return;
if (rail) {
openBefore = groups.map((b) => b.getAttribute("aria-expanded") === "true");
for (const b of groups) setGroup(b, false);
} else if (openBefore) {
groups.forEach((b, i) => setGroup(b, openBefore[i]));
}
root.classList.toggle("ap-sidebar--rail", rail);
// The label says what the button will do; no aria-pressed on top of it.
collapseText.textContent = rail ? "Expand sidebar" : "Collapse sidebar";
status.textContent = rail ? "Sidebar collapsed" : "Sidebar expanded";
}
function onClick(event) {
if (event.target.closest(".ap-sidebar__collapse")) {
setRail(!isRail());
return;
}
const group = event.target.closest(".ap-sidebar__toggle");
if (group) {
// In the rail a group cannot open in place: widen the sidebar and open it there.
if (isRail()) {
setRail(false);
setGroup(group, true);
} else {
setGroup(group, group.getAttribute("aria-expanded") !== "true");
}
return;
}
const link = event.target.closest(".ap-sidebar__link, .ap-sidebar__sublink");
if (!link) return;
// The demo stays on this page: the link becomes the current page instead.
event.preventDefault();
for (const l of links) {
if (l === link) l.setAttribute("aria-current", "page");
else l.removeAttribute("aria-current");
}
pageName.textContent = (link.querySelector(".ap-sidebar__text") || link).textContent.trim();
}
/* Tooltips in the rail: Escape hides the one showing without moving focus
(WCAG 1.4.13); it comes back the next time the item is hovered or focused. */
const quiet = (el) => el?.closest(".ap-sidebar__link, .ap-sidebar__toggle, .ap-sidebar__collapse");
function onKeydown(event) {
if (event.key !== "Escape" || !isRail()) return;
const targets = [quiet(document.activeElement), ...[...root.querySelectorAll(":hover")].map(quiet)].filter((el) => el && root.contains(el));
if (!targets.length) return;
for (const el of targets) el.setAttribute("data-quiet", "");
event.preventDefault();
}
function onFocusout(event) {
event.target.removeAttribute?.("data-quiet");
}
function onPointerout(event) {
const el = event.target.closest?.("[data-quiet]");
if (el && !el.contains(event.relatedTarget)) el.removeAttribute("data-quiet");
}
root.addEventListener("click", onClick);
root.addEventListener("keydown", onKeydown);
root.addEventListener("focusout", onFocusout);
root.addEventListener("pointerout", onPointerout);
return () => {
root.removeEventListener("click", onClick);
root.removeEventListener("keydown", onKeydown);
root.removeEventListener("focusout", onFocusout);
root.removeEventListener("pointerout", onPointerout);
};
}
for (const root of document.querySelectorAll("[data-ap-sidebar]")) init(root);
/* Design tokens for the pattern components. Light by default, dark when the
system asks for it; set data-theme="dark" on :root to force dark. */
:root {
--ap-radius-sm: 8px;
--ap-radius: 12px;
--ap-radius-lg: 16px;
--ap-radius-full: 999px;
--ap-font: "Instrument Sans", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
--ap-mono: "JetBrains Mono", ui-monospace, "Cascadia Mono", "SF Mono", Consolas, monospace;
--ap-ease: cubic-bezier(.2, .8, .2, 1);
--ap-duration: 180ms;
--ap-target: 44px;
--ap-bg: #F4F4F5;
--ap-surface: #FFFFFF;
--ap-surface-2: #F4F4F5;
--ap-surface-3: #E4E4E7;
--ap-border: #E4E4E7;
--ap-border-strong: #76767F;
--ap-text: #18181B;
--ap-text-2: #3F3F46;
--ap-text-3: #5E5E66;
--ap-accent: #4F46E5;
--ap-accent-hover: #4338CA;
--ap-on-accent: #FFFFFF;
--ap-accent-text: #4338CA;
--ap-accent-soft: #EEF2FF;
--ap-accent-soft-text: #3730A3;
--ap-focus: #4F46E5;
--ap-danger: #B91C1C;
--ap-danger-soft: #FEF2F2;
--ap-success: #15803D;
--ap-success-soft: #F0FDF4;
--ap-warning: #A15C07;
--ap-warning-soft: #FEFCE8;
--ap-info: #1D4ED8;
--ap-info-soft: #EFF6FF;
--ap-scrim: rgb(9 9 11 / .48);
--ap-shadow-sm: 0 1px 2px rgb(9 9 11 / .06);
--ap-shadow-md: 0 1px 2px rgb(9 9 11 / .05), 0 6px 16px -4px rgb(9 9 11 / .1);
--ap-shadow-lg: 0 2px 6px rgb(9 9 11 / .06), 0 20px 40px -12px rgb(9 9 11 / .22);
--ap-light-bg: #F4F4F5;
--ap-light-surface: #FFFFFF;
--ap-light-surface-2: #F4F4F5;
--ap-light-surface-3: #E4E4E7;
--ap-light-border: #E4E4E7;
--ap-light-border-strong: #76767F;
--ap-light-text: #18181B;
--ap-light-text-2: #3F3F46;
--ap-light-text-3: #5E5E66;
--ap-light-accent: #4F46E5;
--ap-light-accent-hover: #4338CA;
--ap-light-on-accent: #FFFFFF;
--ap-light-accent-text: #4338CA;
--ap-light-accent-soft: #EEF2FF;
--ap-light-accent-soft-text: #3730A3;
--ap-light-focus: #4F46E5;
--ap-light-danger: #B91C1C;
--ap-light-danger-soft: #FEF2F2;
--ap-light-success: #15803D;
--ap-light-success-soft: #F0FDF4;
--ap-light-warning: #A15C07;
--ap-light-warning-soft: #FEFCE8;
--ap-light-info: #1D4ED8;
--ap-light-info-soft: #EFF6FF;
--ap-light-scrim: rgb(9 9 11 / .48);
--ap-light-shadow-sm: 0 1px 2px rgb(9 9 11 / .06);
--ap-light-shadow-md: 0 1px 2px rgb(9 9 11 / .05), 0 6px 16px -4px rgb(9 9 11 / .1);
--ap-light-shadow-lg: 0 2px 6px rgb(9 9 11 / .06), 0 20px 40px -12px rgb(9 9 11 / .22);
--ap-dark-bg: #09090B;
--ap-dark-surface: #18181B;
--ap-dark-surface-2: #27272A;
--ap-dark-surface-3: #3F3F46;
--ap-dark-border: #2E2E33;
--ap-dark-border-strong: #8E8E97;
--ap-dark-text: #FAFAFA;
--ap-dark-text-2: #D4D4D8;
--ap-dark-text-3: #A1A1AA;
--ap-dark-accent: #818CF8;
--ap-dark-accent-hover: #A5B4FC;
--ap-dark-on-accent: #0C0A1F;
--ap-dark-accent-text: #A5B4FC;
--ap-dark-accent-soft: #1E1B4B;
--ap-dark-accent-soft-text: #C7D2FE;
--ap-dark-focus: #A5B4FC;
--ap-dark-danger: #F87171;
--ap-dark-danger-soft: #2A1215;
--ap-dark-success: #4ADE80;
--ap-dark-success-soft: #0F2A1A;
--ap-dark-warning: #FACC15;
--ap-dark-warning-soft: #2A2410;
--ap-dark-info: #60A5FA;
--ap-dark-info-soft: #0F1D33;
--ap-dark-scrim: rgb(0 0 0 / .62);
--ap-dark-shadow-sm: 0 1px 2px rgb(0 0 0 / .4);
--ap-dark-shadow-md: 0 1px 2px rgb(0 0 0 / .4), 0 8px 20px -6px rgb(0 0 0 / .5);
--ap-dark-shadow-lg: 0 2px 8px rgb(0 0 0 / .45), 0 24px 48px -12px rgb(0 0 0 / .7);
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--ap-bg: #09090B;
--ap-surface: #18181B;
--ap-surface-2: #27272A;
--ap-surface-3: #3F3F46;
--ap-border: #2E2E33;
--ap-border-strong: #8E8E97;
--ap-text: #FAFAFA;
--ap-text-2: #D4D4D8;
--ap-text-3: #A1A1AA;
--ap-accent: #818CF8;
--ap-accent-hover: #A5B4FC;
--ap-on-accent: #0C0A1F;
--ap-accent-text: #A5B4FC;
--ap-accent-soft: #1E1B4B;
--ap-accent-soft-text: #C7D2FE;
--ap-focus: #A5B4FC;
--ap-danger: #F87171;
--ap-danger-soft: #2A1215;
--ap-success: #4ADE80;
--ap-success-soft: #0F2A1A;
--ap-warning: #FACC15;
--ap-warning-soft: #2A2410;
--ap-info: #60A5FA;
--ap-info-soft: #0F1D33;
--ap-scrim: rgb(0 0 0 / .62);
--ap-shadow-sm: 0 1px 2px rgb(0 0 0 / .4);
--ap-shadow-md: 0 1px 2px rgb(0 0 0 / .4), 0 8px 20px -6px rgb(0 0 0 / .5);
--ap-shadow-lg: 0 2px 8px rgb(0 0 0 / .45), 0 24px 48px -12px rgb(0 0 0 / .7);
}
}
:root[data-theme="dark"] {
--ap-bg: #09090B;
--ap-surface: #18181B;
--ap-surface-2: #27272A;
--ap-surface-3: #3F3F46;
--ap-border: #2E2E33;
--ap-border-strong: #8E8E97;
--ap-text: #FAFAFA;
--ap-text-2: #D4D4D8;
--ap-text-3: #A1A1AA;
--ap-accent: #818CF8;
--ap-accent-hover: #A5B4FC;
--ap-on-accent: #0C0A1F;
--ap-accent-text: #A5B4FC;
--ap-accent-soft: #1E1B4B;
--ap-accent-soft-text: #C7D2FE;
--ap-focus: #A5B4FC;
--ap-danger: #F87171;
--ap-danger-soft: #2A1215;
--ap-success: #4ADE80;
--ap-success-soft: #0F2A1A;
--ap-warning: #FACC15;
--ap-warning-soft: #2A2410;
--ap-info: #60A5FA;
--ap-info-soft: #0F1D33;
--ap-scrim: rgb(0 0 0 / .62);
--ap-shadow-sm: 0 1px 2px rgb(0 0 0 / .4);
--ap-shadow-md: 0 1px 2px rgb(0 0 0 / .4), 0 8px 20px -6px rgb(0 0 0 / .5);
--ap-shadow-lg: 0 2px 8px rgb(0 0 0 / .45), 0 24px 48px -12px rgb(0 0 0 / .7);
}
WCAG 2.2
What it meets
The success criteria this pattern takes care of, and how.
-
1.3.1 Info and Relationships
Level A
The sidebar is a nav landmark of nested lists, and each group's links follow the button that controls them.
-
1.4.1 Use of Color
Level A
The current page has a tinted fill, a bar and bold text; its group is bold too, and keeps a bar in the rail.
-
1.4.13 Content on Hover or Focus
Level AA
Rail tooltips appear on hover and keyboard focus, stay while the pointer moves onto them, and Escape hides them.
-
2.5.3 Label in Name
Level A
In the rail each icon's name is the same word it showed before, and the tooltip shows that word, so voice commands still match.
-
4.1.2 Name, Role, Value
Level A
Group buttons report expanded or collapsed, and the open page's link reports current page.
-
4.1.3 Status Messages
Level AA
Collapsing or expanding the sidebar is confirmed in a status message, without moving focus.
Usage
When to use it
Use it
- Apps and dashboards with many sections that people switch between all day.
- Navigation that has to stay in view beside the work, with room to fold away.
Use something else
- Content sites with a handful of pages: a header navigation is enough.
- Phones, where the sidebar should become a drawer.
- Commands such as Save or Export: they belong in a toolbar or a menu button.
Common failures
How it usually goes wrong
Icons with no name in the collapsed rail
Hiding the words with display: none leaves links named nothing at all. Here they are visually hidden, so each icon is still a named link.
A title attribute as the label
title tooltips do not appear on keyboard focus or touch, and screen readers handle them unevenly. The visible tooltip here is the link's own text.
Menu roles on site navigation
role=menu tells screen readers to expect an application menu with arrow keys. Links and disclosure buttons are what navigation needs.
A group name that is both link and toggle
If Billing opens a page and also expands, people cannot tell which will happen. Group names are buttons; pages are links inside them.
An expanded state that is not true
If the rail hides a group's links but its button still says expanded, people look for links that are not there. The rail sets every group to collapsed.
Tooltips that cannot be dismissed
A tooltip that covers the content beside it, with no way to close it, fails WCAG 1.4.13. Escape hides it here.
Notes
Building it
- Groups follow the disclosure navigation pattern from the APG: a button with aria-expanded and aria-controls, the links in a list right after it.
- The words in the rail use a visually hidden style, not display: none, and the same element becomes the tooltip, so what is shown and what is read never drift apart.
- The collapse button's label switches between Collapse sidebar and Expand sidebar, so it has no aria-pressed as well; the status message confirms the change.
- Remember the choice, for example in localStorage, so the sidebar stays the way people left it.
- In a single-page app, opening a page should also move focus to its main heading; see the focus after a page change pattern.
Sources: WAI-ARIA Authoring Practices: Disclosure navigation menu example · Understanding WCAG 2.2: Content on hover or focus
Checked with axe in light and dark themes, at desktop and phone widths, and by keyboard. Not yet tested with every screen reader and browser pair. Report a correction