スマートフォンで自作のスライダーをドラッグしようとしたら、つまみは動かずページごと縦にスクロールしてしまった——タッチ操作を自分で実装すると必ずぶつかる問題です。これを解決するのが CSS の touch-action で、「この要素の上で始まったタッチを、ブラウザーのスクロールやズームに使ってよいか」を宣言できます。この記事では値ごとの効き方、preventDefault() ではなく CSS で宣言する理由、カルーセルやスライダーでの実践的な指定までを、動かせるコード付きで解説します。
目次
touch-action はタッチの扱い方をブラウザーに伝えるプロパティ
タッチスクリーンで指を動かしたとき、その動きをページのスクロール(パン)やピンチズームに使うかどうかを決めているのはブラウザーです。ブラウザーはスクロールを滑らかに見せるため、この判断を JavaScript の実行とは別に、指が触れた直後に済ませようとします。つまりスクリプトが何かを言う前に、スクロールするかどうかが決まっている場面があるということです。
touch-action は、その判断材料を CSS 側からあらかじめ渡しておくためのプロパティです。「この要素の上のタッチはスクロールに使わないでほしい」「縦スクロールだけ許して、横方向は自分で処理する」といった意図を宣言しておくと、ブラウザーはそれに従ってジェスチャーを処理します。初期値は auto、つまりすべてのパン・ズームをブラウザーに任せる状態です。
名前に touch と付いていますが、対象はタッチだけではありません。仕様では、タッチスクリーン上のスタイラスなど直接操作でパン・ズームができる入力全般に適用されると定義されています。一方で、テキスト選択やリンク・フォーム部品の操作には影響しません。あくまでビューポートのパンとズームだけを扱うプロパティです。
指定できる値と効き方
値は「ブラウザーに許可するジェスチャー」を並べる形で指定します。書いた値以外のジェスチャーはブラウザーが処理しなくなり、その動きは自分のイベントハンドラーで扱えるようになります。
| 値 | 効き方 |
|---|---|
auto | 初期値。パン・ズームのすべてをブラウザーが扱う |
none | この要素で始まったタッチでは、パンもズームも一切行わない |
pan-x | 指 1 本の水平方向のパンだけを許可する |
pan-y | 指 1 本の垂直方向のパンだけを許可する |
pan-left pan-right pan-up pan-down | 指定した向きに始まるパンだけを許可する。向きはコンテンツが動く方向で、pan-up は指を下に動かす操作を指す |
pinch-zoom | 複数の指によるパンとズーム(ピンチズーム)を許可する |
manipulation | パンとピンチズームは許可し、ダブルタップズームのような追加のジェスチャーは無効にする |
pan-x と pan-y は touch-action: pan-x pan-y のように並べて書けます。pinch-zoom も各 pan-* と組み合わせられます。逆に auto / none / manipulation は単独でしか使えません。
紛らわしいのが manipulation です。これは「タッチ操作を全部止める」値ではなく、パンとピンチズームは今までどおり許可したうえで、一定時間内の複数回のタップに頼る動作(ダブルタップでのズームなど)だけを無効にする値です。仕様では pan-x pan-y pinch-zoom と書いた場合と同じ意味とされています。ページの拡大手段を奪わないので、後述するタップ反応の高速化に使いやすい値です。
なお、方向指定の 4 つの値と pinch-zoom は少し立場が違います。Pointer Events Level 3 の本体の文法は auto | none | [ pan-x || pan-y ] | manipulation で、それ以外は実装由来の値として Compatibility Standard 側で定義されています。ブラウザーによって対応状況に差が出やすい部分なので、確実に動かしたい場面では auto / none / pan-x / pan-y / manipulation の範囲で組み立てるのが無難です。
自前のドラッグには touch-action: none|スライダーを作ってみる
もっとも分かりやすい使いどころが、つまみを指で動かすスライダーやお絵かきキャンバスのように、タッチの動きを丸ごと自分で処理したい要素です。この場合は対象の要素に touch-action: none を指定して、パンもズームもブラウザーに渡さないようにします。
/* ドラッグで操作する部分にだけ指定する */
.slider__thumb {
touch-action: none;
}
下のデモは、つまみを横にドラッグすると数値が変わるスライダーです。つまみに touch-action: none を指定してあるので、スマートフォンでつまみをつかんで指を動かしても、ページがスクロールに持っていくことはありません。touch-action はタッチ操作に対する宣言なので、パソコンのマウスで試した場合は指定の有無で違いは出ません。
<div class="slider">
<p class="slider__label">音量:<span id="value">0</span></p>
<div class="slider__track" id="track">
<div class="slider__thumb" id="thumb"></div>
</div>
</div>
.slider {
font-family: sans-serif;
padding: 24px 16px;
}
.slider__track {
position: relative;
height: 40px;
background: #eef1f5;
border-radius: 20px;
}
.slider__thumb {
position: absolute;
top: 4px;
left: 0;
width: 32px;
height: 32px;
border-radius: 50%;
background: #007bff;
cursor: grab;
/* ここがポイント。
ドラッグは自分の JavaScript で処理するので、
ブラウザーのスクロール・ズームには手を出させない */
touch-action: none;
}
const track = document.getElementById('track');
const thumb = document.getElementById('thumb');
const value = document.getElementById('value');
let dragging = false;
function moveTo(clientX) {
const rect = track.getBoundingClientRect();
const max = rect.width - thumb.offsetWidth;
const x = Math.min(Math.max(clientX - rect.left - thumb.offsetWidth / 2, 0), max);
thumb.style.left = x + 'px';
value.textContent = Math.round((x / max) * 100);
}
thumb.addEventListener('pointerdown', (event) => {
dragging = true;
// 指がつまみの外に出ても、以降のイベントを受け取り続ける
thumb.setPointerCapture(event.pointerId);
});
thumb.addEventListener('pointermove', (event) => {
if (!dragging) return;
// touch-action: none を宣言してあるので、
// preventDefault() を書かなくてもスクロールに奪われない
moveTo(event.clientX);
});
thumb.addEventListener('pointerup', () => {
dragging = false;
});
座標の取得には pointerdown / pointermove / pointerup というポインターイベントを使っています。マウス・ペン・タッチをまとめて扱えるので、タッチ用とマウス用で処理を書き分ける必要がありません。setPointerCapture() は、指やマウスがつまみの外へ出ても以降のイベントを同じ要素に届け続けるための指定で、ドラッグ処理とは相性の良い組み合わせです。
注目してほしいのは、pointermove の中に preventDefault() が一行も無いことです。スクロールさせないための処理はすべて CSS の 1 行に寄せてあります。なぜそれで済むのか、次の章で見ていきます。
なぜ preventDefault() ではなく CSS で宣言するのか
かつてタッチのドラッグを実装するときは、touchmove の中で preventDefault() を呼んでスクロールを止めるのが定番でした。ところが今のブラウザーでは、これがそのままでは効かない場面があります。window / document / document.body に登録した touchstart と touchmove のリスナーは、既定でパッシブ(passive: true)として扱われるためです。パッシブなリスナーの中の preventDefault() は無視され、コンソールに警告が出るだけで終わります。
これはスクロール性能のための仕様変更です。パッシブだと分かっていればブラウザーはスクリプトの終了を待たずにスクロールを始められますが、キャンセルされる可能性が残っていると、毎回リスナーの処理を待たなければならず、スクロールがもたつきます。どうしても preventDefault() を使いたい場合は、次のように第 3 引数で明示的にパッシブを外す必要があります。
// 昔ながらの書き方。passive: false を明示しないと
// preventDefault() が無視されることがある
document.addEventListener('touchmove', (event) => {
event.preventDefault();
}, { passive: false });
ただしこの書き方は、スクロールを止めたい範囲が広くなりがちで、どこまで止まるのかもコードを読まないと分かりません。さらに、ポインターイベントを使っている場合はそもそもこの手が使えません。仕様には「ビューポートのパンやズームはポインターイベントをキャンセルしても抑制できない。代わりに touch-action で宣言的に指定する」と明記されており、pointermove で preventDefault() を呼んでもスクロールは止まらないのです。
その点 touch-action なら、指が触れた瞬間にブラウザーがスタイルを見て判断できます。スクリプトの実行を待つ必要がないのでスクロールの滑らかさを損なわず、「どの要素のどの方向を自分で扱うのか」が CSS を見るだけで分かります。タッチのドラッグを実装するなら、まず touch-action で意図を宣言するのが基本の形だと考えてください。
横ドラッグのカルーセルで縦スクロールを妨げない
次に困るのが、ページの途中に置いた横スクロールのカルーセルです。ここで touch-action: none を指定してしまうと、カードの上から指を動かしたときにページの縦スクロールまで止まってしまいます。カードが画面の大部分を占めていると、ユーザーは「スクロールできない場所」に閉じ込められたように感じます。
そこで使うのが pan-y です。縦方向のパンはブラウザーに任せたまま、横方向の動きだけを自分のスクリプトに回せます。仕様書にも、画像カルーセルが縦方向のパンを邪魔せずに横方向のポインターイベントを受け取る例として pan-y が挙げられています。
<div class="demo">
<p class="demo__note">スマートフォンでカードの上を<strong>縦に</strong>なぞってみてください。</p>
<div class="demo__switch">
<label><input type="radio" name="ta" value="pan-y" checked> touch-action: pan-y</label>
<label><input type="radio" name="ta" value="none"> touch-action: none</label>
</div>
<div class="carousel" id="carousel">
<ul class="carousel__list" id="list">
<li class="card">1</li>
<li class="card">2</li>
<li class="card">3</li>
<li class="card">4</li>
<li class="card">5</li>
<li class="card">6</li>
</ul>
</div>
<p class="demo__result">pan-y ならカードを横にドラッグでき、縦になぞればこの枠がスクロールします。none だと縦スクロールも止まります。</p>
<div class="demo__filler">↓ 縦スクロール確認用の余白 ↓</div>
</div>
.demo {
font-family: sans-serif;
padding: 16px;
}
.carousel {
overflow: hidden;
padding: 8px 0;
/* 横方向のドラッグは JavaScript が受け取り、
縦方向のスクロールはブラウザーに任せる */
touch-action: pan-y;
}
.carousel__list {
display: flex;
gap: 12px;
margin: 0;
padding: 0;
list-style: none;
will-change: transform;
}
.card {
flex: 0 0 120px;
height: 120px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
background: #007bff;
color: #fff;
font-size: 32px;
user-select: none;
}
.demo__switch label {
margin-right: 16px;
font-size: 14px;
}
.demo__filler {
margin-top: 16px;
padding: 80px 0;
text-align: center;
color: #888;
background: #f5f7fa;
}
const carousel = document.getElementById('carousel');
const list = document.getElementById('list');
let startX = 0;
let startOffset = 0;
let offset = 0;
let dragging = false;
function maxOffset() {
return Math.max(list.scrollWidth - carousel.clientWidth, 0);
}
carousel.addEventListener('pointerdown', (event) => {
dragging = true;
startX = event.clientX;
startOffset = offset;
carousel.setPointerCapture(event.pointerId);
});
carousel.addEventListener('pointermove', (event) => {
if (!dragging) return;
const moved = startX - event.clientX;
offset = Math.min(Math.max(startOffset + moved, 0), maxOffset());
list.style.transform = 'translateX(' + -offset + 'px)';
});
carousel.addEventListener('pointerup', () => {
dragging = false;
});
// ラジオボタンで touch-action を切り替える
document.querySelectorAll('input[name="ta"]').forEach((radio) => {
radio.addEventListener('change', () => {
carousel.style.touchAction = radio.value;
});
});
ラジオボタンで none に切り替えると、カードの上では縦になぞっても枠がスクロールしなくなります。pan-y に戻せば、横ドラッグでカードを動かしつつ、縦になぞったときはスクロールする、という両立ができます。なお、overflow-x: auto のようにブラウザー標準の横スクロールで作るカルーセルなら、ブラウザーが自動で軸を判断してくれるので touch-action は不要です。pan-y が必要になるのは、こうした横方向の動きを JavaScript で処理するときです。
方向まで細かく制御したい場合は pan-y pan-right のような書き方もできます。仕様書では、カルーセルが右端まで来たら pan-y pan-right に変えて、その先のスワイプはページのスクロールに渡す、という使い方が例示されています。ただし前述のとおり方向指定の値は対応差があるため、採用するなら実機での確認が前提になります。
manipulation でタップの反応を速くする
touch-action にはもうひとつ、操作感を良くする使い道があります。ブラウザーはダブルタップによるズームに対応するため、auto の要素では 1 回目のタップのあと click を発火させるまでに待ち時間(典型的には 300 ミリ秒程度)を設けることがあります。2 回目のタップが来るかどうかを見極めるためです。
manipulation を指定すると、ダブルタップズームのような複数回のタップに依存する動作が無効になり、ブラウザーは 2 回目を待たずに click を発火できます。ピンチズームは残るので、拡大して読みたいユーザーの手段を奪うことにもなりません。ボタンやリンクなど、タップしたら即座に反応してほしい要素に指定します。
/* タップしてすぐ反応してほしい要素に指定する。
ピンチズームは有効なままなので拡大の手段は残る */
a,
button,
.tab,
[role="button"] {
touch-action: manipulation;
}
ただし、最近のモバイルブラウザーは <meta name="viewport" content="width=device-width, initial-scale=1"> が指定されたページではこの待ち時間を省くようになっています。多くのサイトでは体感できるほどの差が出ないこともあるので、「指定すれば必ず速くなる」とは考えず、実機で比べてから採用するとよいでしょう。
touch-action が効かないとき
指定したはずなのに挙動が変わらない、逆に思わぬ場所でスクロールが止まる、という相談は touch-action の適用範囲の勘違いから来ていることがほとんどです。よくある原因を順に見ていきます。
マウスやトラックパッドの操作は変わらない
このプロパティが扱うのは、画面に直接触れてページを動かす種類の操作です。マウスホイールやトラックパッドによるスクロールは対象外なので、パソコンの開発環境で touch-action: none を指定しても、ホイールを回せばページは普通にスクロールします。「効いていない」と判断する前に、実機かブラウザーの端末エミュレーションで、タッチとして操作しているかを確認してください。
親要素の指定は継承されないが、制限はかかる
touch-action は継承しないプロパティです。親に pan-y と書いても、子要素の計算値は auto のままになります。ところが実際のジェスチャーの可否は、指が触れた要素からもっとも近いスクロールコンテナである祖先までの各要素を見て、そのすべてが許可している動きだけが実行される、という決まり方をします。
つまり制限は祖先方向に積み上がります。親に touch-action: none を書けば、その中の子要素に触れたときのスクロールも止まりますし、逆に子要素に auto を書いても親が止めた動きを復活させることはできません。ページ全体が急にスクロールしなくなったときは、まず祖先に none を書いていないかを疑ってください。
インライン要素やテーブルの行には適用されない
適用対象は、置換要素でないインライン要素・表の行・行グループ・列・列グループを除くすべての要素と定められています。つまり素の <span> や <tr> に書いても無視されます。<span> をドラッグ対象にしたいなら、display: inline-block や display: block を与えて幅と高さを持てる状態にしてから指定してください。
pointerdown の中で値を変えても、その操作には間に合わない
ブラウザーは操作の開始時点で、そのジェスチャーを自分で処理するかどうかを決めてしまいます。pointerdown のハンドラーで element.style.touchAction = 'none' と書き換えても、その指の操作が終わるまで新しい値は反映されません。同じ理由で、pan-y を指定した要素で横に動かし始めたジェスチャーを、途中から縦のスクロールに変えることもできません。値の切り替えは、あくまで次の操作からの話だと考えてください。
iframe の外から中身は制御できない
touch-action は埋め込みブラウジングコンテキストの内側までは伝わりません。<iframe> 要素に指定しても、その中の文書のパンやズームの挙動は変わらないということです。地図や動画の埋め込みでスクロールの挙動を変えたい場合は、iframe を包む要素側で操作を受け止める設計にするか、埋め込み先が提供するオプションを使うことになります。
まとめ
touch-action は、要素の上で始まったタッチをブラウザーがパンやズームに使ってよいかを宣言するプロパティです。自分でドラッグを実装する要素には none、横方向だけ自分で扱いたいカルーセルには pan-y、タップの反応を速くしたいボタンには manipulation と、目的に合わせて許可する範囲を絞り込んでいくのが基本の考え方になります。
ポインターイベントではそもそも preventDefault() でスクロールを止められず、touchmove のリスナーも既定ではパッシブ扱いです。スクロールを制御する手段は CSS 側に移っている、と捉えておくと迷いません。継承しないのに祖先の制限は効くこと、マウス操作には影響しないことを押さえておけば、「効かない」と悩む場面もぐっと減るはずです。