本を読む画面に、横スワイプでページをめくる機能を付けるとします。
指が触れた。ページをめくる準備。指が動いた。スクロールを止める。準備万端です。
ただし、読者は続きを下にスクロールしたかっただけかもしれません。
本屋に入ると案内係が付き、棚を眺めようとするたびに腕をつかんで「次の棚ですね!」と連れていく。そんな接客を、JavaScriptで実装してしまうわけです。親切のつもりで、自由行動を廃止しています。
横スワイプの実装で大事なのは、横を検出することと、横ではない操作に介入しないことをセットにすることでした。
横を担当する前に、縦の担当を決める
個人開発のAfter 6 Streetには、本を開いて読む画面があります。紙面には縦のパンとピンチズームを許可し、横スワイプの成立をJavaScriptで判断します。
.bk-paper {
touch-action: pan-y pinch-zoom;
}
touch-actionは、ブラウザーにどの操作を任せるかを、ジェスチャーの開始前に伝えます。だから、指が動いてからCSSを書き換えても、その操作には間に合いません。スクロールを担う要素までの祖先の指定も関わります。
StreetのコードはTouch Eventsで座標を受け取り、横と判断したときだけ、取り消せるtouchmoveをpreventDefault()します。そのために、移動と終了のリスナーはpassive: falseです。
Pointer Eventsで組む場合、パンやズームの許可・抑制はtouch-actionで宣言します。ブラウザーがパンなどを引き取るとpointercancelが届き、アプリ側はそこで処理を中止できます。
指が触れただけでは、注文を受けない
リンクを押すつもりの人に、ページめくりを売り込む必要はありません。文章を選んでいる人にも、まず選び終えてもらいます。
touchstartでは、指が一本で、めくれる画面にいて、文字を選択していないときだけ記録を始めます。リンクやボタン、入力欄から始まった操作も除外します。closest()を使うので、リンクの内側の文字やアイコンを触っても同じ扱いです。対象のセレクターは、画面に置く操作部品に合わせます。
記録するのは、指のidentifier、開始座標、時刻、表示中の画面を表すキーです。Streetは表示モード・コラム・ページ位置などをキーに含めます。指を置いたまま画面が切り替わったら、古い操作はそこで打ち切ります。
横かどうかと、めくるかどうかは別々に決める
touchmoveで開始位置からの横・縦の移動量を比べます。どちらかが12 CSS pxを超えたら、横が縦の1.5倍より大きいときだけ横に確定。ほかは縦として、その軸を最後まで持ちます。一度縦と決めたら、そのあと指が右へ大きく曲がっても、案内係は追いかけません。
横に確定すると、取り消せる移動イベントを止めます。一方、実際にめくるのはtouchendです。横に13px動いてすぐ離せば、移動イベントを止めたのにページは変わりません。早めに横を引き受けるための判定と、短い動きで誤ってめくらないための判定は役目が違います。
終了時には、同じ画面で文字選択がなく、指が残っていないことを確認します。離れた指はtouchesから消えるので、changedTouchesで開始時と同じidentifierを探します。移動中に縦と決めていなければ、その指の移動が48 CSS px以上、横が縦の1.5倍より大きく、800ms以内で成立します。左へ動けば次、右へ動けば前をページ移動側に伝えます。
touchstartからclickまでのコード
公開版のsrc/swipe.jsからattach()を抜き出し、改行とインデントを整えました。context()はめくれる画面の間はキーを返し、対象外ではnullを返します。onSwipe()は、すでにあるページ移動処理につなぎます。
function attach(element, {
context,
onSwipe,
selected = () => !window.getSelection()?.isCollapsed,
now = () => Date.now()
}) {
let gesture = null, suppressClickUntil = 0;
const controls = 'button,a,input,select,textarea,[contenteditable="true"]';
element.addEventListener('touchstart', event => {
gesture = null;
suppressClickUntil = 0;
const key = context();
if (
event.touches.length !== 1 ||
!key ||
event.target.closest(controls) ||
selected()
) return;
const t = event.touches[0];
gesture = {
id: t.identifier, x: t.clientX, y: t.clientY,
time: now(), key, axis: null
};
}, { passive: true });
element.addEventListener('touchmove', event => {
if (!gesture) return;
if (event.touches.length !== 1 || context() !== gesture.key || selected()) {
gesture = null;
return;
}
const t = event.touches[0],
dx = Math.abs(t.clientX - gesture.x),
dy = Math.abs(t.clientY - gesture.y);
if (!gesture.axis && Math.max(dx, dy) > 12)
gesture.axis = dx > dy * 1.5 ? 'x' : 'y';
if (gesture.axis === 'x' && event.cancelable) event.preventDefault();
}, { passive: false });
element.addEventListener('touchend', event => {
const g = gesture;
gesture = null;
if (
!g ||
event.touches.length ||
context() !== g.key ||
selected() ||
now() - g.time > 800
) return;
const t = [...event.changedTouches].find(t => t.identifier === g.id);
if (!t) return;
const dx = t.clientX - g.x, dy = t.clientY - g.y;
if (
g.axis === 'y' ||
Math.abs(dx) < 48 ||
Math.abs(dx) <= Math.abs(dy) * 1.5
) return;
if (event.cancelable) event.preventDefault();
suppressClickUntil = now() + 400;
onSwipe(dx < 0 ? 1 : -1);
}, { passive: false });
element.addEventListener('touchcancel', () => {
gesture = null;
}, { passive: true });
element.addEventListener('click', event => {
if (event.detail !== 0 && now() < suppressClickUntil) {
event.preventDefault();
event.stopPropagation();
}
}, true);
}
成立直後のクリックにも担当があります。本のコンテナでキャプチャ時に拾い、成立から400ms未満でdetailが0以外なら、クリックの既定動作と子要素への伝播を止めます。detail === 0を通すのは、キーボードでの起動などを巻き込みにくくするためです。detailはクリックの回数に関係する値で、入力元を厳密に判定する値ではありません。新しいtouchstartが来れば、前の抑制時間は解除します。
境界は、少しだけ意地悪に確認する
| 与えた操作 | ページ移動 | 確認できたこと |
|---|---|---|
| 左へ60px | 次へ1回 | 移動中と終了時にイベントを取り消す |
| 縦へ60px | なし | 移動中も終了時もイベントを取り消さない |
| 横へ47px/48px | なし/1回 | 成立する距離は48pxから |
| 横60px・縦40px/横61px・縦40px | なし/1回 | 1.5倍ちょうどは横扱いしない |
| 最初に縦へ進み、そのあと横へ大きく動く | なし | 一度決めた縦の軸を保つ |
| 800ms/801msで横へ60px | 1回/なし | 800msまでは成立する |
| 成立後399ms/400msのクリック(detail=1) | ― | 取り消す/取り消さない |
表は合成イベントで判定を追った結果です。iPhoneでのタッチスクロール、ピンチ、画面端の「戻る」との競合は実機で未確認です。
小さな本で試す
Streetの本文は紙面に収まる長さに分けて表示し、スクロールできる目次はページが変わると先頭に戻します。下のサンプルでは、同じattach()を縦にスクロールできる紙面に付け、ページが変わるとpaper.scrollTop = 0で先頭へ戻しています。
前・次ボタンもスワイプと同じturn()を呼ぶので、タッチ操作が使えない環境でもページを送れます。HTMLを保存して開けば試せます。
保存して開けるHTML一式
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>横だけめくる本</title>
<style>
body {
max-width: 42rem;
margin: 1.5rem auto;
padding: 0 1rem;
color: #293b36;
background: #f6f2e8;
font: 16px/1.8 system-ui, sans-serif;
}
button { font: inherit; padding: .4rem 1rem; }
a { color: #1f6755; }
#paper {
touch-action: pan-y pinch-zoom;
block-size: clamp(14rem, 50vh, 24rem);
overflow: auto;
padding: .75rem 1.25rem;
border: 1px solid #c6cabb;
border-radius: .75rem;
background: #fffdf7;
}
:focus-visible { outline: 2px solid #1f6755; outline-offset: 2px; }
</style>
</head>
<body>
<h1>横だけめくる本</h1>
<p>紙面の中を縦にスクロールできます。タッチ端末で横にめくるか、前・次ボタンを押してみてください。</p>
<button id="prev" type="button">前へ</button>
<button id="next" type="button">次へ</button>
<p id="status" aria-live="polite"></p>
<article id="paper" tabindex="0" aria-labelledby="heading">
<h2 id="heading"></h2>
<p><a href="#note">紙面の注記へ</a></p>
<div id="prose"></div>
<p id="note" tabindex="-1">この本はサンプルです。めくると、次の紙面の先頭に戻ります。</p>
</article>
<script>
function attach(element, {
context,
onSwipe,
selected = () => !window.getSelection()?.isCollapsed,
now = () => Date.now()
}) {
let gesture = null, suppressClickUntil = 0;
const controls = 'button,a,input,select,textarea,[contenteditable="true"]';
element.addEventListener('touchstart', event => {
gesture = null;
suppressClickUntil = 0;
const key = context();
if (
event.touches.length !== 1 ||
!key ||
event.target.closest(controls) ||
selected()
) return;
const t = event.touches[0];
gesture = {
id: t.identifier, x: t.clientX, y: t.clientY,
time: now(), key, axis: null
};
}, { passive: true });
element.addEventListener('touchmove', event => {
if (!gesture) return;
if (event.touches.length !== 1 || context() !== gesture.key || selected()) {
gesture = null;
return;
}
const t = event.touches[0],
dx = Math.abs(t.clientX - gesture.x),
dy = Math.abs(t.clientY - gesture.y);
if (!gesture.axis && Math.max(dx, dy) > 12)
gesture.axis = dx > dy * 1.5 ? 'x' : 'y';
if (gesture.axis === 'x' && event.cancelable) event.preventDefault();
}, { passive: false });
element.addEventListener('touchend', event => {
const g = gesture;
gesture = null;
if (
!g ||
event.touches.length ||
context() !== g.key ||
selected() ||
now() - g.time > 800
) return;
const t = [...event.changedTouches].find(t => t.identifier === g.id);
if (!t) return;
const dx = t.clientX - g.x, dy = t.clientY - g.y;
if (
g.axis === 'y' ||
Math.abs(dx) < 48 ||
Math.abs(dx) <= Math.abs(dy) * 1.5
) return;
if (event.cancelable) event.preventDefault();
suppressClickUntil = now() + 400;
onSwipe(dx < 0 ? 1 : -1);
}, { passive: false });
element.addEventListener('touchcancel', () => {
gesture = null;
}, { passive: true });
element.addEventListener('click', event => {
if (event.detail !== 0 && now() < suppressClickUntil) {
event.preventDefault();
event.stopPropagation();
}
}, true);
}
const pages = [
['一ページ目 親切な案内係', '棚を眺めるたびに案内係が駆けてくる。そんな本屋だと、立ち止まるのにも勇気がいります。'],
['二ページ目 上下左右の担当', '縦に読み進めるのはブラウザーの仕事。横にめくるときだけJavaScriptの出番です。'],
['三ページ目 用があるときだけ', 'リンクを押す人もいれば、文章を選ぶ人もいます。とりあえず見守るのも接客です。']
];
const paper = document.querySelector('#paper');
const prev = document.querySelector('#prev');
const next = document.querySelector('#next');
let index = 0;
function render() {
const [heading, text] = pages[index];
document.querySelector('#heading').textContent = heading;
const paragraphs = Array.from({ length: 9 }, () => {
const p = document.createElement('p');
p.textContent = text;
return p;
});
document.querySelector('#prose').replaceChildren(...paragraphs);
document.querySelector('#status').textContent = (index + 1) + ' / ' + pages.length;
prev.disabled = index === 0;
next.disabled = index === pages.length - 1;
}
function turn(direction) {
const target = index + direction;
if (target < 0 || target >= pages.length) return;
index = target;
render();
paper.scrollTop = 0;
}
attach(paper, { context: () => String(index), onSwipe: turn });
prev.addEventListener('click', () => turn(-1));
next.addEventListener('click', () => turn(1));
render();
</script>
</body>
</html>
案内係の求人票
横に動かす人だけを見ていると、画面はだんだん横着になります。縦に読みたい人も、リンクを押したい人も、ボタンで進みたい人もいます。
この仕様を、案内係の求人票にするとします。
業務内容は「その人には付いていかない」「今は腕をつかまない」「もう注文は終わっている」。
「気の利く案内係募集」と書いたはずなのに、いちばん大事な応募条件は、こうなりました。
お客さんを、放っておける方。
参考資料と関連回
- MDNのtouch-action
- MDNのaddEventListener()
- MDNのTouchEvent.changedTouches
- MDNのUIEvent.detail
- Pointer Events Level 3
- Streetの来店日数、フォーカス復帰、セリフ選択は同じカレンダーの12月4日・6日・13日に登録しています。あえての素のWeb Advent Calendar 2026