08一括選択ツールバー
行のチェックボックスを一つでも選ぶと、画面下から削除・移動などの一括操作ツールバーがスライドで現れる。
使いどころメールの一括削除、管理画面リストの一括操作
indeterminate checkboxtranslateY(120%)selectall 동기화

<main class="stage">
<div class="bulk-list is-demo">
<label class="bulk-list__row bulk-list__row--all">
<input type="checkbox" class="bulk-list__selectall">
<span>すべて選択</span>
</label>
<label class="bulk-list__row">
<input type="checkbox" class="bulk-list__item">
<span>2026年第1四半期レポート.pdf</span>
</label>
<label class="bulk-list__row">
<input type="checkbox" class="bulk-list__item">
<span>デザイン案v3.fig</span>
</label>
<div class="bulk-list__toolbar">
<span class="bulk-list__count"><span class="bulk-list__count-num">0</span>件選択中</span>
<button type="button">移動</button>
<button type="button">削除</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base; // 툴바가 올라오는 시간
$easing: $ease-spring;
$color: $subject-blue; // 체크박스·툴바 강조 색
@include stage($stage-paper);
.bulk-list {
position: relative;
overflow: hidden;
width: min(240px, 78vw);
display: flex;
flex-direction: column;
gap: $sp-2;
padding-bottom: $sp-10;
}
.bulk-list__row {
display: flex;
align-items: center;
gap: $sp-2;
padding: $sp-2 $sp-3;
border-radius: $r-control;
background: $subject-cream;
font-size: 12px;
color: $stage-ink;
cursor: pointer;
}
.bulk-list__row--all { background: none; font-weight: 700; padding-left: $sp-3; }
.bulk-list input[type="checkbox"] {
width: 16px;
height: 16px;
accent-color: $color;
}
.bulk-list input[type="checkbox"]:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.bulk-list__toolbar {
position: absolute;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
gap: $sp-2;
padding: $sp-2 $sp-3;
border-radius: $r-control;
background: $stage-ink;
color: $stage-paper;
transform: translateY(0); // 정지 상태는 보이는 자리 — 반복 재생은 .is-demo 에서만
transition: transform $duration $easing;
button {
margin-left: auto;
border: 0;
border-radius: $r-pill;
padding: $sp-1 $sp-3;
background: $color;
color: $stage-paper;
font-size: 11px;
font-weight: 700;
font-family: inherit;
cursor: pointer;
& + button { margin-left: $sp-1; background: $error; }
}
}
.bulk-list__count { font-size: 11px; }
.bulk-list.has-selection .bulk-list__toolbar { transform: translateY(0); }
.bulk-list.is-demo .bulk-list__toolbar { animation: bulk-toolbar-loop $dur-loop $easing infinite; }
@keyframes bulk-toolbar-loop {
0%, 15% { transform: translateY(120%); }
45%, 65% { transform: translateY(0); }
95%, 100% { transform: translateY(120%); }
}
@media (prefers-reduced-motion: reduce) {
.bulk-list__toolbar { transition: none; }
.bulk-list.is-demo .bulk-list__toolbar { animation: none; transform: translateY(0); }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.bulk-list {
position: relative;
overflow: hidden;
width: min(240px, 78vw);
display: flex;
flex-direction: column;
gap: 8px;
padding-bottom: 40px;
}
.bulk-list__row {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-radius: 12px;
background: #fff7e6;
font-size: 12px;
color: #17141a;
cursor: pointer;
}
.bulk-list__row--all {
background: none;
font-weight: 700;
padding-left: 12px;
}
.bulk-list input[type=checkbox] {
width: 16px;
height: 16px;
accent-color: #2f6df6;
}
.bulk-list input[type=checkbox]:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.bulk-list__toolbar {
position: absolute;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-radius: 12px;
background: #17141a;
color: #ffffff;
transform: translateY(0);
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.bulk-list__toolbar button {
margin-left: auto;
border: 0;
border-radius: 999px;
padding: 4px 12px;
background: #2f6df6;
color: #ffffff;
font-size: 11px;
font-weight: 700;
font-family: inherit;
cursor: pointer;
}
.bulk-list__toolbar button + button {
margin-left: 4px;
background: #c2361a;
}
.bulk-list__count {
font-size: 11px;
}
.bulk-list.has-selection .bulk-list__toolbar {
transform: translateY(0);
}
.bulk-list.is-demo .bulk-list__toolbar {
animation: bulk-toolbar-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes bulk-toolbar-loop {
0%, 15% {
transform: translateY(120%);
}
45%, 65% {
transform: translateY(0);
}
95%, 100% {
transform: translateY(120%);
}
}
@media (prefers-reduced-motion: reduce) {
.bulk-list__toolbar {
transition: none;
}
.bulk-list.is-demo .bulk-list__toolbar {
animation: none;
transform: translateY(0);
}
}(function () {
var list = document.querySelector('.bulk-list');
var all = list.querySelector('.bulk-list__selectall');
var items = Array.prototype.slice.call(list.querySelectorAll('.bulk-list__item'));
var count = list.querySelector('.bulk-list__count-num');
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);
list.classList.remove('is-demo');
count.textContent = String(n);
}
all.addEventListener('change', function () {
items.forEach(function (c) { c.checked = all.checked; });
sync();
});
items.forEach(function (c) { c.addEventListener('change', sync); });
})();import { useEffect, useMemo, useRef, useState, type CSSProperties } from "react";
import styles from "./BulkSelectToolbar.module.scss";
export type BulkSelectToolbarProps = {
files: string[];
/** 툴바가 올라오는 시간 (기본 300ms) */
duration?: number;
easing?: string;
/** 체크박스·툴바 강조 색 */
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function BulkSelectToolbar({
files,
duration = 300,
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#2f6df6",
}: BulkSelectToolbarProps) {
const [checked, setChecked] = useState<boolean[]>(files.map(() => false));
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const count = useMemo(() => checked.filter(Boolean).length, [checked]);
const allChecked = count === files.length;
const someChecked = count > 0 && !allChecked;
const allRef = useRef<HTMLInputElement | null>(null);
// .indeterminate 는 DOM 프로퍼티일 뿐 HTML 속성이 아니라서 JSX 에 그대로 못 준다 — ref 로 직접 써야 한다
// (vanilla 판의 all.indeterminate = n > 0 && n < items.length 와 동일한 지점)
useEffect(() => {
if (allRef.current) allRef.current.indeterminate = someChecked;
}, [someChecked]);
const toggleOne = (i: number) => {
const next = checked.slice();
next[i] = !next[i];
setChecked(next);
};
const toggleAll = () => setChecked(files.map(() => !allChecked));
return (
<div className={`${styles.list} ${count > 0 ? styles.hasSelection : ""}`} style={vars}>
<label className={`${styles.row} ${styles.rowAll}`}>
<input ref={allRef} type="checkbox" checked={allChecked} onChange={toggleAll} />
<span>전체 선택</span>
</label>
{files.map((f, i) => (
<label className={styles.row} key={f}>
<input type="checkbox" checked={checked[i]} onChange={() => toggleOne(i)} />
<span>{f}</span>
</label>
))}
<div className={styles.toolbar}>
<span className={styles.count}>{count}개 선택됨</span>
<button type="button">이동</button>
<button type="button">삭제</button>
</div>
</div>
);
}08 一括選択ツールバー — 計測 累積面積 6.635% · 動いたフレーム 11/23GODRICH オリジナル · SCSS と React は zip に同梱 · スワイプ削除リストUI 9選 — ページネーションまで
このデモが属する記事
スワイプ削除リストUI 9選 — ページネーションまでスワイプ削除とは、リストの行を横に押すと裏に隠れていた削除ボタンが現れる動きのことで、アプリで見たあの操作をウェブでもそのまま使いたい人は多いです。この記事では、そのスワイプ削除を含むリストのインタラクションを9個、実際に動くコードでまとめ この記事のデモをすべて見る


