雙向平移

使用者捲動二維捲動容器時,瀏覽器通常會套用捲動軸鎖定啟發式演算法 (也稱為「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 平移,而非 CSS transform:使用 transform 轉譯介面會導致元素無法做為捲動容器,進而破壞原生捲軸、邊界限制、鍵盤方向鍵捲動、scroll 事件、IntersectionObserver 和捲動驅動動畫。

備用策略

根據應用程式的需求,選擇漸進式增強或 JavaScript 備用方案:

方法 1:漸進增強 (不使用 JavaScript)

如果保留原生合成器執行緒觸控捲動、慣性滑動動量和雙指撥動縮放,比在不支援的瀏覽器中消除軸鎖定更重要,請使用漸進增強:

  • 支援的瀏覽器:瀏覽器會防止軸鎖定 (「軌道」) 啟發式演算法在捲動手勢期間,在 2D 捲動容器上啟動。
  • 不支援的瀏覽器:無法辨識 scroll-axis-lock: none 的瀏覽器會安全地忽略宣告,並保留預設的 auto 2D 捲動行為,也就是在兩個軸上原生捲動,同時仍會限制主要沿著單一軸開始的手勢。

選項 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,而非 CSS transform:更新 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);
}