Reactで、スワイプで消せるリストを作る方法

​​React​CSS,Playwright,Pointer Events,TypeScript

リストの行を左へ引いた量を state に持ち、Pointer Events で受け取った横の動きに合わせて行をずらすと、ライブラリを使わずに「スワイプで消せるリスト」を作れます。引いた量が行の幅の 4 割を超えたところで離したら削除し、それより少なければ元の位置へ戻します。

タッチ操作で欠かせないのが、CSS の touch-action です。指定しないと、横に引いた途中でブラウザがイベントを打ち切り、行がまったく消えませんでした。pan-y を指定すれば、横は自前のスワイプ、縦はページのスクロールと分けて扱えます。

補足

検証環境は Ubuntu 24.04、Node.js 22.23.4、Chromium 153 です。使ったのは React 19.3.1、TypeScript 7.0.2、Playwright 1.63.0 です。マウスとキーボードは Playwright で操作し、タッチは Chromium の DevTools プロトコル(CDP)の Input.dispatchTouchEvent で、スマートフォンの表示を模した画面に送りました。実機のスマートフォン、Firefox と Safari、スクリーンリーダーでの読み上げは試していません。削除の取り消し(元に戻す)や、消した行の高さを縮めるアニメーションも、この記事では扱いません。

1回目は行を少しだけ左へ引いて離すと元の位置へ戻り、2回目と3回目は大きく引いて離すと行が左へ消え、下の「残り」の件数が 5 から 4、3 へ減ってピンクになる
指で行を左へスワイプする3回の操作(丸は指の位置。丸と上の表示は GIF 用に重ねたもの)

行を引いた量を state に持つ SwipeRow

行1つ分のコンポーネント SwipeRow を作ります。行は2枚重ねで、下にピンクの「削除」、上に行の中身があります。上の1枚を transform: translateX() で左へずらすと、下の「削除」が見えてきます。

tsx
import { useRef, useState } from 'react';
import type { PointerEvent, TransitionEvent } from 'react';

type Props = {
  label: string;
  done: boolean;
  onToggle: () => void;
  onDelete: () => void;
};

// 指やマウスがこれだけ動いたら、横か縦かを決める(px)
const SLOP = 10;
// 行の幅のこの割合より左へ引いて離したら、削除する
const THRESHOLD = 0.4;

type Gesture = {
  pointerId: number;
  startX: number;
  startY: number;
  // 'pending':まだ向きが決まっていない/'swipe':横に引いている/'scroll':縦なので何もしない
  mode: 'pending' | 'swipe' | 'scroll';
};

export function SwipeRow({ label, done, onToggle, onDelete }: Props) {
  const [offset, setOffset] = useState(0);
  // 'idle':指に付いてくる/'back':元の位置へ戻る/'removing':左へ消えていく
  const [phase, setPhase] = useState<'idle' | 'back' | 'removing'>('idle');
  const gesture = useRef<Gesture | null>(null);

  function handlePointerDown(e: PointerEvent<HTMLDivElement>) {
    if (phase === 'removing' || (e.pointerType === 'mouse' && e.button !== 0)) return;
    gesture.current = { pointerId: e.pointerId, startX: e.clientX, startY: e.clientY, mode: 'pending' };
    setPhase('idle');
  }

  function handlePointerMove(e: PointerEvent<HTMLDivElement>) {
    const g = gesture.current;
    if (!g || g.pointerId !== e.pointerId) return;
    const dx = e.clientX - g.startX;
    const dy = e.clientY - g.startY;
    if (g.mode === 'pending') {
      if (Math.abs(dx) < SLOP && Math.abs(dy) < SLOP) return;
      // 横の動きのほうが大きければスワイプ、縦ならページのスクロールに任せる
      g.mode = Math.abs(dx) > Math.abs(dy) ? 'swipe' : 'scroll';
      if (g.mode === 'swipe') {
        // 行の外へ出ても、離すまでこの要素にイベントが届くようにする
        e.currentTarget.setPointerCapture(e.pointerId);
      }
    }
    if (g.mode === 'swipe') setOffset(Math.min(0, dx)); // 左へだけ動かす
  }

  function handlePointerUp(e: PointerEvent<HTMLDivElement>) {
    const g = gesture.current;
    if (!g || g.pointerId !== e.pointerId) return;
    gesture.current = null;
    if (g.mode !== 'swipe') return;
    const width = e.currentTarget.offsetWidth;
    if (-offset > width * THRESHOLD) {
      // 動きを減らす設定のときは transition を切っているので、transitionend を待たずに消す
      if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
        onDelete();
        return;
      }
      setPhase('removing');
      setOffset(-width);
    } else {
      setPhase('back');
      setOffset(0);
    }
  }

  // ブラウザがスクロールなどを始めて、ポインターのイベントが打ち切られたとき
  function handlePointerCancel() {
    gesture.current = null;
    setPhase('back');
    setOffset(0);
  }

  function handleTransitionEnd(e: TransitionEvent<HTMLDivElement>) {
    if (phase === 'removing' && e.propertyName === 'transform') onDelete();
  }

  return (
    <li className="row">
      <span className="row-bg" aria-hidden="true">削除</span>
      <div
        className={`row-fg${phase === 'idle' ? '' : ' animate'}`}
        style={{ transform: `translateX(${offset}px)` }}
        onPointerDown={handlePointerDown}
        onPointerMove={handlePointerMove}
        onPointerUp={handlePointerUp}
        onPointerCancel={handlePointerCancel}
        onTransitionEnd={handleTransitionEnd}
      >
        <button
          type="button"
          className="row-label"
          aria-pressed={done}
          onClick={onToggle}
        >
          {label}
        </button>
        <button type="button" className="row-delete" aria-label={`「${label}」を削除`} onClick={onDelete}>
          ×
        </button>
      </div>
    </li>
  );
}

押した位置と、横か縦かの判定は gesture という ref に入れています。ref の値を書き換えても再レンダリングは起きないので、画面に出さない値を置くのに向いています。画面に関わる値(ずらす量の offset と、戻るか消えるかの phase)だけを state にしています。

押した直後は、向きを決めません。縦か横のどちらかに 10px 動いたところで、横の動きのほうが大きければスワイプ、縦のほうが大きければ何もしない、と決めます。スワイプと決まったときにだけ setPointerCapture() を呼びます。そのため、引いている途中でポインターが行の外へ出ても、離すまでイベントがこの行に届きます。

離したときは、引いた量が行の幅の 4 割(THRESHOLD)を超えていれば、行を幅いっぱいまで左へ動かします。その動きが終わったときの transitionend で onDelete を呼び、親に行を消してもらいます。4 割に届かなければ、offset を 0 に戻します。

指で引く間は、行がすぐ指に付いてくるように transition を付けません。離したあとだけ animate クラスを付けて、0.2 秒かけて動かします。CSS は次のとおりです。

css
.row {
  position: relative;
  overflow: hidden; /* 引き出した行が、横にはみ出して見えないようにする */
  border-bottom: 1px solid #d5dde1;
}

/* 行の下に敷く、削除の色 */
.row-bg {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: flex-end;
  padding-right: 20px;
  background: #d85878;
  color: #fff;
  font-weight: bold;
}

.row-fg {
  position: relative;
  display: flex;
  align-items: center;
  background: #fff;
  /* 縦のスクロールはブラウザに任せ、横の動きだけをページに渡す */
  touch-action: pan-y;
  user-select: none;
}
.row-fg.animate { transition: transform 0.2s ease-out; }

@media (prefers-reduced-motion: reduce) {
  .row-fg.animate { transition: none; }
}

touch-action: pan-y が、タッチ操作のための指定です。理由は後の節で、指定を変えて比べた結果とあわせて書きます。

一覧を持つ App

使う側の App は、行の一覧を state に持ちます。onDelete で一覧から行を除き、行の文字を押したとき(onToggle)は済んだかどうかを切り替えます。

tsx
import { useState } from 'react';
import { SwipeRow } from './SwipeRow';

type Todo = { id: number; label: string; done: boolean };

const initialTodos: Todo[] = [
  { id: 1, label: '牛乳を買う', done: false },
  { id: 2, label: '本を返す', done: false },
  { id: 3, label: '記事の図を描く', done: false },
  { id: 4, label: '傘を持っていく', done: false },
  { id: 5, label: '植木に水をやる', done: false },
];

export function App() {
  const [todos, setTodos] = useState(initialTodos);

  return (
    <main>
      <ul className="list">
        {todos.map((t) => (
          <SwipeRow
            key={t.id}
            label={t.label}
            done={t.done}
            onToggle={() => setTodos((prev) => prev.map((x) => (x.id === t.id ? { ...x, done: !x.done } : x)))}
            onDelete={() => setTodos((prev) => prev.filter((x) => x.id !== t.id))}
          />
        ))}
      </ul>
      <p className="count">残り {todos.length} 件</p>
    </main>
  );
}

onDelete と onToggle では、setTodos に関数を渡しています。こうすると、それまでの更新を反映した一覧を受け取って、新しい一覧を作れます。

行の右端には「×」のボタンも置きました。スワイプはマウスやタッチでしかできないので、キーボードで操作する人のための削除の手段です。ボタンには aria-label で「「牛乳を買う」を削除」のような名前を付けています。

マウスで引いたときの動き

表示領域の幅を 360px にして、Playwright のマウスで1行目を引きました。行の幅は 328px なので、131.2px より左へ引いて離すと削除されます。

text
行の幅: 328px(削除になるのは 131.2px より左へ引いて離したとき)
最初: 牛乳を買う, 本を返す, 記事の図を描く, 傘を持っていく, 植木に水をやる / 残り 5 件
1行目を左へ100px引いて離す: 引いている間 translateX(-100px) / 離した直後 translateX(0px)
  0.4秒後: 牛乳を買う, 本を返す, 記事の図を描く, 傘を持っていく, 植木に水をやる / 残り 5 件 / 1行目 translateX(0px) / イベント: pointerdown pointerup click
1行目を左へ200px引いて離す: 引いている間 translateX(-200px) / 離した直後 translateX(-328px) / 牛乳を買う, 本を返す, 記事の図を描く, 傘を持っていく, 植木に水をやる / 残り 5 件
  0.4秒後: 本を返す, 記事の図を描く, 傘を持っていく, 植木に水をやる / 残り 4 件 / イベント: pointerdown pointerup click
1行目を右へ150px引いて離す: 引いている間 translateX(0px) / 本を返す, 記事の図を描く, 傘を持っていく, 植木に水をやる / 残り 4 件
2行目を下へ150px引いて離す: 引いている間 translateX(0px) / 本を返す, 記事の図を描く, 傘を持っていく, 植木に水をやる / 残り 4 件 / イベント: pointerdown pointerup click
1行目を左へ200px・下へ120px(行の外まで)引いて離す: 引いている間 translateX(-200px)
  0.4秒後: 記事の図を描く, 傘を持っていく, 植木に水をやる / 残り 3 件
2行目をクリック: 記事の図を描く, 傘を持っていく(済), 植木に水をやる / 残り 3 件 / イベント: pointerdown pointerup click
1行目のボタンから Tab で「「記事の図を描く」を削除」へ移って Enter: 傘を持っていく(済), 植木に水をやる / 残り 2 件

左へ 100px 引いたときは、引いている間は translateX(-100px) まで付いてきて、離すと 0 に戻りました。200px 引いたときは、離した直後は行が -328px(行の幅いっぱい)まで動き、一覧にはまだ残っています。離してから 0.4 秒後(0.2 秒の動きが終わったあと)には、一覧から消えて「残り 4 件」になりました。

右へ引いても、行は動きません(Math.min(0, dx) で左にだけ動かしています)。下へ 150px 引いたときは、縦の動きと判定されたので、行は動きませんでした。文字の切り替えも起きていません。左へ 200px 引きながら下へ 120px、行の外まで出たときも、setPointerCapture() のおかげで最後まで付いてきて、削除されました。

文字のボタンをクリックすると「(済)」になり、「×」のボタンは Tab でフォーカスして Enter で押すと削除できました。

行を引いたあとに、文字のボタンの onClick が呼ばれない理由

行の文字は button で、押すと済んだかどうかが切り替わります。行を引いて離したときにも click は起きますが、文字は切り替わりませんでした。click が届いた要素を記録すると、次のとおりです。

text
1行目を左へ100px引いて離す: 牛乳を買う, 本を返す, 記事の図を描く, 傘を持っていく, 植木に水をやる / 残り 5 件 / click の target: row-fg animate
1行目を左へ6px(向きを決める前)だけ動かして離す: 牛乳を買う(済), 本を返す, 記事の図を描く, 傘を持っていく, 植木に水をやる / 残り 5 件 / click の target: row-label

横に 100px 引いたときは、click は文字のボタン(row-label)ではなく、setPointerCapture() を呼んだ行の中身(row-fg)に届いていました。Pointer Events の仕様の「click, auxclick, and contextmenu」の節によると、ポインターをキャプチャしている間に pointerup が起きたとき、click はキャプチャしている要素に送られます。文字のボタンの onClick は呼ばれないので、引いたあとに切り替えを止める処理を足す必要はありませんでした。

一方、6px だけ動かして離したときは、まだ向きを決める前なのでキャプチャしていません。click は文字のボタンに届き、「(済)」になりました。押したあと少し動いても、10px(SLOP)未満なら文字のボタンの onClick は呼ばれます(マウスで確かめました。タッチのタップでは試していません)。

動きを減らす設定では transitionend が届かない

CSS では、OS の「動きを減らす」設定(prefers-reduced-motion)が有効なときに、transition を外しています。すると、行を消すときに頼っている transitionend が起きません。MDN の transitionend の説明にも、transition の時間がないときは transition のイベントは起きない、とあります。

そこで SwipeRow では、離したときに window.matchMedia() でこの設定を調べ、有効なら transitionend を待たずに onDelete を呼んでいます。Playwright の reducedMotion: 'reduce' で、この処理を外した版と比べました。

text
記事のコード: 1行目を左へ200px引いて離した直後: 本を返す, 記事の図を描く, 傘を持っていく, 植木に水をやる / 残り 4 件 / 1行目 translateX(0px)
  1秒後: 本を返す, 記事の図を描く, 傘を持っていく, 植木に水をやる / 残り 4 件
比べる用(transitionend を待つ版): 1行目を左へ200px引いて離した直後: 牛乳を買う, 本を返す, 記事の図を描く, 傘を持っていく, 植木に水をやる / 残り 5 件 / 1行目 translateX(-328px)
  1秒後: 牛乳を買う, 本を返す, 記事の図を描く, 傘を持っていく, 植木に水をやる / 残り 5 件

この処理を外した版では、行が左へ動いたまま(translateX(-328px))で、1秒たっても一覧から消えませんでした。記事のコードでは、離した直後に一覧から消えています。

touch-action を変えて、指で引いたときの違いを比べる

タッチでは、ブラウザは指の動きをスクロールや拡大に使います。MDN の touch-action の説明によると、ブラウザがタッチの操作を自分で扱い始めると、ページには pointercancel が送られます。touch-action は、ブラウザに任せる操作を要素ごとに指定するプロパティで、pan-y は1本指の縦のスクロールだけをブラウザに任せます。

.row-fg の touch-action を pan-y、auto(指定しないときと同じ)、none に変え、表示領域 360×240 の画面に CDP でタッチを送りました。1行目を指で左へ 200px 引く操作と、リストの上で指を上へ 140px 動かす操作です。

text
touch-action: pan-y: 1行目を指で左へ200px: 本を返す, 記事の図を描く, 傘を持っていく, 植木に水をやる / 残り 4 件 / イベント: pointerdown pointerup
touch-action: pan-y: 指で上へ140px: scrollY 77(最大 77) / イベント: pointerdown pointercancel
touch-action: auto: 1行目を指で左へ200px: 牛乳を買う, 本を返す, 記事の図を描く, 傘を持っていく, 植木に水をやる / 残り 5 件 / イベント: pointerdown pointercancel
touch-action: auto: 指で上へ140px: scrollY 134(最大 134) / イベント: pointerdown pointercancel
touch-action: none: 1行目を指で左へ200px: 本を返す, 記事の図を描く, 傘を持っていく, 植木に水をやる / 残り 4 件 / イベント: pointerdown pointerup
touch-action: none: 指で上へ140px: scrollY 0(最大 77) / イベント: pointerdown pointerup

auto では、横に引いた途中で pointercancel が届き、行は消えませんでした。SwipeRow は pointercancel を受け取ると行を元の位置へ戻すので、スワイプが途中で打ち切られた形になります。none では横のスワイプは効きますが、指を上へ動かしてもページがスクロールしませんでした(scrollY が 0 のまま)。リストの上で指を動かすとスクロールできない画面になってしまいます。

pan-y では、横に引くと行が消え、縦に動かすとページがスクロールしました(この比べ方は Chromium にタッチを送ったもので、実機のスマートフォンでは試していません)。縦のときは pointercancel が届き、行は動かないまま終わります。scrollY の最大値が auto と pan-y で違うのは、pan-y の画面では先に1行消えて、ページが短くなっているためです。

ポイント

スワイプする要素には touch-action: pan-y を指定します。横の動きはページのコードで扱い、縦のスクロールはブラウザに任せられます。

​Posted by 86