使用者捲動二維捲動容器時,瀏覽器通常會套用捲動軸鎖定啟發式演算法 (也稱為「railing」)。如果手勢一開始沿著某個軸的移動量明顯大於垂直軸,瀏覽器就會將捲動鎖定在該水平或垂直軸,並忽略垂直差異,通常在手勢的其餘部分也是如此。
雖然軸鎖定功能可防止一維閱讀流程中意外橫向漂移,但也會在二維平移介面上造成摩擦,例如互動式地圖、縮放圖片、無限畫布、節點圖和大型資料網格。由於鎖定狀態會持續到手勢結束,因此會影響任何涉及兩個方向的平移操作,無論是同時沿著兩個軸平移 (對角線平移),還是在單一手勢中先後沿著兩個軸平移 (例如曲線、圓形或 L 形路徑)。CSS scroll-axis-lock 屬性會直接在原生捲動容器上停用這項經驗法則,允許不受限制的二維平移,同時保留合成器執行緒捲動、動量物理、原生捲軸和鍵盤導覽。
導入作業
1. 停用軸鎖定,並在二維捲動容器中包含過度捲動
直接將 scroll-axis-lock: none 套用至建立二維捲動容器 (overflow: auto 或 overflow: scroll) 的元素。雖然 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不會繼承,因此在包裝函式上設定不會影響子系捲動容器。 - 限制過度捲動並防止意外導覽:將
overscroll-behavior: none或overscroll-behavior: contain新增至 2D 捲動容器,這樣一來,平移至邊界時就不會鏈結至父項頁面,也不會觸發瀏覽器返回/前進滑動導覽和下拉更新手勢。 - 巢狀一維面板會自動保留欄位:由於
scroll-axis-lock不會繼承,因此巢狀在.pan-viewport內的側欄或檢查器面板會保留預設的scroll-axis-lock: auto行為。 - 在標準直向動態饋給、文章和一維清單中,保持
auto:軸鎖定功能可防止單向內容意外垂直偏移。 - 請勿無條件設定
touch-action: none:touch-action控制瀏覽器是否處理觸控手勢 (對觸控板或滾輪輸入沒有影響),因此設定後會停用原生合成器觸控捲動。實作 JavaScript 指標備用方案時,請只在@supports not (scroll-axis-lock: none)內套用。 - 使用
scrollLeft/scrollTop平移,而非 CSStransform:使用transform轉譯介面會導致元素無法做為捲動容器,進而破壞原生捲軸、邊界限制、鍵盤方向鍵捲動、scroll事件、IntersectionObserver和捲動驅動動畫。
備用策略
根據應用程式的需求,選擇漸進式增強或 JavaScript 備用方案:
方法 1:漸進增強 (不使用 JavaScript)
如果保留原生合成器執行緒觸控捲動、慣性滑動動量和雙指撥動縮放,比在不支援的瀏覽器中消除軸鎖定更重要,請使用漸進增強:
- 支援的瀏覽器:瀏覽器會防止軸鎖定 (「軌道」) 啟發式演算法在捲動手勢期間,在 2D 捲動容器上啟動。
- 不支援的瀏覽器:無法辨識
scroll-axis-lock: none的瀏覽器會安全地忽略宣告,並保留預設的auto2D 捲動行為,也就是在兩個軸上原生捲動,同時仍會限制主要沿著單一軸開始的手勢。
選項 2:JavaScript 備用廣告 (wheel + pointermove,搭配 scrollLeft/scrollTop)
如果需要在所有瀏覽器中不受限制地進行二維平移,請使用 @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:
- 為何使用
scrollLeft/scrollTop,而非 CSStransform:更新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);
}