사용자가 2차원 스크롤 컨테이너를 스크롤하면 브라우저는 일반적으로 스크롤 축 잠금 휴리스틱('레일링'이라고도 함)을 적용합니다. 동작이 수직 축보다 한 축을 따라 훨씬 더 많이 움직이는 것으로 시작되면 브라우저는 스크롤을 해당 단일 가로 또는 세로 축으로 잠그고 수직 델타를 무시합니다(동작의 나머지 부분에서 자주 발생).
축 잠금은 1차원 읽기 흐름에서 의도치 않은 측면 이동을 방지하지만, 대화형 지도, 확대된 이미지, 무한 캔버스, 노드 그래프, 대규모 데이터 그리드와 같은 2차원 화면 이동 표면에서는 마찰을 일으킵니다. 잠금은 동작 전반에 걸쳐 유지되므로 두 방향이 포함된 모든 화면 이동에 영향을 미칩니다. 두 축이 동시에 화면 이동되거나 (대각선 화면 이동) 단일 동작 내에서 하나씩 화면 이동되는 경우 (예: 곡선, 원형 또는 L자형 경로) 모두 해당됩니다. CSS scroll-axis-lock 속성은 네이티브 스크롤 컨테이너에서 이 휴리스틱을 직접 사용 중지하여 컴포지터 스레드 스크롤, 모멘텀 물리, 네이티브 스크롤바, 키보드 탐색을 유지하면서 제한 없는 2차원 화면 이동을 허용합니다.
구현
1. 2차원 스크롤 컨테이너에서 축 잠금을 사용 중지하고 오버스크롤을 포함합니다.
2차원 스크롤 컨테이너 (overflow: auto 또는 overflow: scroll)를 설정하는 요소에 scroll-axis-lock: none를 직접 적용합니다. 2D 스크롤 컨테이너는 기본 auto 값에서 이미 두 축을 따라 스크롤 델타를 업데이트할 수 있지만, auto를 사용하면 제스처가 주로 한 축을 따라 시작될 때 브라우저의 레일링 휴리스틱이 시작될 수 있습니다. scroll-axis-lock: none를 설정하면 터치스크린, 트랙패드, 마우스 휠에서 레일링이 작동하지 않습니다.
스크롤 컨테이너에서 scroll-axis-lock: none를 overscroll-behavior: none (또는 overscroll-behavior: contain)와 페어링하여 가장자리에 도달해도 상위 페이지로 스크롤이 연결되거나 실수로 브라우저 오버스크롤 탐색 (예: 가로 뒤로/앞으로 스와이프 탐색 또는 당겨서 새로고침)이 트리거되지 않도록 합니다.
2. 키보드 포커스 가능성 및 접근성 유지
2D 화면 이동 영역에는 포커스 가능한 하위 컨트롤 (예: 지도 또는 확대된 이미지)이 없는 경우가 많으므로 키보드 사용자가 컨테이너에 포커스를 맞추고 화살표 키로 화면을 이동할 수 있도록 tabindex="0", 적절한 role 및 액세스 가능한 라벨, 표시되는 :focus-visible 표시기를 추가하세요.
예시 코드
<!-- 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;
}
권장사항 및 제약 조건
- 상위 래퍼가 아닌 스크롤 컨테이너 자체에 적용:
scroll-axis-lock는 상속되지 않으므로 래퍼에 설정해도 하위 스크롤 컨테이너에는 영향을 미치지 않습니다. - 오버스크롤 포함 및 의도하지 않은 탐색 방지: 2D 스크롤 컨테이너에
overscroll-behavior: none또는overscroll-behavior: contain를 추가하여 경계로 패닝해도 상위 페이지에 연결되거나 브라우저 뒤로/앞으로 스와이프 탐색 및 당겨서 새로고침 동작이 트리거되지 않도록 합니다. - 중첩된 1차원 패널은 자동으로 레일링을 유지함:
scroll-axis-lock는 상속되지 않으므로.pan-viewport내에 중첩된 사이드바 또는 검사기 패널은 기본scroll-axis-lock: auto동작을 유지합니다. - 표준 세로 피드, 기사, 1차원 목록에서
auto유지: 축 잠금은 단방향 콘텐츠에서 의도하지 않은 수직 이동을 방지합니다. - 무조건
touch-action: none를 설정하지 마세요:touch-action는 브라우저가 터치 동작을 처리하는지 여부를 제어하므로 (트랙패드 또는 휠 입력에는 영향을 미치지 않음) 이를 설정하면 네이티브 컴포지터 터치 스크롤이 사용 중지됩니다. 자바스크립트 포인터 대체 기능을 구현할 때@supports not (scroll-axis-lock: none)내부에만 적용합니다. - CSS
transform가 아닌scrollLeft/scrollTop로 화면 이동:transform로 표면을 변환하면 요소가 스크롤 컨테이너로 작동하지 않아 네이티브 스크롤바, 경계 고정, 키보드 화살표 키 스크롤,scroll이벤트,IntersectionObserver, 스크롤 기반 애니메이션이 중단됩니다.
대체 전략
애플리케이션의 요구사항에 따라 점진적 개선과 JavaScript 대체 중에서 선택합니다.
옵션 1: 점진적 개선 (JavaScript 없음)
지원되지 않는 브라우저에서 축 잠금을 제거하는 것보다 네이티브 compositor-thread 터치 스크롤, 관성 플링 모멘텀, 핀치 투 줌을 유지하는 것이 더 중요한 경우 점진적 개선을 사용합니다.
- 지원 브라우저: 브라우저는 스크롤 동작 중에 2D 스크롤 컨테이너에서 축 잠금 ('레일링') 휴리스틱이 시작되지 않도록 합니다.
- 지원되지 않는 브라우저:
scroll-axis-lock: none를 인식하지 않는 브라우저는 선언을 안전하게 무시하고 기본auto2D 스크롤 동작을 유지합니다. 즉, 기본적으로 두 축을 가로질러 스크롤하면서도 주로 단일 축을 따라 시작되는 동작을 레일링합니다.
옵션 2: JavaScript 대체 (wheel + pointermove, scrollLeft/scrollTop 사용)
모든 브라우저에서 제한 없는 2차원 화면 이동이 필요한 경우 @supports not (scroll-axis-lock: none) 및 CSS.supports('scroll-axis-lock', 'none')를 사용하여 wheel 및 포인터 드래그 동작을 조건부로 가로챕니다.
wheel (e.deltaX, e.deltaY) 및 pointermove (e.clientX, e.clientY)는 브라우저의 스크롤 엔진이 축 잠금을 적용하기 전에 잠금 해제된 원시 2D 델타를 노출하므로 이러한 델타를 scrollLeft 및 scrollTop에 직접 적용할 수 있습니다.
- CSS
transform대신scrollLeft/scrollTop를 사용하는 이유:scrollLeft및scrollTop를 업데이트하면 요소가 실제 스크롤 컨테이너로 유지됩니다. 브라우저는 스크롤 위치를[0, scrollWidth - clientWidth]및[0, scrollHeight - clientHeight]로 자동 고정하고, 네이티브 스크롤바는 동기화된 상태를 유지하며, 키보드 화살표 키 스크롤은 계속 작동하고,scroll이벤트와IntersectionObserver는 정상적으로 실행됩니다. - 장단점:
{ passive: false }로wheel를 가로채면 스크롤이 기본 스레드로 이동하고 터치스크린에서touch-action: none를 설정하면pointerup이후에 네이티브 관성 플링 모멘텀(및 네이티브 핀치 투 줌)이 사용 중지됩니다.
/* 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);
}