Quando um usuário rola um contêiner de rolagem bidimensional, os navegadores geralmente aplicam uma heurística de bloqueio de eixo de rolagem (também conhecida como "railing"). Se um gesto começar com um movimento significativamente maior em um eixo do que no eixo perpendicular, o navegador vai bloquear a rolagem para esse único eixo horizontal ou vertical e ignorar os deltas perpendiculares, geralmente pelo restante do gesto.
Embora o bloqueio de eixo evite o desvio lateral acidental em fluxos de leitura unidimensionais, ele cria atrito em superfícies de movimentação bidimensionais, como mapas interativos, imagens ampliadas, telas infinitas, gráficos de nós e grandes grades de dados. Como o bloqueio persiste durante o gesto, ele afeta qualquer movimento que envolva duas direções, seja em ambos os eixos simultaneamente (movimento diagonal) ou um após o outro em um único gesto (como caminhos curvos, circulares ou em forma de L). A propriedade CSS scroll-axis-lock desativa essa heurística diretamente em um contêiner de rolagem nativo, permitindo uma movimentação bidimensional irrestrita e preservando a rolagem da linha de execução do compositor, a física de impulso, as barras de rolagem nativas e a navegação pelo teclado.
Implementação
1. Desativar o bloqueio de eixo e conter a rolagem esticada no contêiner de rolagem bidimensional
Aplique scroll-axis-lock: none diretamente ao elemento que estabelece o contêiner de rolagem bidimensional (overflow: auto ou overflow: scroll). Embora um contêiner de rolagem 2D já possa atualizar os deltas de rolagem nos dois eixos com o valor padrão auto, auto permite que a heurística de grade do navegador seja ativada quando um gesto começa predominantemente em um eixo. Definir scroll-axis-lock: none impede que essa restrição seja aplicada em telas touch, trackpads e rodas do mouse.
Combine scroll-axis-lock: none com overscroll-behavior: none (ou overscroll-behavior: contain) no contêiner de rolagem para que, ao chegar a uma borda, a rolagem não seja encadeada para a página mãe nem acione navegações acidentais de rolagem esticada do navegador (como navegação horizontal de deslizar para trás/frente ou arrastar para baixo para atualizar).
2. Preservar a acessibilidade e o foco do teclado
Como uma superfície de movimentação 2D geralmente não tem controles filhos focalizáveis (por exemplo, um mapa ou uma imagem ampliada), adicione tabindex="0", um role adequado, um rótulo acessível e um indicador :focus-visible visível para garantir que os usuários de teclado possam focar o contêiner e movê-lo com as teclas de seta.
Exemplo de código
<!-- 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áticas recomendadas e restrições
- Aplique-a ao próprio contêiner de rolagem, não a um wrapper ancestral:
scroll-axis-locknão é herdada, então defini-la em um wrapper não afeta os contêineres de rolagem descendentes. - Contenha a rolagem esticada e evite navegações acidentais: adicione
overscroll-behavior: noneouoverscroll-behavior: containao contêiner de rolagem 2D para que a movimentação em um limite não se encadeie à página mãe nem acione a navegação de deslizar para frente/trás do navegador e os gestos de arrastar para baixo para atualizar. - Os painéis unidimensionais aninhados mantêm o bloqueio automaticamente: como
scroll-axis-locknão herda, uma barra lateral ou um painel de inspeção aninhado em.pan-viewportmantém o comportamento padrão descroll-axis-lock: auto. - Mantenha o
autoem feeds verticais padrão, artigos e listas unidimensionais: o bloqueio de eixo evita desvios perpendiculares não intencionais em conteúdo unidirecional. - Não defina
touch-action: noneincondicionalmente:touch-actioncontrola se o navegador processa gestos de toque (e não tem efeito na entrada do trackpad ou da roda). Portanto, defini-lo desativa a rolagem por toque do compositor nativo. Aplique-o apenas dentro de@supports not (scroll-axis-lock: none)ao implementar o substituto do ponteiro JavaScript. - Mova com
scrollLeft/scrollTop, nunca com o CSStransform: mover a superfície comtransformimpede que o elemento funcione como um contêiner de rolagem, o que quebra as barras de rolagem nativas, a fixação de limites, a rolagem com as teclas de seta do teclado, os eventosscroll,IntersectionObservere as animações de rolagem.
Estratégias de fallback
Escolha entre o aprimoramento progressivo e um substituto do JavaScript com base nos requisitos do seu aplicativo:
Opção 1: aprimoramento progressivo (sem JavaScript)
Use o aprimoramento progressivo quando preservar a rolagem por toque nativa da linha de execução do compositor, o movimento de rolagem inercial e o gesto de pinça para dar zoom for mais importante do que eliminar o bloqueio de eixo em navegadores sem suporte:
- Navegadores compatíveis: o navegador impede que a heurística de bloqueio de eixo ("railing") seja ativada no contêiner de rolagem 2D durante gestos de rolagem.
- Navegadores sem suporte: os navegadores que não reconhecem
scroll-axis-lock: noneignoram a declaração com segurança e mantêm o comportamento de rolagem 2D padrão deauto, ou seja, rolam pelos dois eixos de forma nativa, mas ainda limitam os gestos que começam predominantemente em um único eixo.
Opção 2: substituto do JavaScript (wheel + pointermove com scrollLeft/scrollTop)
Se for necessário usar a movimentação bidimensional irrestrita em todos os navegadores, use @supports not (scroll-axis-lock: none) e CSS.supports('scroll-axis-lock', 'none') para interceptar condicionalmente wheel e gestos de arrastar do ponteiro.
Como wheel (e.deltaX, e.deltaY) e pointermove (e.clientX, e.clientY) expõem deltas 2D brutos e desbloqueados antes que o mecanismo de rolagem do navegador aplique o bloqueio de eixo, é possível aplicar esses deltas diretamente a scrollLeft e scrollTop:
- Por que usar
scrollLeft/scrollTopem vez de CSStransform: atualizarscrollLeftescrollTopmantém o elemento como um contêiner de rolagem verdadeiro. O navegador fixa automaticamente as posições de rolagem em[0, scrollWidth - clientWidth]e[0, scrollHeight - clientHeight], as barras de rolagem nativas permanecem sincronizadas, a rolagem com as teclas de seta do teclado continua funcionando e os eventosscrolleIntersectionObserversão acionados normalmente. - Compensações: interceptar
wheelcom{ passive: false }move a rolagem para a linha de execução principal, e definirtouch-action: noneem touchscreens desativa o momento de rolagem inercial nativo apóspointerup(assim como o gesto nativo de pinça para dar 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);
}