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-lockn'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: noneouoverscroll-behavior: containau 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-lockn'est pas hérité, une barre latérale ou un panneau d'inspecteur imbriqué dans.pan-viewportconserve le comportementscroll-axis-lock: autopar défaut. - Conservez
autosur 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: nonede manière inconditionnelle :touch-actioncontrô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 CSStransform: la translation de la surface avectransformempê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énementsscroll,IntersectionObserveret 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: noneignorent 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/scrollTopau lieu detransformen CSS : la mise à jour descrollLeftetscrollToppermet 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énementsscrolletIntersectionObserverse déclenchent normalement. - Inconvénients : l'interception de
wheelavec{ passive: false }déplace le défilement vers le thread principal, et la définition detouch-action: nonesur les écrans tactiles désactive l'inertie native aprèspointerup(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);
}