Desplazamiento bidimensional

Cuando un usuario se desplaza por un contenedor de desplazamiento bidimensional, los navegadores suelen aplicar una heurística de bloqueo del eje de desplazamiento (también conocida como "railing"). Si un gesto comienza con un movimiento significativamente mayor a lo largo de un eje que el eje perpendicular, el navegador bloquea el desplazamiento a ese único eje horizontal o vertical y, a menudo, ignora los deltas perpendiculares durante el resto del gesto.

Si bien el bloqueo de ejes evita el desplazamiento lateral accidental en los flujos de lectura unidimensionales, crea fricción en las superficies de desplazamiento bidimensionales, como mapas interactivos, imágenes ampliadas, lienzos infinitos, gráficos de nodos y cuadrículas de datos grandes. Como el bloqueo persiste durante todo el gesto, afecta cualquier desplazamiento que implique dos direcciones, ya sea que ambos ejes se desplacen simultáneamente (desplazamiento diagonal) o uno después del otro dentro de un solo gesto (como rutas curvas, circulares o en forma de L). La propiedad scroll-axis-lock de CSS inhabilita esta heurística directamente en un contenedor de desplazamiento nativo, lo que permite un desplazamiento bidimensional sin restricciones y, al mismo tiempo, conserva el desplazamiento del subproceso del compositor, la física de impulso, las barras de desplazamiento nativas y la navegación con el teclado.

Implementación

1. Inhabilitar el bloqueo de ejes y contener el sobredesplazamiento en el contenedor de desplazamiento bidimensional

Aplica scroll-axis-lock: none directamente al elemento que establece el contenedor de desplazamiento bidimensional (overflow: auto o overflow: scroll). Si bien un contenedor de desplazamiento 2D ya puede actualizar los deltas de desplazamiento en ambos ejes con el valor predeterminado auto, auto permite que se active la heurística de barandilla del navegador cuando un gesto comienza predominantemente en un eje. Si se establece en scroll-axis-lock: none, se evita que se active la barandilla en pantallas táctiles, paneles táctiles y ruedas del mouse.

Vincula scroll-axis-lock: none con overscroll-behavior: none (o overscroll-behavior: contain) en el contenedor de desplazamiento para que, cuando se llegue a un borde, no se encadene el desplazamiento a la página superior ni se activen navegaciones accidentales de sobredesplazamiento del navegador (como la navegación horizontal con deslizamiento hacia atrás o hacia adelante, o el gesto de deslizar hacia abajo para actualizar).

2. Preserva la capacidad de enfoque y la accesibilidad del teclado

Como una superficie de desplazamiento lateral en 2D suele carecer de controles secundarios enfocables (por ejemplo, un mapa o una imagen ampliada), asegúrate de que los usuarios de teclado puedan enfocar el contenedor y desplazarse lateralmente por él con las teclas de flecha. Para ello, agrega tabindex="0", un role adecuado, una etiqueta accesible y un indicador :focus-visible visible.

Código de ejemplo

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

Prácticas recomendadas y restricciones

  • Aplícalo al contenedor de desplazamiento, no a un wrapper superior: scroll-axis-lock no se hereda, por lo que establecerlo en un wrapper no tiene efecto en los contenedores de desplazamiento descendientes.
  • Contén el sobredesplazamiento y evita las navegaciones accidentales: Agrega overscroll-behavior: none o overscroll-behavior: contain al contenedor de desplazamiento en 2D para que el desplazamiento lateral hacia un límite no se encadene a la página superior ni active la navegación con deslizamiento hacia atrás o adelante del navegador ni los gestos de deslizar hacia abajo para actualizar.
  • Los paneles unidimensionales anidados mantienen la barandilla automáticamente: Como scroll-axis-lock no se hereda, una barra lateral o un panel de inspector anidado dentro de .pan-viewport conserva el comportamiento predeterminado de scroll-axis-lock: auto.
  • Mantén auto en feeds verticales, artículos y listas unidimensionales estándar: El bloqueo de ejes evita el desplazamiento perpendicular involuntario en el contenido unidireccional.
  • No configures touch-action: none de forma incondicional: touch-action controla si el navegador controla los gestos táctiles (y no tiene efecto en la entrada del panel táctil o de la rueda), por lo que, si lo configuras, se inhabilita el desplazamiento táctil nativo del compositor. Solo aplícalo dentro de @supports not (scroll-axis-lock: none) cuando implementes el resguardo del puntero de JavaScript.
  • Desplázate con scrollLeft/scrollTop, nunca con CSS transform: Traducir la superficie con transform impide que el elemento funcione como un contenedor de desplazamiento, lo que interrumpe las barras de desplazamiento nativas, la fijación de límites, el desplazamiento con las teclas de flecha del teclado, los eventos scroll, IntersectionObserver y las animaciones basadas en el desplazamiento.

Estrategias de resguardo

Elige entre la mejora progresiva y un resguardo de JavaScript según los requisitos de tu aplicación:

Opción 1: Mejora progresiva (sin JavaScript)

Usa la mejora progresiva cuando preservar el desplazamiento táctil nativo del subproceso del compositor, el impulso de desplazamiento inercial y el gesto de pellizcar para hacer zoom sea más importante que eliminar el bloqueo de ejes en los navegadores no compatibles:

  • Navegadores compatibles: El navegador evita que se active su heurística de bloqueo de eje ("barandilla") en el contenedor de desplazamiento 2D durante los gestos de desplazamiento.
  • Navegadores no compatibles: Los navegadores que no reconocen scroll-axis-lock: none ignoran de forma segura la declaración y mantienen su comportamiento predeterminado de desplazamiento en 2D de auto, es decir, se desplazan por ambos ejes de forma nativa, mientras que siguen limitando los gestos que comienzan predominantemente a lo largo de un solo eje.

Opción 2: Resguardo de JavaScript (wheel + pointermove con scrollLeft/scrollTop)

Si se requiere un desplazamiento bidimensional sin restricciones en todos los navegadores, usa @supports not (scroll-axis-lock: none) y CSS.supports('scroll-axis-lock', 'none') para interceptar de forma condicional los gestos de arrastre del puntero y wheel.

Debido a que wheel (e.deltaX, e.deltaY) y pointermove (e.clientX, e.clientY) exponen diferencias 2D sin procesar y desbloqueadas antes de que el motor de desplazamiento del navegador aplique el bloqueo de ejes, puedes aplicar esas diferencias directamente a scrollLeft y scrollTop:

  • Por qué scrollLeft/scrollTop en lugar de CSS transform: Actualizar scrollLeft y scrollTop mantiene el elemento como un verdadero contenedor de desplazamiento. El navegador fija automáticamente las posiciones de desplazamiento en [0, scrollWidth - clientWidth] y [0, scrollHeight - clientHeight], las barras de desplazamiento nativas permanecen sincronizadas, el desplazamiento con las teclas de flecha del teclado sigue funcionando y los eventos scroll y IntersectionObserver se activan con normalidad.
  • Compensaciones: Interceptar wheel con { passive: false } mueve el desplazamiento al subproceso principal, y configurar touch-action: none en pantallas táctiles inhabilita el impulso de deslizamiento inercial nativo después de pointerup (así como el gesto nativo de pellizcar para hacer zoom).
/* 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);
}