Overzicht
Een zijmenu is een deelvenster dat vanaf de rand van de viewport over de paginacontent schuift en alles erachter dimt. Het wordt geopend via een triggerknop en gesloten door het deelvenster van het scherm te swipen, op de gedimde achtergrond te tikken of op Escape te drukken.
In deze handleiding wordt de lade geïmplementeerd als:
- Een
popover="manual"-element dat is gepromoot naar de bovenste laag, zodat het deelvenster en de::backdropelk ander element op de pagina overlappen, ongeacht de stapelcontext. - Een horizontaal scrollende container met 2 CSS-scrolluitlijningsstops: één voor open en één voor gesloten, zodat het swipegebaar native wordt verwerkt door de browser. Hiermee kun je momentum, snelheid en onderbrekingen kosteloos bijhouden, zonder JavaScript-code voor aanwijzergebeurtenissen.
- Een scrollgestuurde animatie die de dekking van de achtergrond koppelt aan de scrollpositie, zodat de dimming soepel in- en uitfadet terwijl de gebruiker het deelvenster sleept.
- Een
IntersectionObserverin het deelvenster dat herkent wanneer het volledig in of uit het weergavegebied is en die momenten gebruikt om de focus,aria-expandedeninertte updaten.
Deze aanpak heeft de voorkeur boven JavaScript-gestuurde transform-animaties, omdat het scrollmechanisme de gebruiker rechtstreekse controle geeft over de positie van het deelvenster (de vinger van de gebruiker stuurt het aan, niet een tween) en het veel beter aansluit bij de interactiepatronen die gebruikers gewend zijn in systeemeigen mobiele apps.
Implementatie
1. Opmaak
De lade is één pop-over met een horizontale scroller die het zichtbare blad bevat. De triggerknop bevindt zich in de paginacontent.
<!-- popover="manual" is REQUIRED. Do not use popover="auto" or "hint". -->
<div class="Drawer" id="drawer" popover="manual">
<div class="Drawer-scroller">
<nav class="Drawer-sheet" tabindex="-1">
<!-- tabindex="-1" makes the sheet programmatically focusable so we
can move focus into it when the drawer opens, without adding it
to the natural tab order. -->
<ul>
<li><a href="/page-1">Page 1</a></li>
<li><a href="/page-2">Page 2</a></li>
<!-- ... -->
</ul>
</nav>
</div>
</div>
<main>
<header>
<!-- aria-controls links the trigger to the drawer; aria-expanded
reflects the current state for assistive tech. -->
<button id="drawer-open"
aria-label="Menu"
aria-expanded="false"
aria-controls="drawer">
<!-- MANDATORY: Inline decorative SVGs MUST define aria-hidden="true" -->
<svg aria-hidden="true" viewBox="0 0 24 24">...</svg>
</button>
</header>
<!-- Page content. -->
</main>
2. Stijlen
De popover resetten en het kijkvenster vullen
De popover moet de hele viewport bedekken, zodat de ::backdrop de hele pagina dimt en het swipe-oppervlak van rand tot rand loopt. De standaard popover-stijlen van de user-agent (gecentreerd, automatisch formaat, met rand) zitten in de weg en moeten worden gereset.
.Drawer {
/* min() caps the sheet width on large screens but on a phone leaves
a 20% peek of page content visible, which is the affordance that
tells the user they can tap outside to dismiss. */
--drawer-width: min(20em, 80dvw);
/* Custom property driven by the scroll-driven animation below.
0 = drawer fully closed (transparent backdrop).
1 = drawer fully open (visible backdrop). */
--drawer-backdrop: 0;
/* Reset UA popover style that would constrain the element. */
width: auto;
height: auto;
background: transparent;
border: 0;
overflow: visible;
}
/* Style the popover's ::backdrop to achieve the overlay effect and
provide visual affordances indicating that the rest of the page is inert */
.Drawer::backdrop {
background: #000;
/* Use calc() to limit the opacity range so the content beneath is visible */
opacity: calc(var(--drawer-backdrop) / 2);
}
Het swipe-oppervlak maken met scrolluitlijning
De scroller is een horizontaal raster dat breder is dan de viewport: kolom 1 bevat het blad (breedte --drawer-width), kolom 2 is een leeg pseudo-element met de breedte van de viewport. Door tussen de 2 kolommen te schakelen, open en sluit je de lade.
.Drawer-scroller {
position: relative;
display: grid;
/* Sheet on the left, full-viewport spacer on the right. The user
scrolls between the two snap stops to open and close. */
grid-template-columns: var(--drawer-width) 100%;
overflow-x: scroll;
/* Stop the swipe from chaining into the page's vertical scroll
when the user reaches either snap edge. */
overscroll-behavior: none;
scrollbar-width: none;
/* `mandatory` guarantees the drawer always settles fully open or
fully closed — never half-open after a partial swipe. */
scroll-snap-type: x mandatory;
}
/* Enable smooth scrolling natively, but only if the user has not
requested reduced motion. */
@media (prefers-reduced-motion: no-preference) {
.Drawer-scroller {
scroll-behavior: smooth;
}
}
/* The empty spacer that creates the "closed" snap stop. */
.Drawer-scroller::after {
content: '';
scroll-snap-align: end;
/* Open the popover already scrolled to this stop (drawer off-screen),
so the JS only needs to scroll to the open position to
animate it in. */
scroll-initial-target: nearest;
}
.Drawer-sheet {
display: grid;
grid-template-rows: auto 1fr;
/* Use `svh` (small viewport height) — not `vh` or `dvh` — so the
sheet height does not jump when the iOS Safari address bar
resizes mid-swipe. */
height: 100svh;
background: #333;
color: #fff;
overflow-y: auto;
scroll-snap-align: start;
scrollbar-width: none;
}
Koppel de ondoorzichtigheid van de achtergrond aan de scrollpositie
Een scrollgestuurde animatie wijst --drawer-backdrop toe van 1 (open) naar 0 (gesloten) over het bereik van de scroller, zodat de achtergrond in- en uitfadet, perfect gesynchroniseerd met het slepen.
/* MANDATORY: Wrap this entire block in @supports. Browsers that don't
support animation-timeline still parse the @keyframes and would
apply the animation's `0%` value (--drawer-backdrop: 1) at all
times, leaving the backdrop permanently opaque. The @supports gate
ensures the animation is only registered where it actually works. */
@supports (animation-timeline: scroll()) {
.Drawer {
/* timeline-scope lets .Drawer reference a scroll-timeline that
is defined on its descendant (the scroller). Without this, the
timeline name is not visible to the .Drawer element. */
timeline-scope: --drawer-fade;
animation: fade-drawer-backdrop linear both;
animation-timeline: --drawer-fade;
}
.Drawer-scroller {
/* The horizontal scroll position of this element drives the
timeline named `--drawer-fade`. */
scroll-timeline: --drawer-fade x;
}
/* @property is REQUIRED. Without registering --drawer-backdrop with
a `<number>` syntax, the browser treats it as a string and cannot
interpolate it — the backdrop would jump from 0 to 1 with no
fade. */
@property --drawer-backdrop {
syntax: '<number>';
inherits: true;
initial-value: 0;
}
@keyframes fade-drawer-backdrop {
/* Scroll position 0 = drawer fully open = backdrop visible. */
0% { --drawer-backdrop: 1 }
/* Scroll position 100% = drawer fully closed = backdrop hidden. */
100% { --drawer-backdrop: 0 }
}
}
3. Het zijmenu openen en sluiten
Het openen bestaat uit 2 stappen: de popover naar de bovenste laag promoveren en dan het blad in beeld scrollen. Sluiten is één stap: scroll terug naar de afstandhouder. Een waarnemer (stap 4) verbergt de popover zodra het blad volledig van het scherm is.
const drawer = document.getElementById('drawer');
const openBtn = document.getElementById('drawer-open');
const scroller = drawer.querySelector('.Drawer-scroller');
const sheet = drawer.querySelector('.Drawer-sheet');
function openDrawer() {
// Show the popover first so the element is in the top layer before
// we trigger any scrolling. `scroll-initial-target` (set on the
// ::after spacer) places the initial scroll position at the closed
// stop, so the drawer enters the top layer already off-screen.
drawer.showPopover();
// Scroll the sheet into view. The `behavior: 'auto'` option defers
// to the CSS `scroll-behavior` property, which will be smooth unless
// the user prefers reduced motion. Snap takes over at the end and
// locks the drawer fully open.
scroller.scrollTo({left: 0, behavior: 'auto'});
}
function closeDrawer() {
// Scroll back to the spacer. Do NOT call hidePopover() here —
// doing so would remove the element from the top layer mid-animation
// and the close animation would not be visible. The
// IntersectionObserver in step 4 hides the popover once the sheet
// has actually left the viewport.
scroller.scrollTo({left: scroller.offsetWidth, behavior: 'auto'});
}
4. Open en gesloten status waarnemen
Gebruik een IntersectionObserver op het blad (niet de scrollpositie) als de bron van waarheid voor de status van de lade. De waarnemer wordt geactiveerd, ongeacht hoe het blad is verplaatst (swipen door de gebruiker, programmatisch scrollen, uitlijnen), dus alle sluitingspaden komen samen in dezelfde callback.
function onDrawerOpened() {
// Mark the rest of the page inert so keyboard and screen-reader
// users cannot tab into content hidden behind the drawer.
document.querySelector('main').inert = true;
openBtn.setAttribute('aria-expanded', 'true');
// Move focus into the drawer for keyboard users.
sheet.focus();
}
function onDrawerClosed() {
// Hide the popover only after the close animation completes,
// so the slide-out is visible to the user.
drawer.hidePopover();
document.querySelector('main').inert = false;
openBtn.setAttribute('aria-expanded', 'false');
}
// Treat "any pixel of the sheet visible inside the popover root" as
// "open enough to count as not closed". This threshold is intentionally
// tiny so the closed callback only fires once the sheet is truly gone.
const visibleThreshold = 1 / window.innerWidth;
const observer = new IntersectionObserver(
(entries) => {
// During programmatic scrolling the observer can deliver multiple
// entries in one batch. Only the most recent describes the
// current state; earlier entries are intermediate positions.
const entry = entries.at(-1);
if (entry.intersectionRatio < visibleThreshold) onDrawerClosed();
if (entry.intersectionRatio === 1) onDrawerOpened();
},
// root: drawer makes the popover element the intersection root,
// so the ratio reflects the sheet's visibility within the popover
// (i.e. how much of it has been swiped on-screen).
{root: drawer, threshold: [visibleThreshold, 1]},
);
observer.observe(sheet);
5. De trigger- en sluitingshandlers koppelen
// Open trigger.
openBtn.addEventListener('click', openDrawer);
// Light-dismiss: a tap on the dimmed area (anywhere inside the
// popover but outside the sheet) closes the drawer. We implement
// this manually because popover="manual" disables the browser's
// built-in light-dismiss (which would also fire mid-swipe — see step 1).
drawer.addEventListener('click', (event) => {
if (!sheet.contains(event.target)) closeDrawer();
});
// Escape key. Listen on document because focus may be inside the
// drawer when the user presses Escape.
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape') closeDrawer();
});
Reservestrategieën
De popover-API, de scrollgestuurde animatie die de achtergrond vervaagt en scroll-initial-target zijn progressive enhancements met eenvoudige fallbacks die makkelijk kunnen worden geïmplementeerd als brede browserondersteuning is vereist.
Fallback voor vervagen van achtergrond (geen ondersteuning voor animation-timeline):
Detecteer met CSS.supports('animation-timeline: scroll()') en schrijf --drawer-backdrop vanuit een scroll-event-listener als dit niet wordt ondersteund. Het CSS-blok @supports in stap 2 zorgt ervoor dat de keyframes nooit worden toegepast in niet-ondersteunde browsers, zodat de JavaScript-waarde de enige schrijver is.
if (!CSS.supports('animation-timeline: scroll()')) {
scroller.addEventListener('scroll', () => {
// Same mapping as the @keyframes: 0 scroll = 1 (open),
// sheet-width scroll = 0 (closed).
const ratio = 1 - scroller.scrollLeft / sheet.offsetWidth;
drawer.style.setProperty('--drawer-backdrop', ratio);
});
}
Fallback voor eerste scrollpositie (geen ondersteuning voor scroll-initial-target):
Detecteer met CSS.supports('scroll-initial-target', 'nearest') en binnen openDrawer(), scroll de scroller onmiddellijk na showPopover() naar de gesloten positie. Zonder dit zou de lade onmiddellijk in de open positie verschijnen zonder inschuifanimatie.
async function openDrawer() {
drawer.showPopover();
if (!CSS.supports('scroll-initial-target', 'nearest')) {
// Jump-scroll to the closed stop so the scroll below
// animates the drawer in from off-screen.
scroller.scrollTo({left: scroller.offsetWidth, behavior: 'instant'});
// Wait two animation frames for the jump-scroll to commit.
// A single rAF is not enough — the second `scrollTo` would
// cancel the first before the browser has a chance to apply it.
await new Promise((r) =>
requestAnimationFrame(() => requestAnimationFrame(r))
);
}
scroller.scrollTo({left: 0, behavior: 'auto'});
}
@property-reserve (geen geregistreerde aangepaste eigenschappen):
@property is alleen nodig omdat de scrollgestuurde animatie --drawer-backdrop tussen keyframes interpoleert. Zonder registratie wordt de property behandeld als een tekenreeks en springt deze tussen 0 en 1 zonder vervaging. Als de bovenstaande scrollgestuurde animatie-fallback is ingesteld, schrijft dat JavaScript een nieuwe numerieke tekenreeks naar --drawer-backdrop in elk scrollframe en wordt er nooit geïnterpoleerd. Er is dus geen afzonderlijke @property-fallback nodig, omdat alle browsers die scrollgestuurde animaties ondersteunen ook @property ondersteunen.
Popover API-fallback (geen ondersteuning voor het kenmerk popover):
Omdat deze component popover="manual" gebruikt en sluiting volledig implementeert vanuit JavaScript, is deze niet afhankelijk van het gedrag dat de popover-API definieert: licht sluiten, het kenmerk popovertarget, Escape-afhandeling beheerd door de bovenste laag of focusbeheer. De enige popover-functies die het daadwerkelijk gebruikt, zijn promotie van de bovenste laag (via showPopover()) en het pseudo-element ::backdrop.
Als je bredere browserondersteuning nodig hebt, moet je niet vertakken op functieherkenning, maar popover helemaal niet gebruiken. Verwijder het kenmerk popover="manual", vervang de promotie van de bovenste laag door position: fixed en een hoge z-index, vervang ::backdrop door een element op hetzelfde niveau met dezelfde stijl (met dezelfde aangepaste eigenschap --drawer-backdrop) en schakel de zichtbaarheid in via een klasse in plaats van showPopover()/hidePopover(). De rest van de component (scrolluitlijning, de scrollgestuurde achtergrondanimatie, de IntersectionObserver en de sluitingshandlers) is ongewijzigd.