Défilement panoramique bidimensionnel

Fonctionnalités Web : scroll-axis-lock overscroll-behavior

Lorsqu'un utilisateur fait défiler un conteneur de défilement bidimensionnel, les navigateurs appliquent généralement une heuristique de verrouillage de l'axe de défilement (également appelée "railing"). Si un geste commence par un mouvement nettement plus important le long d'un axe que de l'axe perpendiculaire, le navigateur verrouille le défilement sur cet axe horizontal ou vertical unique et ignore les deltas perpendiculaires, souvent pour le reste du geste.

Bien que le verrouillage des axes empêche les dérives latérales accidentelles dans les flux de lecture unidimensionnels, il crée des frictions sur les surfaces de panoramique bidimensionnelles telles que les cartes interactives, les images zoomées, les canevas infinis, les graphiques de nœuds et les grandes grilles de données. Comme le verrouillage persiste tout au long du geste, il affecte tout panoramique impliquant deux directions, que les deux axes soient panoramiques simultanément (panoramique diagonal) ou l'un après l'autre en un seul geste (comme les trajectoires courbes, circulaires ou en forme de L). La propriété CSS scroll-axis-lock désactive cette heuristique directement sur un conteneur de défilement natif, ce qui permet d'effectuer un panoramique bidimensionnel sans restriction tout en préservant le défilement du thread du compositeur, la physique de l'élan, les barres de défilement natives et la navigation au clavier.

Implémentation

1. Désactiver le verrouillage de l'axe et contenir le défilement au-delà des limites sur le conteneur de défilement bidimensionnel

Appliquez scroll-axis-lock: none directement à l'élément qui établit le conteneur de défilement bidimensionnel (overflow: auto ou overflow: scroll). Bien qu'un conteneur de défilement 2D puisse déjà mettre à jour les deltas de défilement le long des deux axes sous la valeur par défaut auto, auto permet à l'heuristique de défilement du navigateur de s'activer lorsqu'un geste commence principalement le long d'un axe. La définition de scroll-axis-lock: none empêche l'activation de ce rail sur les écrans tactiles, les pavés tactiles et les molettes de souris.

Associez scroll-axis-lock: none à overscroll-behavior: none (ou overscroll-behavior: contain) sur le conteneur de défilement afin que le défilement ne soit pas enchaîné à la page parente lorsque vous atteignez un bord, ou que des navigations de défilement hors limites accidentelles du navigateur ne soient pas déclenchées (comme la navigation par balayage horizontal vers l'arrière/l'avant ou le glisser vers le bas pour actualiser).

2. Préserver le focus clavier et l'accessibilité

Comme une surface de panoramique 2D ne dispose souvent pas de commandes enfants sélectionnables (par exemple, une carte ou une image zoomée), assurez-vous que les utilisateurs du clavier peuvent sélectionner le conteneur et effectuer un panoramique à l'aide des touches fléchées en ajoutant tabindex="0", un role approprié et un libellé accessible, ainsi qu'un indicateur :focus-visible visible.

Exemple de code

<!-- Make the 2D scroll container keyboard-focusable and labeled so keyboard
     users can focus the viewport and pan in all directions with Arrow keys. -->
<div class="pan-viewport" tabindex="0" role="region" aria-label="Interactive canvas">
  <div class="pan-surface">
    <!-- 2D overflowing content (map tiles, zoomed image, canvas nodes, or grid) -->
  </div>
</div>
.pan-viewport {
  /* Example viewport dimensions; adjust to fit your layout. */
  inline-size: 100%;
  block-size: 500px;

  /* MANDATORY: Establish a scroll container that can overflow on both axes. */
  overflow: auto;

  /* MANDATORY: Prevent browser scroll axis-locking ("railing") from kicking in
     so 2D gestures never get locked to a single axis. */
  scroll-axis-lock: none;

  /* Contain scrolling and prevent accidental swipe navigations at edges
     (use `none` or `contain`). */
  overscroll-behavior: none;
}

/* Provide a clear focus indicator when keyboard users focus the viewport. */
.pan-viewport:focus-visible {
  outline: 3px solid #005fcc;
  outline-offset: 2px;
}

.pan-surface {
  /* Example surface dimensions larger than .pan-viewport in both axes. */
  inline-size: 3000px;
  block-size: 3000px;
}

Bonnes pratiques et contraintes

  • Appliquez-la au conteneur de défilement lui-même, et non à un wrapper ancêtre : scroll-axis-lock n'est pas hérité. Par conséquent, la définir sur un wrapper n'a aucun effet sur les conteneurs de défilement descendants.
  • Contenir le défilement hors limites et empêcher les navigations accidentelles : ajoutez overscroll-behavior: none ou overscroll-behavior: contain au conteneur de défilement 2D afin que le panoramique dans une limite ne soit pas lié à la page parente, et ne déclenche pas la navigation par balayage vers l'arrière/l'avant du navigateur ni les gestes de glisser vers le bas pour actualiser.
  • Les panneaux unidimensionnels imbriqués conservent automatiquement la barre de navigation : comme scroll-axis-lock n'est pas hérité, une barre latérale ou un panneau d'inspecteur imbriqué dans .pan-viewport conserve le comportement scroll-axis-lock: auto par défaut.
  • Conservez auto sur les flux verticaux standards, les articles et les listes unidimensionnelles : le verrouillage de l'axe empêche toute dérive perpendiculaire involontaire dans le contenu unidirectionnel.
  • Ne définissez pas touch-action: none de manière inconditionnelle : touch-action contrôle si le navigateur gère les gestes tactiles (et n'a aucun effet sur la saisie au pavé tactile ou à la molette). Par conséquent, le définir désactive le défilement tactile natif du compositeur. Appliquez-le uniquement dans @supports not (scroll-axis-lock: none) lorsque vous implémentez le remplacement du pointeur JavaScript.
  • Panoramique avec scrollLeft/scrollTop, jamais avec CSS transform : la translation de la surface avec transform empêche l'élément de fonctionner comme un conteneur de défilement, ce qui interrompt les barres de défilement natives, le serrage des limites, le défilement avec les touches fléchées du clavier, les événements scroll, IntersectionObserver et les animations basées sur le défilement.

Stratégies de remplacement

Choisissez entre l'amélioration progressive et une solution de remplacement JavaScript en fonction des exigences de votre application :

Option 1 : Amélioration progressive (sans JavaScript)

Utilisez l'amélioration progressive lorsque la préservation du défilement tactile natif du thread du compositeur, de l'inertie et du zoom par pincement est plus importante que l'élimination du verrouillage d'axe dans les navigateurs non compatibles :

  • Navigateurs compatibles : le navigateur empêche l'activation de son heuristique de verrouillage d'axe ("railing") sur le conteneur de défilement 2D lors des gestes de défilement.
  • Navigateurs non compatibles : les navigateurs qui ne reconnaissent pas scroll-axis-lock: none ignorent la déclaration en toute sécurité et conservent leur comportement de défilement 2D par défaut (auto), c'est-à-dire qu'ils font défiler les deux axes de manière native, tout en limitant les gestes qui commencent principalement sur un seul axe.

Option 2 : JavaScript de remplacement (wheel + pointermove avec scrollLeft/scrollTop)

Si un panoramique bidimensionnel sans restriction est requis sur tous les navigateurs, utilisez @supports not (scroll-axis-lock: none) et CSS.supports('scroll-axis-lock', 'none') pour intercepter de manière conditionnelle les gestes de wheel et de déplacement du pointeur.

Étant donné que wheel (e.deltaX, e.deltaY) et pointermove (e.clientX, e.clientY) exposent des deltas 2D bruts et déverrouillés avant que le moteur de défilement du navigateur n'applique le verrouillage d'axe, vous pouvez appliquer ces deltas directement à scrollLeft et scrollTop :

  • Pourquoi scrollLeft/scrollTop au lieu de transform en CSS : la mise à jour de scrollLeft et scrollTop permet de conserver l'élément en tant que conteneur de défilement réel. Le navigateur limite automatiquement les positions de défilement à [0, scrollWidth - clientWidth] et [0, scrollHeight - clientHeight], les barres de défilement natives restent synchronisées, le défilement à l'aide des touches fléchées du clavier continue de fonctionner, et les événements scroll et IntersectionObserver se déclenchent normalement.
  • Inconvénients : l'interception de wheel avec { passive: false } déplace le défilement vers le thread principal, et la définition de touch-action: none sur les écrans tactiles désactive l'inertie native après pointerup (ainsi que le zoom par pincement natif).
/* Only disable native touch scrolling when scroll-axis-lock is unsupported
   and the JavaScript pointer fallback below is active. */
@supports not (scroll-axis-lock: none) {
  .pan-viewport {
    touch-action: none;
  }
}
// Only attach the main-thread gesture fallback when native support is missing
if (!CSS.supports('scroll-axis-lock', 'none')) {
  const viewport = document.querySelector('.pan-viewport');
  let lastPointer = null;

  // 1. Trackpad and mouse wheel fallback
  viewport.addEventListener(
    'wheel',
    (e) => {
      e.preventDefault();
      viewport.scrollLeft += e.deltaX;
      viewport.scrollTop += e.deltaY;
    },
    { passive: false },
  );

  // 2. Touch and pointer drag fallback
  viewport.addEventListener('pointerdown', (e) => {
    // Ignore non-primary buttons and clicks on native scrollbars
    const rect = viewport.getBoundingClientRect();
    if (
      !e.isPrimary ||
      e.button !== 0 ||
      e.clientX - rect.left - viewport.clientLeft >= viewport.clientWidth ||
      e.clientY - rect.top - viewport.clientTop >= viewport.clientHeight
    ) {
      return;
    }
    lastPointer = { x: e.clientX, y: e.clientY };
    viewport.setPointerCapture(e.pointerId);
  });

  viewport.addEventListener('pointermove', (e) => {
    if (!lastPointer) return;
    viewport.scrollLeft -= e.clientX - lastPointer.x;
    viewport.scrollTop -= e.clientY - lastPointer.y;
    lastPointer = { x: e.clientX, y: e.clientY };
  });

  const endPointerPan = () => {
    lastPointer = null;
  };
  viewport.addEventListener('pointerup', endPointerPan);
  viewport.addEventListener('pointercancel', endPointerPan);
}