アコーディオンリスト UI 9選 — チェックリストまで
アコーディオンリストとは、行をクリックするとその場で高さがなめらかに伸びて詳細内容が開くUIで、別ページに移動せずリスト画面の中で情報を増やしたいときに使います。タップすると行がふわっと開くあの感じを、実際に動くコードとして九つ用意しました。
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 行のインライン展開
- 02 グループ固定セクションヘッダー
- 03 リスト→詳細スライド遷移
- 04 もっと見るボタンリスト
- 05 ロングプレスアクションメニュー
- 06 ドロップ位置表示の並び替え
- 07 リストのインライン編集
- 08 アクティビティタイムライン
- 09 チェックリスト取り消し線
九つは人気順ではなく、リストの一行が実際にたどる順番で並べています。まず行を一つ開いて詳細を見る一番基本の動き(01)から始め、リストをグループにまとめてスクロールを助ける動き(02)、画面ごと切り替わる動き(03)へ進みます。続いてデータをさらに読み込むとき(04)、あまり使わない操作を隠しておくとき(05)、順番そのものを変えるとき(06)、値をその場で直すとき(07)、過去の出来事を記録するとき(08)、最後にやることを完了にするとき(09)の順です。
01行のインライン展開
注文履歴のような行をクリックすると矢印アイコンが90度回転し、その下に詳細情報が現れます。
.rowx-item__body {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows .3s cubic-bezier(.2,.8,.2,1);
}
.rowx-item.is-open .rowx-item__body { grid-template-rows: 1fr; }
.rowx-item__body-inner { overflow: hidden; }
height: autoはアニメーションできませんが、grid-template-rowsは0frから1frへなめらかに補間されるため、中身の高さが事前にわからなくても自然に開きます。
02グループ固定セクションヘッダー
連絡先アプリのように頭文字でまとめたリストで、今スクロールしているグループの見出しが上部に固定され続け、次のグループが来ると押し上げられます。
.grp-group__header {
position: sticky;
top: 0;
z-index: 1;
background: #ff9ebb;
}
position: stickyは親要素の高さの中でしか固定されないため、グループごとに囲む要素を分けて初めて次のグループが前のヘッダーを押し出せます。
03リスト→詳細スライド遷移
リスト項目を押すとリストパネルが左へスライドし、詳細パネルが右から入ってくる、設定画面でよく見る遷移です。
.ltd-track {
display: flex;
width: 200%;
transition: transform .3s cubic-bezier(.2,.8,.2,1);
}
.ltd-viewport.is-open .ltd-track { transform: translateX(-50%); }
リストと詳細を並べた一本のtrackをまとめて動かすので、パネルごとに別々にアニメーションせず一つのtransformだけで済みます。
04もっと見るボタンリスト
もっと見るを押すと一瞬スピナーが回り、新しい3行が一度にではなく順番に少しずつ下からせり上がって現れます。
btn.addEventListener('click', () => {
list.classList.add('is-loading');
setTimeout(() => {
list.classList.remove('is-loading');
list.classList.add('has-loaded');
}, 500);
});
3行は最初max-height: 0で畳まれていて、行ごとにtransition-delayを少しずつ変えることで、一斉にではなく順番に現れます。
05ロングプレスアクションメニュー
行を0.5秒以上長押しすると、お気に入り・アーカイブ・削除のアイコンメニューが弾むように現れます。
row.addEventListener('pointerdown', (e) => {
if (e.target.closest('button')) return;
timer = setTimeout(openMenu, 500);
});
['pointerup', 'pointerleave'].forEach((ev) =>
row.addEventListener(ev, () => clearTimeout(timer))
);
指を離すとタイマーがすぐ解除されるため、スクロールのつもりで軽く触れただけではメニューは開きません。長押しが難しい人のために、常に見える「···」ボタンでも同じメニューを開けます。
06ドロップ位置表示の並び替え
六点ハンドルをドラッグすると、項目をただ入れ替えるのではなく、置かれる場所に青い帯状の線が先に現れて位置を示します。ハンドルにフォーカスしてAlt+矢印キーでも同じ並び替えができます。
handle.addEventListener('keydown', (e) => {
if (!e.altKey || (e.key !== 'ArrowUp' && e.key !== 'ArrowDown')) return;
e.preventDefault();
const targetIdx = order().indexOf(item) + (e.key === 'ArrowUp' ? -1 : 1);
list.insertBefore(item, order()[targetIdx]);
});
マウスのドラッグに頼らず、ハンドルにフォーカスしてAlt+矢印キーだけで同じ結果を作れることが、並び替えリストをアクセシブルにする条件です。
07リストのインライン編集
名前欄をクリックするとその場で入力欄に変わり、Enterか外側クリックで保存され、鉛筆アイコンがチェックに変わります。
input.addEventListener('click', () => { if (input.readOnly) startEdit(); });
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') save();
else if (e.key === 'Escape') cancel();
});
input.addEventListener('blur', () => { if (!input.readOnly) save(); });
contenteditableの代わりに普通の<input readonly>を使うと、値の検証やEscapeでの取り消しをinput標準の動作にそのまま任せられ、コードが短くなります。
08アクティビティタイムライン
縦線に沿ったアイコンの点が、上から順に少しずつふわっと浮き上がるように現れます。
.tl-item {
opacity: 0;
transform: translateY(10px);
animation: tl-in .6s ease-out forwards;
animation-delay: calc(var(--n) * 140ms);
}
@keyframes tl-in { to { opacity: 1; transform: translateY(0); } }
各項目に--nカスタムプロパティで順番を渡しcalc()で遅延を計算すれば、項目数がいくつでもループなしで順番に浮き上がらせられます。
09チェックリスト取り消し線
チェックボックスを押すと色が満ちてチェックが飛び出し、テキストには左から右へ取り消し線が引かれて薄くなります。
.ck-item__text::after {
content: "";
position: absolute; inset: 50% 0 auto 0; height: 2px;
background: #ff9ebb;
transform: scaleX(0);
transform-origin: left;
transition: transform .3s;
}
.ck-item.is-checked .ck-item__text::after { transform: scaleX(1); }
text-decoration-lineはブラウザによってアニメーションが滑らかでないため、代わりに疑似的な下線をscaleX(0)からscaleX(1)へ伸ばす方法を使います。transform-origin: leftで起点を左に固定するので、線は左から引かれます。
アコーディオンリストは、どこで壊れるか
01番の行展開を作る中で実際にぶつかった落とし穴は、grid-template-rowsをいきなりautoへアニメーションしようとして失敗したことです——autoは補間できる始点でも終点でもないため、ふわっと開かず一瞬で切り替わってしまいます。0frと1frのようなグリッド比率の値どうしでアニメーションする必要があります。二つ目は04番のもっと見るボタンで、新しい行をdisplay: noneで隠しておいてからクラスを切り替えると、ブラウザがレイアウトを計算する前に切り替えが終わってしまい、アニメーションなしで突然現れました——max-height: 0のように実際にはレンダリングされつつサイズだけ0の状態から始める必要があります。この九つのファイルの圧縮パスワードはpw5ht9pcで、解凍するとvanilla・reactフォルダで同じ九つの動きを並べて比較できます。
ほかのリスト・表のテーマは実用UIカテゴリに、スワイプ・並び替え・ページネーションを扱うもう一つの九選は9 Swipe to Delete List UI Patternsに、このサイトが何を扱っているかはサイト紹介にまとまっています。grid-template-rowsがfr単位をどう補間するかはMDNのgrid-template-rowsページに、position: stickyの効く範囲はMDNのpositionページにまとまっています。
アクセシビリティ — 九つがreduced-motionで何をするか
システムで動きを減らす設定をオンにすると、九つのデモはすべてtransitionとanimationを止め、最終状態だけをすぐに表示します。01・03番のように高さや位置が変わる項目は展開・遷移後の状態へ直接移り、05・09番のように弾む感じ自体が要点の項目はその感じだけが消えて結果(メニューが開く、チェックが付く)は残ります。06番の並び替えと07番のインライン編集はもともとキーボードだけで操作できるため、動作そのものは変わりません。
FAQ
自作せずHTMLの<details>要素を使ってもいいですか?
<details>・<summary>は開閉そのものは標準で提供されますが、高さのアニメーションは自分で追加する必要があり、既定の見た目もブラウザによって違います。そのため九つのデモではボタンとgrid-template-rowsでより細かく制御しました。アクセシビリティを優先するなら<details>の上にスタイルを重ねる方法も選択肢です。
リストの項目が数百個に増えても、このコードはそのまま使えますか?
01・02・08番のように表示中の行すべてにアニメーションが付く場合、項目が数百個規模になると仮想スクロール(画面に見える分だけ描画する手法)のような追加の最適化が必要です。zipのコードは1画面に収まる数十個規模を前提にしています。
ロングプレスメニューはモバイルでも同じように動きますか?
pointerdown・pointerupイベントはマウス・タッチ・ペンを同じコードで扱うため、そのまま動作します。ただしモバイルブラウザは長押しで独自のコンテキストメニューやテキスト選択が出ることがあるため、デモのコードはtouch-action: manipulationでその競合を防いでいます。