Panneau de navigation

Présentation

Un panneau de navigation est un panneau qui glisse depuis le bord de la fenêtre d'affichage sur le contenu de la page, en assombrissant tout ce qui se trouve derrière. Il s'ouvre à l'aide d'un bouton de déclenchement et se ferme en balayant le panneau hors de l'écran, en appuyant sur l'arrière-plan grisé ou en appuyant sur Échap.

Ce guide implémente le panneau comme suit :

  • Un élément popover="manual" promu au niveau de la couche supérieure afin que le panneau et son ::backdrop se superposent à tous les autres éléments de la page, quel que soit le contexte d'empilement.
  • Un conteneur à défilement horizontal avec deux points d'arrêt CSS scroll-snap (un pour l'état "ouvert" et un pour l'état "fermé"), de sorte que le geste de balayage soit géré de manière native par le navigateur. Cela permet de suivre l'élan, la vitesse et l'interruption sans frais, sans code d'événement de pointeur JavaScript.
  • Une animation basée sur le défilement qui lie l'opacité du fond à la position de défilement, de sorte que l'assombrissement s'estompe progressivement lorsque l'utilisateur fait glisser le panneau.
  • Un IntersectionObserver sur le panneau qui détecte quand il est complètement entré ou sorti de la fenêtre d'affichage, et utilise ces moments pour mettre à jour le focus, aria-expanded et inert.

Cette approche est préférable aux animations transform basées sur JavaScript, car le mécanisme de défilement permet à l'utilisateur de contrôler directement la position du panneau (son doigt le contrôle, et non un tween) et correspond beaucoup plus aux modèles d'interaction auxquels les utilisateurs sont habitués dans les applications mobiles natives.

Implémentation

1. Annoter

Le panneau est un popover unique contenant un défilement horizontal, qui contient la "feuille" visible. Le bouton de déclenchement se trouve dans le contenu de la page.

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

Réinitialiser le popover et remplir la fenêtre d'affichage

Le popover doit couvrir l'intégralité de la fenêtre d'affichage afin que son ::backdrop assombrisse toute la page et que la surface de balayage s'étende d'un bord à l'autre. Les styles de popover par défaut de l'user-agent (centré, taille automatique, bordure) sont gênants et doivent être réinitialisés.

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

Créer la surface de balayage avec le défilement scroll-snap

Le défilement est une grille horizontale plus large que la fenêtre d'affichage : la colonne 1 contient la feuille (largeur --drawer-width), la colonne 2 est un espaceur de pseudo-élément vide de la largeur de la fenêtre d'affichage. L'ancrage entre les deux colonnes permet d'ouvrir et de fermer le panneau.

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

Lier l'opacité de l'arrière-plan à la position de défilement

Une animation basée sur le défilement mappe --drawer-backdrop de 1 (ouvert) à 0 (fermé) sur la plage du défilement, de sorte que l'arrière-plan s'affiche et disparaît en parfaite synchronisation avec le déplacement.

/* 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. Ouvrir et fermer le panneau

L'ouverture se fait en deux étapes : promouvoir le popover vers la couche supérieure, puis faire défiler la feuille pour l'afficher. La fermeture se fait en une seule étape : faites défiler l'écran jusqu'à l'espaceur. Un observateur (étape 4) masque le popover une fois que la feuille est complètement hors de l'écran.

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. Détecter l'état ouvert et fermé

Utilisez un IntersectionObserver sur la feuille (et non la position de défilement) comme source de vérité pour l'état du panneau. L'observateur se déclenche quelle que soit la façon dont la feuille a été déplacée (balayage de l'utilisateur, défilement programmatique, défilement scroll-snap). Tous les chemins de fermeture convergent donc vers le même rappel.

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. Connecter les gestionnaires de déclenchement et de fermeture

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

Stratégies de remplacement

L'API Popover, l'animation basée sur le défilement qui estompe l'arrière-plan et scroll-initial-target sont des améliorations progressives avec des solutions de remplacement simples qui peuvent être facilement implémentées si une compatibilité étendue avec les navigateurs est requise.

Fondu de l'arrière-plan de remplacement (pas de compatibilité avec animation-timeline) :

Détectez avec CSS.supports('animation-timeline: scroll()') et écrivez --drawer-backdrop à partir d'un écouteur d'événements scroll si l'option n'est pas compatible. Le bloc CSS @supports de l'étape 2 garantit que les images clés ne sont jamais appliquées dans les navigateurs non compatibles. La valeur JavaScript est donc la seule à être écrite.

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

Solution de secours pour la position de défilement initiale (pas de compatibilité avec scroll-initial-target) :

Détectez avec CSS.supports('scroll-initial-target', 'nearest') et, dans openDrawer(), faites défiler rapidement le défileur jusqu'à la position fermée immédiatement après showPopover(). Sans cela, le panneau s'afficherait instantanément en position ouverte, sans animation de glissement.

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 de remplacement (aucune propriété personnalisée enregistrée) :

@property n'est nécessaire que parce que l'animation basée sur le défilement interpole --drawer-backdrop entre les images clés. Sans enregistrement, la propriété serait traitée comme une chaîne et passerait de 0 à 1 sans fondu. Si la solution de remplacement de l'animation basée sur le défilement ci-dessus est en place, ce code JavaScript écrit une nouvelle chaîne numérique dans --drawer-backdrop à chaque frame de défilement et n'effectue jamais d'interpolation. Par conséquent, aucune solution de remplacement @property distincte n'est nécessaire, car tous les navigateurs compatibles avec les animations basées sur le défilement sont également compatibles avec @property.

Solution de secours pour l'API Popover (pas de prise en charge de l'attribut popover) :

Comme ce composant utilise popover="manual" et implémente la fermeture entièrement en JavaScript, il ne dépend pas des comportements définis par l'API Popover : fermeture légère, attribut popovertarget, gestion de la touche Échap gérée par la couche supérieure ou gestion du curseur. Les seules fonctionnalités de popover qu'il utilise réellement sont la promotion de la couche supérieure (via showPopover()) et le pseudo-élément ::backdrop.

Si vous avez besoin d'une compatibilité plus étendue avec les navigateurs, n'utilisez pas la détection de fonctionnalités. N'utilisez tout simplement pas l'attribut popover. Supprimez l'attribut popover="manual", remplacez la promotion de la couche supérieure par position: fixed et un z-index élevé, remplacez ::backdrop par un élément frère stylisé de manière identique (en utilisant la même propriété personnalisée --drawer-backdrop) et activez/désactivez la visibilité à partir d'une classe au lieu de showPopover()/hidePopover(). Le reste du composant (défilement scroll-snap, animation d'arrière-plan basée sur le défilement, IntersectionObserver et gestionnaires de fermeture) reste inchangé.