2 次元パン

ユーザーが 2 次元のスクロール コンテナをスクロールすると、ブラウザは通常、スクロール軸ロックのヒューリスティック(「レール」とも呼ばれます)を適用します。ジェスチャーが、垂直軸よりも 1 つの軸に沿って大幅に移動することから始まる場合、ブラウザはスクロールをその単一の水平軸または垂直軸にロックし、垂直方向のデルタを無視します。これは、ジェスチャーの残りの部分でも同様です。

軸ロックは、1 次元の読み取りフローで誤って横方向に移動するのを防ぐことができますが、インタラクティブな地図、ズームされた画像、無限キャンバス、ノードグラフ、大規模なデータグリッドなど、2 次元のパン操作を行うサーフェスでは摩擦が生じます。ロックはジェスチャー全体で維持されるため、2 つの方向を含むパンに影響します。これは、両方の軸が同時にパンされる場合(斜めのパン)でも、1 つのジェスチャー内で 1 つずつパンされる場合(曲線、円、L 字型のパスなど)でも同様です。CSS の scroll-axis-lock プロパティは、ネイティブ スクロール コンテナでこのヒューリスティックを直接無効にします。これにより、コンポジタ スレッド スクロール、慣性物理、ネイティブ スクロールバー、キーボード ナビゲーションを維持しながら、制限のない 2 次元パンが可能になります。

実装

1. 2 次元スクロール コンテナで軸ロックを無効にし、オーバースクロールを抑制する

scroll-axis-lock: none は、2 次元スクロール コンテナ(overflow: auto または overflow: scroll)を確立する要素に直接適用します。2D スクロール コンテナは、デフォルトの auto 値で両方の軸に沿ってスクロール デルタを更新できますが、auto を使用すると、ジェスチャーが主に 1 つの軸に沿って開始されたときに、ブラウザのレール ヒューリスティックが有効になります。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 は、ブラウザがタッチ操作を処理するかどうかを制御します(トラックパッドやホイール入力には影響しません)。そのため、これを設定すると、ネイティブ コンポジタのタッチ スクロールが無効になります。JavaScript ポインタ フォールバックを実装する場合は、@supports not (scroll-axis-lock: none) 内にのみ適用します。
  • scrollLeft / scrollTop でパンし、CSS transform は使用しない: transform でサーフェスを変換すると、要素がスクロール コンテナとして機能しなくなります。ネイティブ スクロールバー、境界クランプ、キーボードの矢印キーによるスクロール、scroll イベント、IntersectionObserver、スクロール駆動型アニメーションが機能しなくなります。

代替戦略

アプリケーションの要件に基づいて、プログレッシブ エンハンスメントと JavaScript フォールバックのどちらかを選択します。

オプション 1: プログレッシブ エンハンスメント(JavaScript なし)

ネイティブのコンポジタ スレッドのタッチ スクロール、慣性フリング モーメンタム、ピンチ操作によるズームを維持することが、サポートされていないブラウザで軸ロックを排除することよりも重要な場合は、プログレッシブ エンハンスメントを使用します。

  • サポートしているブラウザ: ブラウザは、スクロール ジェスチャー中に 2D スクロール コンテナで軸ロック(「レール」)ヒューリスティックが作動しないようにします。
  • サポートされていないブラウザ: scroll-axis-lock: none を認識しないブラウザは、宣言を安全に無視し、デフォルトの auto 2D スクロール動作を維持します。つまり、両方の軸に沿ってネイティブにスクロールしながら、主に 1 つの軸に沿って開始されるジェスチャーをレールします。

オプション 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);
}