KENTEM TechBlog

建設業のDXを実現するKENTEMの技術ブログです。

Reactでタッチスクロールを条件付きで止めたい? Pointer Eventsでは難しい理由

この記事は、 KENTEM TechBlog アドベントカレンダー2025 13日目、12月13日の記事です。

こんにちは、

React を中心にフロントエンド開発をしているエンジニア T・M です。

タッチデバイス向けのUIを作っていて、

onPointerMovepreventDefault()してるのに、スクロールが全然止まらない

という状況にハマりました。

その過程で、Pointer Eventsの仕様について学びがいくつかありました。

同じ問題に遭遇している方は、ぜひ参考にしてみてください。

やりたかったこと

タッチデバイス向けに、「長押ししたあとにドラッグ操作を有効にする」という挙動を実装する必要がありました。

要件は以下のとおりです。

長押ししたあとにだけドラッグ操作を有効にしたい。
それ以外のときは普通にスクロールしてほしい。

もう少し展開すると、次のような挙動を実現したい、という話です。

  • 通常のスワイプ
    → ページ/コンテンツがそのままスクロールしてほしい
  • 一定時間(例: 500ms)長押し
    → そのあとだけスクロールを止めて、ドラッグ操作(カスタムUIのドラッグなど)に使いたい

最初に思いつくのは、onPointerMoveで条件付きでpreventDefault()を呼ぶ方法です。

  • onPointerDownで、イベントタイプがtouchであればタイマーを起動する
  • onPointerMoveのタイミングで、タイマーが完了していればpreventDefault()を呼ぶ
onPointerMove={(e) => {
  if (e.pointerType === 'touch' && isTimerComplete) {
    e.preventDefault();
  }
}}

しかし、これは動きません。その理由を理解するには、まずReactのイベントとブラウザAPIの関係を押さえる必要があります。

前提知識:ReactのイベントとブラウザAPI

今回の話に出てくる onPointerMove は、React 独自の「合成イベント(SyntheticEvent)」ですが、その中身はブラウザが提供しているネイティブの PointerEvent をラップしたものです。

ざっくりいうと、

  • JSX/TSX では onPointerMove という props を書く
  • React が内部で addEventListener('pointermove', ...) などを呼び出す
  • ハンドラに渡される event オブジェクトも、基本的にはネイティブの PointerEvent をもとにしている

という構造になっています。

そのため、React 側で特別なカスタマイズをしていない限り、

  • 「どのタイミングでスクロールが始まるか」
  • preventDefault() がスクロールに効くかどうか」

といった振る舞いは、最終的にはブラウザの Pointer Events の仕様に従います。

つまり、「onPointerMovepreventDefault() しているのに、なぜスクロールが止まらないのか?」という疑問を解消するには、React というよりも、まず元になっている Pointer Events の仕様を押さえる必要があるのです。

なぜPointer Eventsでは実現できないのか

結論から言うと、Pointer Eventsは「途中からネイティブスクロールを禁止したい」という用途には向いていません。理由はいくつかあります。

pointermoveのpreventDefault()はスクロールを止めない設計

W3CのPointer Events仕様には、次のように明記されています。

Viewport manipulations (panning and zooming) — generally, as a result of a direct manipulation interaction — are intentionally NOT a default action of pointer events, meaning that these behaviors (e.g. panning a page as a result of moving a finger on a touchscreen) cannot be suppressed by canceling a pointer event. Authors must instead use touch-action to explicitly declare the direct manipulation behavior for a region of the document. Removing this dependency on the cancelation of events facilitates performance optimizations by the user agent.

Pointer Events Level 4

つまり、スクロールやズームはpointermoveのデフォルト動作ではないため、preventDefault()で止めることはできません。スクロールを制御したい場合は、touch-actionで事前に宣言する必要があります。

pointercancelによる中断

さらに厄介なのがpointercancelイベントです。

ブラウザがユーザーの操作をネイティブジェスチャー(スクロール、ズーム、スワイプ)だと判断すると、pointercancelイベントを発火させます。これが発火すると、同じ pointerId に対する、以降のpointermoveイベントは一切発火しなくなります

この判定はpointerdown直後〜数回のpointermoveの間に行われるため、タイマー完了を待つ設計では間に合わないことがあります。

解決策

ここまでPointer Eventsでは実現できない理由を見てきました。では、どうすればよいのでしょうか。

答えはネイティブのTouch Eventsを使うことです。Touch Eventsにもtouchcancelイベントは存在しますが、Pointer Eventsとの決定的な違いがあります。

イベント preventDefault()でスクロールを止められるか
pointermove ❌ 止められない(仕様上スクロールはデフォルト動作ではない)
touchmove ✅ 止められる(passive: falseの場合)

touchmovepreventDefault()を呼ぶことで、ブラウザのスクロール処理を抑制でき、結果としてtouchcancelの発火も防げます。

ネイティブのaddEventListenerを使い、明示的にpassive: falseを指定します。

※ 例として提示しています。 useEffect を必ずしも使用する必要はありません。

useEffect(() => {

  const handleTouchMove = (e: TouchEvent) => {
    if (shouldPreventScroll) {
      e.preventDefault();
    }
  };

  document.addEventListener('touchmove', handleTouchMove, { passive: false });

  return () => {
    document.removeEventListener('touchmove', handleTouchMove);
  };
}, [shouldPreventScroll]);

return <div>...</div>;

まとめ

  • Pointer Eventsの仕様上、pointermove の preventDefault() はスクロールを止めない
  • スクロールやズームは pointermove のデフォルト動作ではないため
  • pointercancel により、イベント自体が途切れることもある
  • 確実にスクロールを制御したいなら、ネイティブの touchmove イベント + passive: false

Reactでブラウザの挙動を細かく制御したい場面ではネイティブAPIを併用することも必要だということです。

補足

touch-actionの動的切り替えではダメなのか?

「タイマー完了時にtouch-action: noneを設定すれば、以降のスクロールを止められるのでは?」と考えるかもしれませんが、これも動きません。

onPointerDown={(e) => {
  if (e.pointerType === 'touch') {
    setTimeout(() => {
      // タイマー完了後にtouch-actionを変更
      container.style.touchAction = 'none';
    }, 500);
  }
}}

MDNのドキュメントには、次のように記載されています。

メモ: ジェスチャーが開始された後、 touch-action の値を変更しても、現在のジェスチャーの動作には影響を与えません。

MDN: touch-action

touch-actionの値は、タッチ操作が始まった時点(pointerdown/touchstart発火時)で確定します。開始後に値を変えても、既に始まっているジェスチャには反映されません。

ReactのonTouchMoveではダメなのか?

「Touch Eventsを使えばいいなら、Reactの props でonTouchMoveを使えばいいのでは?」と思うかもしれません。残念ながら、これも動きません。

Reactはtouchstarttouchmovewheelイベントをpassive: trueで登録するためです。passive: trueの場合、preventDefault()は無視されます。

// Reactのソースコード(react-dom-bindings/src/events/DOMPluginEventSystem.js)
if (
  domEventName === 'touchstart' ||
  domEventName === 'touchmove' ||
  domEventName === 'wheel'
) {
  isPassiveListener = true;
}

この設計には歴史的経緯があります。2017年のChrome 56で、Googleはドキュメントレベルのタッチイベントリスナーをデフォルトでpassiveにする「介入(intervention)」を実施しました。当時のReactはイベントをdocumentに委譲していたため、この介入の影響を受け、onTouchMoveでのpreventDefault()が効かなくなりました。

React 17でイベント委譲先がdocumentからルートコンテナに変更され、影響を免れるかと思いましたが、Reactチームは「既にReact 16で壊れていた挙動を、パフォーマンス上の理由からあえて直さない」という判断を下しました。

この問題については、2025年時点も変わっていないようです。

参考文献

おわりに

KENTEMでは、様々な拠点でエンジニアを大募集しています! 建設×ITにご興味頂いた方は、是非下記のリンクからご応募ください。 recruit.kentem.jp career.kentem.jp