Sidebar drawer
The navigation to the left of this page is a rail while there is room for one and a drawer when there is not. That is the whole of the pattern, and every docs site has a version of it: a hamburger in the header, a panel that slides in from the edge, a scrim over what it covers, and a wide screen where none of it happens because the panel is simply there.
<disclosure-elemental> is the part of it that is not
layout — a button, a region somewhere else in the document, and the ARIA holding the two
together. What is left for the page is where the panel sits at each width, and that is a
media query. Here is the assembly.
<header class="topbar">
<disclosure-elemental for="sidebar" open-when="(min-width: 60rem)">
<button class="nav-toggle" aria-label="Documentation navigation">
<svg class="icon-expand" viewBox="0 0 16 16" width="16" height="16" fill="currentColor" aria-hidden="true">
<path d="m4.177 7.823 2.396-2.396A.25.25 0 0 1 7 5.604v4.792a.25.25 0 0 1-.427.177L4.177 8.177a.25.25 0 0 1 0-.354Z"/>
<path d="M0 1.75C0 .784.784 0 1.75 0h12.5C15.216 0 16 .784 16 1.75v12.5A1.75 1.75 0 0 1 14.25 16H1.75A1.75 1.75 0 0 1 0 14.25Zm1.75-.25a.25.25 0 0 0-.25.25v12.5c0 .138.112.25.25.25H9.5v-13Zm12.5 13a.25.25 0 0 0 .25-.25V1.75a.25.25 0 0 0-.25-.25H11v13Z"/>
</svg>
<svg class="icon-collapse" viewBox="0 0 16 16" width="16" height="16" fill="currentColor" aria-hidden="true">
<path d="M6.823 7.823a.25.25 0 0 1 0 .354l-2.396 2.396A.25.25 0 0 1 4 10.396V5.604a.25.25 0 0 1 .427-.177Z"/>
<path d="M1.75 0h12.5C15.216 0 16 .784 16 1.75v12.5A1.75 1.75 0 0 1 14.25 16H1.75A1.75 1.75 0 0 1 0 14.25V1.75C0 .784.784 0 1.75 0ZM1.5 1.75v12.5c0 .138.112.25.25.25H9.5v-13H1.75a.25.25 0 0 0-.25.25ZM11 14.5h3.25a.25.25 0 0 0 .25-.25V1.75a.25.25 0 0 0-.25-.25H11Z"/>
</svg>
</button>
</disclosure-elemental>
<a class="brand" href="#">📓 Handbook</a>
</header>
<div class="layout">
<aside class="sidebar" id="sidebar">
<nav aria-label="Documentation">
<details open>
<summary>Getting started</summary>
<ul>
<li><a href="#" aria-current="page">Install</a></li>
<li><a href="#">Configuration</a></li>
</ul>
</details>
<details open>
<summary>Guides</summary>
<ul>
<li><a href="#">Layouts</a></li>
<li><a href="#">Theming</a></li>
<li><a href="#">Deploying</a></li>
<li><a href="#">Migrating</a></li>
</ul>
</details>
<details open>
<summary>Reference</summary>
<ul>
<li><a href="#">CLI</a></li>
<li><a href="#">Config file</a></li>
<li><a href="#">Filters</a></li>
<li><a href="#">Shortcodes</a></li>
<li><a href="#">Plugins</a></li>
</ul>
</details>
<details open>
<summary>Recipes</summary>
<ul>
<li><a href="#">Blog</a></li>
<li><a href="#">Search</a></li>
<li><a href="#">Sitemap</a></li>
<li><a href="#">Feeds</a></li>
</ul>
</details>
</nav>
</aside>
<div class="scrim"></div>
<main class="content">
<h1>Install</h1>
<p>Narrow the frame and the rail becomes a drawer. Open it and search the page for
“Deploying” with it shut.</p>
</main>
</div>
body { margin: 0; padding: 0; --line: color-mix(in srgb, currentcolor 15%, transparent); }
.topbar {
/* above the drawer, which slides under it rather than over its bottom edge */
position: sticky; top: 0; z-index: 3;
display: flex; align-items: center; gap: 0.75rem;
height: 3.25rem; padding: 0 1rem;
background: Canvas; border-bottom: 1px solid var(--line);
}
.brand { color: inherit; font-weight: 700; text-decoration: none; }
.nav-toggle {
display: none; align-items: center; justify-content: center;
width: 2.25rem; height: 2.25rem; padding: 0;
border: 0; border-radius: 0.375rem; background: none; color: inherit; cursor: pointer;
}
/* the theme's chevron is for a trigger with a label; this one is its own icon */
.nav-toggle::before { content: none; }
.nav-toggle:hover { background: var(--line); }
/* Octicons draws the pair for a rail on the right, and this drawer is on the left */
.nav-toggle svg { scale: -1 1; }
/* the state is already on the button, so the icon reads it rather than being told */
.nav-toggle[aria-expanded="true"] .icon-expand,
.nav-toggle:not([aria-expanded="true"]) .icon-collapse { display: none; }
.layout { display: flex; align-items: flex-start; }
.content { flex: 1 1 auto; min-width: 0; padding: 0 1.5rem; }
.scrim { display: none; }
.sidebar {
flex: 0 0 14rem; position: sticky; top: 3.25rem;
border-right: 1px solid var(--line);
/* as tall as its links, and its own scroller once they outgrow the viewport */
max-height: calc(100dvh - 3.25rem); overflow-y: auto; overscroll-behavior: contain;
}
/* the inset goes on a box inside the region, never on the region itself */
.sidebar nav { padding: 1rem 0.75rem 3rem; }
/* the sections are native `<details>`, whose marker is a filled triangle — swapped for the
chevron the element's theme draws, so the two carets on the page are one caret */
.sidebar summary { display: flex; align-items: center; gap: 0.4rem; list-style: none; padding: 0.35rem 0.6rem; font-size: 0.8rem; font-weight: 700; text-transform: uppercase; cursor: pointer; }
.sidebar summary::before {
content: ""; flex: none; width: 1em; height: 1em;
rotate: -90deg; /* closed, it points at its own label; open, at the list it revealed */
background: currentcolor;
mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M12.78 5.22a.749.749 0 0 1 0 1.06l-4.25 4.25a.749.749 0 0 1-1.06 0L3.22 6.28a.751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018L8 8.94l3.72-3.72a.749.749 0 0 1 1.06 0Z'/%3E%3C/svg%3E") center / contain no-repeat;
}
.sidebar summary:dir(rtl)::before { rotate: 90deg; }
.sidebar details[open] > summary::before { rotate: 0deg; }
/* a masked icon is a background, and forced colors drops author backgrounds — the only
thing marking the section as openable would go with it */
@media (forced-colors: active) { .sidebar summary::before { background: CanvasText; } }
/* 1.2rem + the link's own 0.6rem inset lands the labels under the summary's, past its caret */
.sidebar ul { margin: 0; padding-left: 1.2rem; list-style: none; }
.sidebar a { display: block; padding: 0.3rem 0.6rem; border-radius: 0.375rem; color: inherit; text-decoration: none; opacity: 0.75; }
.sidebar a[aria-current] { background: var(--line); opacity: 1; font-weight: 600; }
#sidebar {
/* the theme's gap belongs under a trigger, and this panel is not under one */
margin: 0;
}
/* the drawer travels, it does not grow: transform instead of the element's height slide. Only
once a tap has asked for one — closed is where the drawer loads, and a transition live from
the first frame slides it there while the reader watches */
#sidebar:where(.sidebar-ready) { transition: transform 0.2s ease; }
/* only while closing — see below, an opening panel must not defer being reachable */
#sidebar[hidden]:where(.sidebar-ready) { transition: transform 0.2s ease, content-visibility 0.2s allow-discrete; }
@media (prefers-reduced-motion: reduce) { #sidebar { transition: none; } }
/* `free` is the element saying its query stopped matching — a drawer, not a rail. The
breakpoint itself is in the HTML, and only in the HTML. */
disclosure-elemental[data-mode="free"] .nav-toggle { display: inline-flex; }
.sidebar[data-mode="free"] {
/* the drawer is a panel against the edge, so it takes the whole edge. The rail's own
border is what separates it from the scrim — a drop shadow over a scrim is two
things doing one job, and it smears across the dimmed page rather than lifting off it */
position: fixed; inset: 3.25rem auto 0 0; z-index: 2;
width: 14rem; background: Canvas;
}
.sidebar[data-mode="free"][hidden] { transform: translateX(-100%); }
.sidebar[data-mode="free"]:not([hidden]) ~ .scrim {
display: block; position: fixed; inset: 3.25rem 0 0; z-index: 1;
background: rgb(0 0 0 / 35%);
}
const drawer = document.querySelector('disclosure-elemental');
// the drawer is not modal, so light dismiss is the page's to add. `pinned` is the rail,
// which no dismissal may touch: the query writes `open` when it *changes*, so nothing
// would put back a rail Escape closed
const close = () => {
if (!drawer.open || drawer.dataset.mode === 'pinned') return;
// focus first, while the panel is still rendered: closing hides it, and a link
// focused inside would drop focus to <body>
if (drawer.region.contains(document.activeElement)) drawer.button.focus();
drawer.open = false;
};
// the transition arrives with the first tap, so a drawer nobody has touched cannot slide
// itself shut on load. The reflow is what keeps that first tap animated
drawer.button.addEventListener('click', () => {
drawer.region.classList.add('sidebar-ready');
void drawer.region.offsetWidth;
}, { once: true });
// the panel is far from its button in tab order, so opening hands focus over
drawer.addEventListener('disclosure-toggle', (e) => {
if (!e.detail.open || drawer.dataset.mode !== 'free') return;
drawer.region.querySelector('[aria-current], a').focus();
});
document.querySelector('.scrim').addEventListener('click', close);
addEventListener('keydown', (e) => { if (e.key === 'Escape') close(); });The button is in the header, the panel is not
Which is the reason this is the element and not a <details>. The panel has to be a child
of the layout — a flex item beside the content, sticky against the topbar — and the button
has to be in the header, several boxes away. Wrapping the two in a <details> would mean
moving one of them somewhere it cannot do its job.
for points across that distance:
<disclosure-elemental for="sidebar">
<button class="nav-toggle" aria-label="Documentation navigation">…</button>
</disclosure-elemental>
<aside class="sidebar" id="sidebar">…</aside>
The element itself is display: contents, so dropping it around the button changes no
layout at all — the flex row the header lays out is the same row it was before.
Two things follow from where the button sits. It is the first thing in the header, so the
tab that leaves it lands on the brand and the next one is in the drawer: the panel is
reachable in the order it appears on screen, without anything being moved. And its label
lives on the button rather than on the icon, because the icon is
aria-hidden — an unlabelled button that says nothing is the most common way this pattern
breaks.
Two icons, one name
The icons are Octicons' sidebar-expand and
sidebar-collapse, the pair
GitHub uses for exactly this button, mirrored because Octicons draws them for a rail on the
right. Which of the two is showing is a CSS question, not a JavaScript one — the element
has already written the state on the button:
.nav-toggle[aria-expanded="true"] .icon-expand,
.nav-toggle:not([aria-expanded="true"]) .icon-collapse { display: none; }
The :not() rather than [aria-expanded="false"] is what covers the moment before the
element upgrades, when the attribute is not there yet: no state written is the closed
state, and the button shows the icon for opening.
The accessible name does not change with the icon. "Documentation navigation" describes the
thing the button controls, and aria-expanded says whether it is open — a name that flips
to "Close navigation" would be saying the same thing twice, and saying it a beat late.
One thing to take out, though. disclosure-elemental's optional theme puts a chevron
::before on the trigger, which is right for a trigger with a label and lands on top of an
icon that is already its own affordance. It comes off the way the
theme documents:
.nav-toggle::before { content: none; }
The breakpoint is an attribute
A rail is not a second mode to build. It is a disclosure that is always open, and which disclosures are always open is a question about the viewport — so the query goes on the element and the desktop story is over:
<disclosure-elemental for="sidebar" open-when="(min-width: 60rem)">
Above 60rem the panel is open and the button that would close it is display: none. Narrow
the window and the same attribute shuts it again, so a drawer left open never survives a
rotation into a layout that has no drawer. The element tracks one boolean and puts it
everywhere it belongs — aria-expanded on the button, hidden on the panel,
aria-controls between them — and now the breakpoint writes to that boolean like everything
else does.
Which is the point worth taking away, more than the attribute. The tempting version of a responsive drawer is pure CSS:
/* the common bug, not a suggestion */
@media (width >= 60rem) {
.sidebar { display: block; }
.nav-toggle { display: none; }
}
The panel appears, and every assistive technology on the page is still being told it is
hidden and its button collapsed. open-when moves that decision to the one place already
holding the state, so there is nothing to disagree with.
And it takes the number with it. The element reflects which side of its query it is on, as
data-mode="pinned" or data-mode="free", onto itself and onto the panel — so the rest
of this page's CSS never says 60rem at all:
disclosure-elemental[data-mode="free"] .nav-toggle { display: inline-flex; }
.sidebar[data-mode="free"] { position: fixed; /* … */ }
Written the usual way, that breakpoint would be in the stylesheet as well as in the markup — one number, twice, in two languages, with nothing to notice when they stop agreeing. Here the query is declared once and the CSS keys off the answer. Which is also what makes the layout portable: it carries no breakpoint, so it drops into a project whose breakpoint is somewhere else and works.
What is left for the page is light dismiss, because a disclosure is not a dialog and neither Escape nor a click on the scrim is behaviour the pattern owes you:
document.querySelector('.scrim').addEventListener('click', close);
addEventListener('keydown', (e) => { if (e.key === 'Escape') close(); });
No breakpoint test in either, because the one that is needed is in close() and covers both.
The scrim is not on screen above the breakpoint, but the keyboard is at every width, and a
rail closed by Escape is a rail nothing brings back: open-when writes open when the query
changes, and a query that is still matching changes nothing — so the panel is gone, with the
button that would reopen it display: none up here. The guard goes where every dismissal
already passes through:
const close = () => {
if (!drawer.open || drawer.dataset.mode === 'pinned') return;
// …
};
pinned rather than not-free, because a disclosure with no open-when attribute has no mode at
all and is a drawer at every width.
Focus, which is the page's too
The pattern does not move focus, and for most disclosures that is right: the region is the next thing after the button, so Tab reaches it. This one is not next. The button is the first thing in the header and the panel is the last thing in the layout, so between them sit the brand, and in a real docs header the search field and every icon link as well. Opening a panel that then takes five tabs to reach is a panel that opened for the mouse only.
So the page hands focus over, on the one mode where the panel is an overlay:
drawer.addEventListener('disclosure-toggle', (e) => {
if (!e.detail.open || drawer.dataset.mode !== 'free') return;
drawer.region.querySelector('[aria-current], a').focus();
});
Three things in there worth reading twice. The data-mode test is not decoration: crossing
the breakpoint opens the rail too, and a rail stealing focus because you widened the window
is worse than the problem being fixed. [aria-current] first, so focus lands on the page you
are actually on rather than the top of the list. And region and button are the element's
own properties — the page never has to re-find by selector what the element already resolved.
The other half is the close. Setting hidden on a panel with focus inside it drops focus to
<body>, and the next Tab restarts from the top of the document — so close() takes focus
back to the button first, while the panel is still rendered:
const close = () => {
if (!drawer.open || drawer.dataset.mode === 'pinned') return;
if (drawer.region.contains(document.activeElement)) drawer.button.focus();
drawer.open = false;
};
That ordering is the whole trick, and it is easy to get backwards: move focus, then close.
None of this makes the drawer modal. Focus is not trapped, the article is not inert, and
tabbing past the last link leaves the panel and carries on into the page — which is still
the disclosure pattern. Handing focus to a panel is not the
same promise as refusing to let it go.
One thing to carry across when you copy it. Those lines go in a
<script type="module">, and the type is not about the scoping — it is about when the
script runs. A classic <script> in the body runs while the page is still parsing, before
a deferred bundle has defined anything, and drawer.open = false against an element that
has not upgraded yet writes an ordinary property onto the instance. It shadows the accessor
the class is about to bring, permanently: the first write appears to work, and then nothing
else ever does, silently. A module is deferred, so it runs after the definition lands.
drawer.removeAttribute("open") is the other way out, and the one to reach for when the
script genuinely has to run early — the attribute is the state, and it does not care whether
the element is alive yet.
Sliding on transform, not on height
The element animates its region's height, because the thing a disclosure usually reveals grows into the space it needs. A drawer does not grow — it is already full height and arrives from off-screen — so the page takes the animation back:
#sidebar:where(.sidebar-ready) { transition: transform 0.2s ease; }
.sidebar[data-mode="free"][hidden] { transform: translateX(-100%); }
The height slide is not switched off with a flag. The element
reads the transition back out of the computed styles
to time itself, so a region with no height transition has nothing to time and the state
lands at once — leaving the transform to do the moving. It is not scoped to free either: at
the wide end there is no transform to animate, and a rail that slid open on every page load
would be a page load you can watch happening. What it is scoped to is a class, which is the
next thing.
margin: 0 on the same panel is the other thing handed back. The element's optional theme
puts half a rem above the region, which is the right gap between a trigger and the thing it
opened directly beneath it — and here the trigger is up in the header and the panel is
flush against it, so that gap is a seam under the topbar instead. It is zeroed for both
modes, because the rail has the same seam.
The transition arrives with the first tap
A transition live from the first frame is a drawer that closes itself while you watch. Closed is the state the panel arrives in — the element writes it at upgrade, from the query — so on a load where the script lands after the first paint, which is a cold cache or a slow phone, the browser has the rail already drawn and is asked for a closed drawer. It animates the difference, and the page's opening move is the navigation sliding off-screen. No transition until something asks for one, and nothing has asked yet:
drawer.button.addEventListener('click', () => {
drawer.region.classList.add('sidebar-ready');
void drawer.region.offsetWidth;
}, { once: true });
The first tap still slides, and the reflow is why. The listener is on the button, so it runs
before the element's own handler catches the click on its way up, and reading offsetWidth
flushes the closed panel with the transition already on it. Without that read the class and
the state land in one recalc — a style change the transition was not there for, so the first
tap would jump and every tap after it would slide.
Counting frames instead does not hold, which is worth knowing before you try it: a closed
panel painted for two requestAnimationFrames still slid in from nothing the moment the rule
arrived. And :where() rather than the bare class, so the specificity stays what it was and
the reduced-motion rule below still wins on source order.
Crossing the breakpoint before any tap snaps rather than slides — which is what the element already does with the state itself.
allow-discrete is the other half of the transition, and it is on the closed state rather
than on the panel:
#sidebar:where(.sidebar-ready) { transition: transform 0.2s ease; }
#sidebar[hidden]:where(.sidebar-ready) { transition: transform 0.2s ease, content-visibility 0.2s allow-discrete; }
hidden="until-found" computes to content-visibility: hidden, which is what keeps the
closed drawer's links out of the tab order and out of the accessibility tree. Applied on the
first frame of a close it would also empty the panel before it had finished leaving, and
transitioning a discrete property is what defers that flip to the end — one keyword rather
than a transitionend listener.
Only that direction wants it. Put the same declaration on #sidebar unconditionally and the
opening drawer inherits a content-visibility transition it cannot finish: the panel slides
in and paints, and its links stay skipped behind it — not focusable, not scrollable,
Tab straight past a drawer you can see. Keying it to [hidden] means the rule is gone by the
time the attribute is, so opening flips at once and only the close is deferred.
Worth a Tab through the open drawer whenever you touch this — a panel that renders is not the same as a panel that is there, and this is the failure that looks like it works.
Note where the padding is: on the <nav> inside the panel, not on the panel. That is the
element's one rule about its region and it is
worth keeping even here — the region is the animated box, and the shipped stylesheet strips its
padding and borders while closed so a collapsed region cannot leave a strip behind.
The panel scrolls itself
A sidebar is a list that grows: sections open, a table of contents nests under the page you
are on, and sooner or later it is taller than the screen. Left alone the overflow goes to
the page — the rail is position: sticky, so it stops at the bottom of the viewport and the
links past it are unreachable without scrolling the article they were meant to navigate. A
ceiling and an overflow make the panel a scroller of its own instead:
.sidebar {
max-height: calc(100dvh - 3.25rem);
overflow-y: auto;
overscroll-behavior: contain;
}
Three deliberate choices in four lines:
max-heightrather thanheight. A rail is as tall as its links. Pinning it to the viewport instead would run its border down a column that ran out of nav two sections ago, and a scrollbar appears on a list that fits — the ceiling only has to be where the scrolling starts, not where the panel ends.dvhrather thanvh. On a phone100vhis the viewport with the browser's toolbars hidden, which is taller than what you can see while they are showing — the last links sit under the address bar with nothing left to scroll. The dynamic viewport unit tracks the box that is actually visible.overscroll-behavior: contain. Reaching the end of the drawer's list should stop there, not hand the rest of the gesture to the article underneath it. This is the non-modal equivalent of the scroll lock a modal drawer would take out on the whole page, and it costs one declaration instead of abody { overflow: hidden }you then have to remember to undo.
The drawer is the one that wants the whole edge — a panel on a background, floating to three-quarters of the screen because that is where its links stopped, is a panel that looks broken. It gets there without repeating the height, because a fixed box pinned top and bottom already fills what is between them:
.sidebar[data-mode="free"] { position: fixed; inset: 3.25rem auto 0 0; }
The bottom padding is on the <nav>, with the rest of the inset — the last link wants room
under it, and a scroller whose content ends flush with its edge always looks like it is
still cut off.
Without the script
The panel is not authored hidden, so with the bundle blocked or still in flight it is
just an <aside> full of links — visible, reachable, in the flow where the markup put it.
The for shape keeps that even now that the element's stylesheet paints sibling regions
closed while scripting is on: a region under an id no stylesheet can know is left alone.
The button is handled twice over — the element's own stylesheet hides a trigger that would
toggle nothing while scripting is off, and this example's .nav-toggle { display: none }
behind [data-mode="free"] hides it until upgrade either way.
The layout has to agree with that, and here it does so by accident of how it is written.
Every rule that makes this thing a drawer is behind [data-mode="free"]:
.sidebar[data-mode="free"] { position: fixed; /* … */ }
data-mode is written at upgrade, so that selector cannot match until the element is
alive. A narrow page with no script never gets an off-canvas panel pinned over the article
with nothing able to move it — it gets an <aside> in the flow, and a page that scrolls.
Which is worth noticing as a habit rather than a trick here: layout that would strand the
page without the script belongs behind a hook the script is what creates. This example used
to spell that :root:has(disclosure-elemental:defined) around the whole block. Keying off
the mode says the same thing and says it once.
A disclosure, not a dialog
The drawer here is not modal: focus is not trapped in it, the article behind the scrim is
not inert, and tabbing past the last link leaves it. That is the
APG disclosure pattern exactly, and
for navigation it is usually the right amount — the panel is a list of links to the same
site, and a keyboard user who tabs out of it has not lost anything.
If your drawer is modal — a filter panel with a form and an apply button, something where
leaving without a decision is a mistake — that is a different pattern with a different
element behind it, and the platform has both. <dialog> with showModal() gives the trap,
the inert background and the ::backdrop for free; popover gives light dismiss and
Escape without the trap. Neither can be a sticky rail at 1024px, which is why they are not
in this example, and the honest version of "modal below the breakpoint" is switching
between the two at the breakpoint.
What the element brought
Everything on this page that is not furniture:
aria-expanded,aria-controlsandhiddenstay in step, across a click, the breakpoint, the scrim, Escape and find-in-page, because all five write one boolean.- The breakpoint is an attribute, so the rail is not a mode with its own code path —
open-whenholds the region open while the query matches and hands it back when it stops. - The layout never repeats the breakpoint, because
data-modelands on the element and the panel, which also means none of it can apply before the script does. - The panel is not moved or wrapped, so it stays the flex item its layout expects and
display: contentskeeps the header's row intact. - Find-in-page reaches a link inside a closed drawer, because closed is
hidden="until-found"rather thandisplay: none, and the element hears the reveal onbeforematchand stops disagreeing with it. - The button gets
type="button"whether or not you remembered, so a drawer inside a form does not submit it.
What is left for the page is a flex row, a fixed panel, a scrim, light dismiss and one handover of focus — and none of it is about a disclosure. It is about where this page put the button.