
こんにちは、
React を中心にフロントエンド開発をしているエンジニア T・M です。
タッチデバイス向けのUIを作っていて、
「onPointerMoveでpreventDefault()してるのに、スクロールが全然止まらない」
という状況にハマりました。
その過程で、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 の仕様に従います。
つまり、「onPointerMove で preventDefault() しているのに、なぜスクロールが止まらないのか?」という疑問を解消するには、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.
つまり、スクロールやズームは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の場合) |
touchmoveでpreventDefault()を呼ぶことで、ブラウザのスクロール処理を抑制でき、結果として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 の値を変更しても、現在のジェスチャーの動作には影響を与えません。
touch-actionの値は、タッチ操作が始まった時点(pointerdown/touchstart発火時)で確定します。開始後に値を変えても、既に始まっているジェスチャには反映されません。
ReactのonTouchMoveではダメなのか?
「Touch Eventsを使えばいいなら、Reactの props でonTouchMoveを使えばいいのでは?」と思うかもしれません。残念ながら、これも動きません。
Reactはtouchstart、touchmove、wheelイベントを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年時点も変わっていないようです。
- React Issue #8968: Chrome 56 breaks touch events Chrome介入の最初の報告
- React Issue #19651: Touch/Wheel Event Passiveness in React 17 背景の議論
- React PR #19654: Keep onTouchStart, onTouchMove, and onWheel passive
passive: trueを維持する決定 - React Issue #22794: React 18 not passive wheel / touch event listeners support
参考文献
- Pointer Events Level 4 - W3C
- MDN: touch-action
- MDN: Pointer events
- MDN: Touch events
- Chrome for Developers: Making touch scrolling fast by default
- React PR #19654: Keep onTouchStart, onTouchMove, and onWheel passive
- React Issue #19651: Touch/Wheel Event Passiveness in React 17
- React Issue #8968: Chrome 56 breaks touch events
- React Issue #22794
おわりに
KENTEMでは、様々な拠点でエンジニアを大募集しています! 建設×ITにご興味頂いた方は、是非下記のリンクからご応募ください。 recruit.kentem.jp career.kentem.jp