Collapsible sidebar
A dashboard sidebar that collapses to icons and becomes a drawer on mobile.
<div class="shell" id="shell">
<aside class="side" id="side">
<div class="side-head">
<span class="mark">A</span>
<span class="name">Acme</span>
<button class="toggle" id="toggle" aria-label="Collapse sidebar" aria-expanded="true">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M15 6l-6 6 6 6"/></svg>
</button>
</div>
<nav aria-label="Sections">
<a href="#" aria-current="page">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 12l9-9 9 9M5 10v10h14V10"/></svg>
<span>Overview</span>
</a>
<a href="#">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 19V9M10 19V5M16 19v-7M22 19H2"/></svg>
<span>Reports</span>
</a>
<a href="#">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 6h16M4 12h16M4 18h10"/></svg>
<span>Orders</span>
</a>
<a href="#">
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="8" r="3"/><path d="M5 20c0-3.9 3.1-7 7-7s7 3.1 7 7"/></svg>
<span>Customers</span>
</a>
</nav>
<div class="side-foot">
<a href="#">
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M12 2v3M12 19v3M4.2 4.2l2.1 2.1M17.7 17.7l2.1 2.1M2 12h3M19 12h3M4.2 19.8l2.1-2.1M17.7 6.3l2.1-2.1"/></svg>
<span>Settings</span>
</a>
</div>
</aside>
<main class="content">
<button class="drawer-open" id="drawer-open" aria-label="Open menu">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h16M4 12h16M4 17h16"/></svg>
</button>
<h1>Overview</h1>
<p>Collapse the sidebar with the arrow, or narrow the preview to see it become a drawer.</p>
</main>
<div class="scrim" id="scrim" hidden></div>
</div>* { box-sizing: border-box; margin: 0; }
body { font: 16px/1.6 system-ui, -apple-system, sans-serif; }
.shell { display: flex; min-height: 100vh; background: #f6f8fb; }
.side {
width: 232px;
flex-shrink: 0;
display: flex;
flex-direction: column;
background: #fff;
border-right: 1px solid #e2e7ef;
transition: width .2s ease;
}
.shell.narrow .side { width: 64px; }
.side-head {
display: flex;
align-items: center;
gap: 10px;
padding: 16px 14px;
border-bottom: 1px solid #eef1f6;
}
.mark {
width: 32px; height: 32px;
flex-shrink: 0;
display: grid;
place-items: center;
border-radius: 8px;
background: #3b4fe4;
color: #fff;
font-weight: 700;
}
.name { font-weight: 600; color: #16202e; white-space: nowrap; }
.toggle {
margin-left: auto;
width: 28px; height: 28px;
display: grid;
place-items: center;
border: 1px solid #e2e7ef;
border-radius: 6px;
background: #fff;
cursor: pointer;
color: #5b6b83;
}
.toggle svg { width: 14px; height: 14px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; }
.shell.narrow .toggle svg { transform: rotate(180deg); }
.side nav { padding: 10px 8px; display: grid; gap: 2px; }
.side-foot { margin-top: auto; padding: 10px 8px 16px; border-top: 1px solid #eef1f6; }
.side a {
display: flex;
align-items: center;
gap: 11px;
padding: 9px 11px;
border-radius: 7px;
color: #5b6b83;
text-decoration: none;
font-size: .93rem;
white-space: nowrap;
}
.side a:hover { background: #f2f5f9; color: #16202e; }
.side a[aria-current="page"] { background: #eef0fe; color: #2a3abf; }
.side a svg {
width: 18px; height: 18px;
flex-shrink: 0;
fill: none;
stroke: currentColor;
stroke-width: 1.8;
stroke-linecap: round;
stroke-linejoin: round;
}
/* collapsed: hide the words, keep the icons */
.shell.narrow .name,
.shell.narrow .side a span { display: none; }
.shell.narrow .side a { justify-content: center; padding: 10px; }
.content { flex: 1; padding: 28px 32px; }
.content h1 { font-size: 1.5rem; color: #16202e; }
.content p { color: #5b6b83; margin-top: 8px; }
.drawer-open {
display: none;
width: 38px; height: 38px;
margin-bottom: 16px;
place-items: center;
border: 1px solid #e2e7ef;
border-radius: 8px;
background: #fff;
cursor: pointer;
}
.drawer-open svg { width: 18px; height: 18px; fill: none; stroke: #16202e; stroke-width: 2; stroke-linecap: round; }
.scrim { position: fixed; inset: 0; background: rgba(22,32,46,.4); z-index: 30; }
/* ---- mobile: the sidebar becomes a drawer ---- */
@media (max-width: 760px) {
.side {
position: fixed;
inset: 0 auto 0 0;
z-index: 40;
width: 232px;
transform: translateX(-100%);
transition: transform .22s ease;
}
.shell.drawer .side { transform: translateX(0); }
.shell.narrow .side { width: 232px; }
.shell.narrow .name, .shell.narrow .side a span { display: inline; }
.shell.narrow .side a { justify-content: flex-start; padding: 9px 11px; }
.toggle { display: none; }
.drawer-open { display: grid; }
.content { padding: 20px; }
}
@media (prefers-reduced-motion: reduce) {
.side { transition: none; }
}const shell = document.getElementById("shell");
const scrim = document.getElementById("scrim");
const toggle = document.getElementById("toggle");
toggle.addEventListener("click", () => {
const narrow = shell.classList.toggle("narrow");
toggle.setAttribute("aria-expanded", narrow ? "false" : "true");
toggle.setAttribute("aria-label", narrow ? "Expand sidebar" : "Collapse sidebar");
});
function drawer(open) {
shell.classList.toggle("drawer", open);
scrim.hidden = !open;
if (open) document.getElementById("side").querySelector("a").focus();
}
document.getElementById("drawer-open").addEventListener("click", () => drawer(true));
scrim.addEventListener("click", () => drawer(false));
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") drawer(false);
});How it works
Two different behaviours from one component, which is what makes sidebars fiddly.
On desktop it collapses in place. The width animates from 232 to 64 pixels and the labels disappear, leaving an icon rail. The content area is a flex child, so it reflows automatically — no width calculation anywhere.
On mobile it becomes a drawer. Below 760 pixels the sidebar switches to position: fixed and slides in over the content with a scrim behind it. The collapse toggle is hidden, because collapsing to icons on a phone helps nobody.
The collapsed state overrides itself on mobile. Note the media query re-enables the labels even when .narrow is set. Without that, a user who collapsed the sidebar on desktop and then opened the site on a phone would get a drawer full of unlabelled icons.
Icons stay inline. Inline SVG with stroke: currentColor means the icons take their colour from the link state — grey normally, indigo when current — with no second set of files and no icon font.
Accessibility notes
The collapse button carries aria-expanded and its label changes with the state, so a screen reader user knows what pressing it will do.
When the drawer opens, focus moves to the first link inside it and Escape closes it. Icons are marked aria-hidden because the adjacent text already names each item — announcing both would read everything twice.
In the collapsed state the labels are hidden with display: none, which removes them from the accessibility tree too. For a production dashboard, add a title or a tooltip so the icon rail is not a memory test.
Making it yours
Change the two widths in .side and .shell.narrow .side to resize the rail. Keep the collapsed width above about 56 pixels or the icons stop having comfortable tap targets.
To remember the collapsed state between visits, write it to localStorage in the toggle handler and read it back before first paint — the same inline-script trick used for a dark-mode preference, which avoids a visible flash.
Related templates
Responsive navbar
A header that collapses to a hamburger under 820px. No framework, keyboard accessible.
HTMLCSSJavaScriptTailwindAccessible dropdown menu
A nav dropdown that opens on hover, click and keyboard, with arrow-key support.
HTMLCSSJavaScriptBreadcrumbs
A breadcrumb trail with the correct markup and matching structured data.
HTMLCSSHoly grail layout
Header, footer, two sidebars and a main column — in nine lines of grid.
HTMLCSSCheck your version
Once you have edited this, the HTML validator will catch any tag you left unclosed, and the formatter will tidy the indentation. Both run in your browser.