スワイプ削除リストUI 9選 — ページネーションまで
スワイプ削除とは、リストの行を横に押すと裏に隠れていた削除ボタンが現れる動きのことで、アプリで見たあの操作をウェブでもそのまま使いたい人は多いです。この記事では、そのスワイプ削除を含むリストのインタラクションを9個、実際に動くコードでまとめました。
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 スワイプ削除リビール
- 02 スワイプ複数アクション
- 03 プルリフレッシュ
- 04 ドラッグハンドルで並び替え
- 05 クリックソートヘッダー
- 06 固定ヘッダー + 固定列
- 07 スケルトンローディング行
- 08 一括選択ツールバー
- 09 数字ページネーション
9個は人気順ではなく、リストがたどる順番で並べました。まず1行だけを扱う手の動き(01・02のスワイプ、03の引っ張って更新)、次にリスト全体を扱う操作(04・05の並び替え、06の固定表示)、最後に項目が増えすぎたときに必要なもの(07の読み込み中表示・08の一括選択・09のページ分割)という順です。
01スワイプ削除リビール
行を左にスワイプすると赤い削除ボタンが裏から現れ、半分以上スワイプすると行ごと自動で消えます。ボタンは常にDOM上にある本物の<button>なので、Tabキーで移動してDeleteキーを押しても同じように削除できます。
// pointermove で --dx を直接書き換え、離した瞬間に2つのしきい値で判定する
content.addEventListener('pointermove', function (e) {
dx = Math.min(0, Math.max(-170, e.clientX - startX));
content.style.transform = 'translateX(' + dx + 'px)';
});
function end() {
if (dx < -140) { removeRow(); } // 削除のしきい値を超えた
else content.style.transform = dx < -44 ? 'translateX(-88px)' : '';
}
02スワイプ複数アクション
さらにスワイプするほど、お気に入り・アーカイブ・削除ボタンが順番に横から現れて並びます。3つとも本物のボタンなので、行にTabキーでフォーカスを移すだけで、キーボード操作でも同じボタンが現れます。
.action {
opacity: 0;
transform: translateY(8px);
transition: opacity .22s, transform .22s;
}
// ホバーだけでなくキーボードフォーカスが行に入ったときも同じ3つのボタンが現れる
.row:focus-within .action { opacity: 1; transform: translateY(0); }
.action--fav { animation-delay: 0ms; }
.action--archive { animation-delay: 80ms; }
.action--del { animation-delay: 160ms; }
03プルリフレッシュ
リストの一番上から下に引っ張るとゴムのような抵抗で伸び、手を離すとスピナーが回って更新されます。表示されている更新ボタンを押しても同じアニメーションが起きるので、指で引っ張る操作ができない人にも使えます。
function end() {
if (dy > 44) refresh(); // 半分以上引っ張った
else body.style.transform = ''; // それ以外は元の位置へ戻る
}
function refresh() {
body.style.transform = 'translateY(56px)';
setTimeout(function () { body.style.transform = ''; }, 900);
}
04ドラッグハンドルで並び替え
行の左にある六点ハンドルをつかんで上下に動かすと、通過する行が場所を譲って順番が入れ替わります。ポインターを使わなくても、ハンドルにフォーカスを合わせて矢印キーを押すだけで同じように並び替えられます。
handle.addEventListener('keydown', function (e) {
var sib = e.key === 'ArrowDown' ? row.nextElementSibling
: e.key === 'ArrowUp' ? row.previousElementSibling : null;
if (!sib) return;
e.preventDefault();
e.key === 'ArrowDown' ? list.insertBefore(sib, row) : list.insertBefore(row, sib);
handle.focus({ preventScroll: true }); // iframe内の親ページが強制スクロールされないようにする
});
05クリックソートヘッダー
列見出しをクリックすると矢印が反転し、その列を基準に昇順・降順が交互に並び替わります。並び替えの状態はaria-sortにも渡されるので、矢印の向きが見えない人にも同じ情報がスクリーンリーダー経由で伝わります。
btn.addEventListener('click', function () {
var dir = btn.getAttribute('aria-sort') === 'ascending' ? 'descending' : 'ascending';
ths.forEach(function (b) { b.setAttribute('aria-sort', 'none'); });
btn.setAttribute('aria-sort', dir);
rows.sort(function (a, b) {
var cmp = a[key] > b[key] ? 1 : a[key] < b[key] ? -1 : 0;
return dir === 'ascending' ? cmp : -cmp;
}).forEach(function (r) { tbody.appendChild(r); });
});
06固定ヘッダー + 固定列
表を縦横にスクロールしても、一番上のヘッダー行と一番左の列は画面に固定されたままついてきます。position: stickyにtopとleftを両方与え、その2つが重なる一つのセルだけz-indexをもう一段上げれば実現できます。
// ヘッダー行 — 縦にスクロールしても上に固定される
thead th {
position: sticky;
top: 0;
z-index: 2;
}
// 一番左の列 — 横にスクロールしても左に固定される
tbody th[scope="row"] {
position: sticky;
left: 0;
z-index: 1;
}
// 両方が重なる角のセルだけさらに上のレイヤーに置く
.corner { position: sticky; top: 0; left: 0; z-index: 3; }
07スケルトンローディング行
データが来る前、グレーのバー行が左右に光を流し、コンテンツが入る場所を先に見せます。バーの地色と光る帯の明るさの差を大きく取るほど、スクリーンショットやgifでも光が流れているのがはっきり見えます。
.bar {
background: linear-gradient(90deg, #d8d8de 25%, rgba(255, 255, 255, .95) 50%, #d8d8de 75%);
background-size: 200% 100%;
animation: shimmer 1.4s linear infinite;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
08一括選択ツールバー
行のチェックボックスを一つでも選ぶと、画面下から削除・移動などの一括操作ツールバーがスライドで現れます。一部の行だけ選ばれているときは、全選択チェックボックスがindeterminate状態に変わり、「全部ではない」ことを見た目で伝えます。
function sync() {
var n = items.filter(function (c) { return c.checked; }).length;
all.checked = n === items.length;
all.indeterminate = n > 0 && n < items.length; // 一部だけ選ばれている状態を区別する
list.classList.toggle('has-selection', n > 0); // ツールバーをスライドで見せるトリガー
}
09数字ページネーション
ページ番号ボタンが並び、ページ数が増えると中央が三点リーダーに折りたたまれ、現在のページだけが強調されます。このデモは全8ページなので、最初・最後・現在のページの隣だけが番号のまま残ります。
function pages(total, current) {
var out = [];
for (var p = 1; p <= total; p++) {
// 最初・最後のページか、現在のページの隣なら番号を残す
if (p === 1 || p === total || Math.abs(p - current) <= 1) {
out.push(p);
} else if (out[out.length - 1] !== '...') {
out.push('...'); // 連続する飛ばし区間は点ひとつにまとめる
}
}
return out;
}
リストのスワイプ・並び替え、どこで壊れるか
01・02番はどちらもドラッグが始まった瞬間にsetPointerCaptureを呼んでいます。この一行を忘れると(うっかり抜けやすい部分です)、指がボタンの外へ少しでも出た瞬間にpointermoveが途切れ、理由もわからずスワイプが途中で止まってしまいます。もう一つの落とし穴は08番のツールバーを作る過程で実際に踏んだものです — チェックが一つもないときは画面外に隠れているはずの要素なのに、overflow: hiddenで囲む前のコードでこの480×300のデモを320px幅で描画し直したところ、document.documentElement.scrollHeightがビューポートより大きくなり、見た目は変わらないのにページだけが伸びていました。今このzipに入っているのは、リスト全体をoverflow: hiddenで囲んで直したあとのコードです。この9個のファイルをまとめたzipの合言葉はkjj4ftraで、解凍するとvanilla・reactの両方が同じ構成で並んでいるので見比べられます。
アクセシビリティ — 9個がreduced-motionで残すもの
「動きを減らす」設定が有効な人に対して、01〜06・08番はtransition: noneで切り替えの時間だけをなくし、スワイプ・並び替え・固定という結果自体はそのまま起きますが瞬時に切り替わります。07番(スケルトン)だけは扱いが違い、光が流れるアニメーションを完全には止めず、代わりに周期を3.2秒まで伸ばして、読み込み中という合図は残しつつちらつきを大きく減らします。09番(ページネーション)はもともと色が変わる切り替えしかないので、reduced-motion環境ではその切り替えもtransition: noneで瞬時になります。9個ともフォーカス表示が残っているので、マウスなしでキーボードだけで最後まで操作できます。
ほかのリスト・表のテーマは実用UIカテゴリーにまとまっていて、このサイトが何を扱っているかはサイト紹介にあります。position: stickyがどのスクロールコンテナを基準にするかはMDNのリファレンスに、01・02番が使うsetPointerCaptureはMDNのPointer events文書にまとまっています。
FAQ — よくある質問
スワイプ削除はライブラリなしで本当に作れますか?
はい。ここでの01・02番はpointerdown・pointermove・pointerupの3つのイベントとtransform: translateX()だけで作られていて、別のジェスチャーライブラリは使っていません。
なぜtop・leftではなくtransformで動かすのですか?
top・left・heightの値を変えると、ブラウザが毎フレームレイアウトを計算し直すことになり、ドラッグやスワイプの最中にカクつきやすくなります。transformとopacityだけを動かせば、レイアウト計算なしで合成レイヤーだけが更新されるので指の動きに滑らかについてきます。
vanillaとreactのコードは違いますか?
動き自体は同じです。vanillaのデモはギャラリー用にis-demoクラスで絶え間なく繰り返し再生しますが、ReactコンポーネントはuseState・useRefを使って実際のpointer・clickイベントにだけ反応します。