Navigationsleiste

Übersicht

Eine Navigationsleiste ist ein Bereich, der vom Rand des Darstellungsbereichs über den Seiteninhalt eingeblendet wird und alles dahinter abdunkelt. Er wird über eine Trigger-Schaltfläche geöffnet und kann durch Wischen des Bereichs vom Bildschirm, Tippen auf den abgedunkelten Hintergrund oder Drücken der Esc-Taste geschlossen werden.

In diesem Leitfaden wird die Seitenleiste so implementiert:

  • Ein popover="manual"-Element, das in die oberste Ebene verschoben wurde, sodass das Steuerfeld und sein ::backdrop alle anderen Elemente auf der Seite überlagern, unabhängig vom Stapelkontext.
  • Ein horizontal scrollender Container mit zwei CSS-Scroll-Snap-Stopps – einer für „offen“, einer für „geschlossen“ –, sodass die Wischgeste nativ vom Browser verarbeitet wird. So werden Schwung, Geschwindigkeit und Unterbrechungen kostenlos erfasst, ohne dass JavaScript-Pointer-Event-Code erforderlich ist.
  • Eine scrollgesteuerte Animation, die die Deckkraft des Hintergrunds an die Scrollposition bindet, sodass die Abdunklung flüssig ein- und ausgeblendet wird, wenn der Nutzer das Steuerfeld zieht.
  • Ein IntersectionObserver im Steuerfeld, der erkennt, wenn es vollständig in den Darstellungsbereich eingetreten ist oder ihn vollständig verlassen hat, und diese Momente nutzt, um den Fokus, aria-expanded und inert zu aktualisieren.

Dieser Ansatz wird gegenüber JavaScript-gesteuerten transform-Animationen bevorzugt, da der Scrollmechanismus dem Nutzer die direkte Kontrolle über die Position des Bereichs gibt (er wird mit dem Finger gesteuert, nicht mit einem Tween) und er den Interaktionsmustern, die Nutzer von nativen mobilen Apps gewohnt sind, viel besser entspricht.

Implementierung

1. Markieren & Zeichnen

Die Leiste ist ein einzelnes Pop-over mit einem horizontalen Scroller, der das sichtbare „Blatt“ enthält. Der Trigger-Button befindet sich im Seiteninhalt.

<!-- 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. Stile

Popover zurücksetzen und Darstellungsbereich füllen

Das Popover muss den gesamten Darstellungsbereich abdecken, damit der ::backdrop die gesamte Seite abdunkelt und die Wischfläche von Rand zu Rand reicht. Die standardmäßigen Popover-Stile des User-Agents (zentriert, automatisch dimensioniert, mit Rahmen) sind im Weg und müssen zurückgesetzt werden.

.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);
}

Wischoberfläche mit Scroll Snap erstellen

Der Scroller ist ein horizontales Raster, das breiter als der Darstellungsbereich ist: Spalte 1 enthält das Sheet (Breite --drawer-width), Spalte 2 ist ein leerer Pseudoelement-Abstandshalter mit der Breite des Darstellungsbereichs. Durch das Einrasten zwischen den beiden Spalten wird die Schublade geöffnet und geschlossen.

.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;
}

Deckkraft des Hintergrunds an die Scrollposition binden

Eine scrollgesteuerte Animation ordnet --drawer-backdrop von 1 (offen) bis 0 (geschlossen) über den Bereich des Scrollers zu, sodass der Hintergrund perfekt synchron mit dem Ziehen ein- und ausgeblendet wird.

/* 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. Leiste öffnen und schließen

Das Öffnen erfolgt in zwei Schritten: Das Popover wird auf die oberste Ebene verschoben und dann wird das Sheet in die Ansicht gescrollt. Das Schließen erfolgt in einem Schritt: Scrollen Sie zurück zum Abstandshalter. Ein Beobachter (Schritt 4) blendet das Popover aus, sobald das Sheet vollständig vom Bildschirm verschwunden ist.

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. Offenen und geschlossenen Zustand erkennen

Verwenden Sie einen IntersectionObserver auf dem Sheet – nicht die Scrollposition – als Source of Truth für den Status des Drawers. Der Beobachter wird unabhängig davon ausgelöst, wie das Sheet bewegt wurde (Wischen durch den Nutzer, programmatisches Scrollen, Einrasten). Alle Schließpfade laufen also im selben Callback zusammen.

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. Trigger- und Schließ-Handler einrichten

// 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();
});

Fallback-Strategien

Die Popover API, die scrollgesteuerte Animation, die den Hintergrund ausblendet, und scroll-initial-target sind progressive Verbesserungen mit einfachen Fallbacks, die sich leicht implementieren lassen, wenn eine breite Browserunterstützung erforderlich ist.

Fallback für das Ausblenden des Hintergrunds (keine animation-timeline-Unterstützung):

Mit CSS.supports('animation-timeline: scroll()') erkennen und --drawer-backdrop über einen scroll-Ereignis-Listener schreiben, wenn nicht unterstützt. Der CSS-Block @supports in Schritt 2 sorgt dafür, dass die Keyframes in nicht unterstützten Browsern nie angewendet werden. Der JavaScript-Wert ist also der einzige Writer.

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 für die anfängliche Scrollposition (keine scroll-initial-target-Unterstützung):

Erkenne mit CSS.supports('scroll-initial-target', 'nearest') und scrolle innerhalb von openDrawer() sofort nach showPopover() zur geschlossenen Position. Ohne diese Angabe würde die Navigationsleiste sofort in der geöffneten Position angezeigt, ohne dass eine Einblendanimation zu sehen wäre.

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-Fallback (keine registrierten benutzerdefinierten Eigenschaften):

@property ist nur erforderlich, weil die scrollgesteuerte Animation --drawer-backdrop zwischen Keyframes interpoliert. Ohne Registrierung würde die Property als String behandelt und ohne Überblendung zwischen 0 und 1 springen. Wenn der oben beschriebene Fallback für scrollgesteuerte Animationen vorhanden ist, schreibt dieses JavaScript bei jedem Scroll-Frame einen neuen numerischen String in --drawer-backdrop und interpoliert nie. Daher ist kein separater @property-Fallback erforderlich, da alle Browser, die scrollgesteuerte Animationen unterstützen, auch @property unterstützen.

Fallback für Popover API (keine Unterstützung des Attributs popover):

Da diese Komponente popover="manual" verwendet und das Schließen vollständig über JavaScript implementiert, ist sie nicht von den definierenden Verhaltensweisen der Popover API abhängig – Light Dismiss, das Attribut popovertarget, die Verarbeitung von Escape-Tasten auf oberster Ebene oder die Fokusverwaltung. Die einzigen Popover-Funktionen, die tatsächlich verwendet werden, sind die Top-Layer-Promotion (über showPopover()) und das Pseudoelement ::backdrop.

Wenn eine breitere Browserunterstützung erforderlich ist, sollten Sie nicht auf die Feature-Erkennung verzweigen, sondern Popover einfach gar nicht verwenden. Lassen Sie das Attribut popover="manual" weg, ersetzen Sie die Hochstufung auf die oberste Ebene durch position: fixed und einen hohen z-index-Wert, ersetzen Sie ::backdrop durch ein gleich gestaltetes gleichgeordnetes Element (mit derselben benutzerdefinierten Eigenschaft --drawer-backdrop) und schalten Sie die Sichtbarkeit über eine Klasse statt über showPopover()/hidePopover() um. Der Rest der Komponente (Scroll Snap, die scrollgesteuerte Hintergrundanimation, IntersectionObserver und die Schließen-Handler) bleibt unverändert.