Select-all where the change propagates like a domino run: flip the master and a wavefront tips down the list, each row's checkbox flipping and shoving the next — click any row mid-run to halt the wave there, leaving the rest untouched.
npx shadcn add https://design.helpmarq.com /r/checkbox-domino-run.jsonregistry/core/checkbox-domino-run/component.tsx"use client";
import {
useCallback,
useEffect,
useId,
useRef,
useState,
type ChangeEvent,
} from "react";
// ---------------------------------------------------------------------------
// ToppleRun — a select-all whose propagation is the interaction model, not a
// cosmetic stagger. Flipping the master checkbox releases a wavefront that
// travels down the list at a fixed rate (14 rows/sec): as the front reaches
// row i, that row's own checkbox commits its new state under a 120ms
// spring-eased transition and the row takes a transient translateY(2px)
// lean toward row i+1 — read as the wave shoving the next domino — handed
// off with a short overlap so consecutive leans read as continuous contact.
// A thin tick travels the list's left edge marking the front's live
// position. Every row commits its state the instant the front passes it, so
// there is nothing to roll back: stopping the run just stops future rows
// from being touched.
//
// INTERRUPTION: the run is spatially interruptible, not just cancelable —
// activating ANY row (pointer or keyboard, since every row stays a fully
// operable checkbox for the run's entire duration) halts the wave
// immediately. Rows the front already passed keep their committed state;
// rows beyond the front are simply untouched, standing exactly as they
// were. Escape halts it too. This is the "all except a few" recovery path:
// watch the run, stop it where you want, no undo needed because nothing
// past that point was ever touched.
//
// A11Y: the master is a real input[type=checkbox] with aria-controls
// pointing at the row list's id; a polite live region announces once at
// the start ("Enabling all…") and once at the end or halt ("Enabled 34 of
// 40, stopped at row 35."). Every row is a real checkbox with its own
// native label association — nothing here is conveyed only by animation.
//
// REDUCED MOTION: there is no wave to catch, so interruption is replaced by
// a straightforward safety net — the master flips every row in a single
// frame and a 5s Undo affordance appears, reverting the whole batch if
// pressed in time.
//
// TOKENS: --foreground for ink (checkbox fill, the traveling tick),
// --border for hairlines, --muted for secondary text, --accent for the
// focus ring only. Pure DOM/CSS, no canvas.
// ---------------------------------------------------------------------------
const ROWS_PER_SEC = 14;
const ROW_H = 56; // px — must match the row wrapper's fixed height
const LEAN_MS = Math.round(1000 / ROWS_PER_SEC) + 40; // ~111ms, overlaps the next row's start
const UNDO_MS = 5000;
export interface ToppleRunItem {
/** Stable identifier for this row. */
id: string;
/** Primary row label — also becomes its checkbox's accessible name. */
label: string;
/** Optional secondary line (sender, timestamp, byline...). */
description?: string;
}
export interface ToppleRunProps {
/** Rows the master toggle governs, top to bottom = wave direction. */
items: ToppleRunItem[];
/** Label for the master control. @default "Select all" */
label?: string;
/** Ids checked on mount. @default [] */
defaultChecked?: string[];
/** Fires whenever the checked set changes, including mid-run commits. */
onChange?: (checkedIds: string[]) => void;
className?: string;
}
function useReducedMotion() {
const [reduced, setReduced] = useState(false);
useEffect(() => {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
const onChange = () => setReduced(mq.matches);
onChange();
mq.addEventListener("change", onChange);
return () => mq.removeEventListener("change", onChange);
}, []);
return reduced;
}
interface Row extends ToppleRunItem {
checked: boolean;
}
export function ToppleRun({
items,
label = "Select all",
defaultChecked = [],
onChange,
className = "",
}: ToppleRunProps) {
const uid = useId();
const reducedMotion = useReducedMotion();
const [rows, setRows] = useState<Row[]>(() =>
items.map((item) => ({
...item,
checked: defaultChecked.includes(item.id),
}))
);
const [running, setRunning] = useState(false);
const [liveMsg, setLiveMsg] = useState("");
const [undoAvailable, setUndoAvailable] = useState(false);
const masterRef = useRef<HTMLInputElement | null>(null);
const rowElRefs = useRef<(HTMLLIElement | null)[]>([]);
const tickRef = useRef<HTMLDivElement | null>(null);
const rafRef = useRef<number | undefined>(undefined);
const runTokenRef = useRef(0);
const committedRef = useRef(-1);
const startTimeRef = useRef(0);
const targetRef = useRef(true);
const leanTimersRef = useRef<Map<number, ReturnType<typeof setTimeout>>>(
new Map()
);
const undoSnapshotRef = useRef<Row[] | null>(null);
const undoTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(
undefined
);
const total = rows.length;
const checkedCount = rows.reduce((n, r) => (r.checked ? n + 1 : n), 0);
const allChecked = total > 0 && checkedCount === total;
const noneChecked = checkedCount === 0;
const indeterminate = !allChecked && !noneChecked;
useEffect(() => {
if (masterRef.current) masterRef.current.indeterminate = indeterminate;
}, [indeterminate]);
const firstRender = useRef(true);
useEffect(() => {
if (firstRender.current) {
firstRender.current = false;
return;
}
onChange?.(rows.filter((r) => r.checked).map((r) => r.id));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [rows]);
const clearLeanTimers = useCallback(() => {
leanTimersRef.current.forEach((t) => clearTimeout(t));
leanTimersRef.current.clear();
}, []);
const commitRow = useCallback((index: number, checked: boolean) => {
setRows((prev) => {
if (prev[index]?.checked === checked) return prev;
const next = prev.slice();
next[index] = { ...next[index]!, checked };
return next;
});
const el = rowElRefs.current[index];
if (el) {
el.style.transform = "translateY(2px)";
const prevTimer = leanTimersRef.current.get(index);
if (prevTimer) clearTimeout(prevTimer);
leanTimersRef.current.set(
index,
setTimeout(() => {
el.style.transform = "";
leanTimersRef.current.delete(index);
}, LEAN_MS)
);
}
}, []);
const endRun = useCallback(
(committedCount: number, rowTotal: number) => {
if (rafRef.current !== undefined) {
cancelAnimationFrame(rafRef.current);
rafRef.current = undefined;
}
setRunning(false);
if (tickRef.current) tickRef.current.style.opacity = "0";
const verb = targetRef.current ? "Enabled" : "Disabled";
if (rowTotal === 0) {
setLiveMsg("");
} else if (committedCount >= rowTotal) {
setLiveMsg(`${verb} all ${rowTotal}.`);
} else {
setLiveMsg(
`${verb} ${committedCount} of ${rowTotal}, stopped at row ${
committedCount + 1
}.`
);
}
},
[]
);
const haltRun = useCallback(() => {
if (!running) return;
runTokenRef.current += 1;
endRun(committedRef.current + 1, total);
}, [running, endRun, total]);
const startRun = useCallback(
(target: boolean) => {
if (total === 0) return;
if (rafRef.current !== undefined) cancelAnimationFrame(rafRef.current);
clearLeanTimers();
runTokenRef.current += 1;
const token = runTokenRef.current;
targetRef.current = target;
committedRef.current = -1;
startTimeRef.current = performance.now();
setRunning(true);
setLiveMsg(target ? "Enabling all…" : "Disabling all…");
if (tickRef.current) {
tickRef.current.style.opacity = "1";
tickRef.current.style.transform = "translateY(0px)";
}
const step = (now: number) => {
if (runTokenRef.current !== token) return;
const elapsedS = (now - startTimeRef.current) / 1000;
const front = elapsedS * ROWS_PER_SEC;
if (tickRef.current) {
tickRef.current.style.transform = `translateY(${
Math.min(front, total) * ROW_H
}px)`;
}
while (committedRef.current + 1 < total && committedRef.current + 1 <= front) {
const i = committedRef.current + 1;
commitRow(i, target);
committedRef.current = i;
}
if (committedRef.current >= total - 1) {
endRun(total, total);
return;
}
rafRef.current = requestAnimationFrame(step);
};
rafRef.current = requestAnimationFrame(step);
},
[total, clearLeanTimers, commitRow, endRun]
);
// Escape halts a live run from anywhere.
useEffect(() => {
if (!running) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") haltRun();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [running, haltRun]);
useEffect(() => {
return () => {
if (rafRef.current !== undefined) cancelAnimationFrame(rafRef.current);
clearLeanTimers();
if (undoTimerRef.current) clearTimeout(undoTimerRef.current);
};
}, [clearLeanTimers]);
const handleMasterChange = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
const target = e.target.checked;
if (total === 0) return;
if (reducedMotion) {
if (undoTimerRef.current) clearTimeout(undoTimerRef.current);
undoSnapshotRef.current = rows.map((r) => ({ ...r }));
setRows((prev) => prev.map((r) => ({ ...r, checked: target })));
setLiveMsg(
target ? `Enabled all ${total}.` : `Disabled all ${total}.`
);
setUndoAvailable(true);
undoTimerRef.current = setTimeout(() => {
setUndoAvailable(false);
undoSnapshotRef.current = null;
}, UNDO_MS);
return;
}
startRun(target);
},
[total, reducedMotion, rows, startRun]
);
const handleRowChange = useCallback(
(index: number, checked: boolean) => {
if (running) haltRun();
setRows((prev) => {
const next = prev.slice();
next[index] = { ...next[index]!, checked };
return next;
});
},
[running, haltRun]
);
const handleUndo = useCallback(() => {
if (undoTimerRef.current) clearTimeout(undoTimerRef.current);
if (undoSnapshotRef.current) {
const snapshot = undoSnapshotRef.current;
setRows(snapshot);
setLiveMsg("Undone.");
}
setUndoAvailable(false);
undoSnapshotRef.current = null;
}, []);
const groupId = `checkbox-domino-run-${uid}-group`;
const liveId = `checkbox-domino-run-${uid}-live`;
return (
<div className={`w-full rounded-md border border-border bg-surface ${className}`}>
<div id={liveId} aria-live="polite" className="sr-only">
{liveMsg}
</div>
<label className="relative flex h-14 cursor-pointer select-none items-center gap-3 border-b border-border px-4">
<input
ref={masterRef}
type="checkbox"
className="peer absolute inset-0 z-10 h-full w-full cursor-pointer opacity-0 focus:outline-none"
checked={allChecked}
onChange={handleMasterChange}
aria-controls={groupId}
/>
<CheckboxVisual
checked={allChecked}
indeterminate={indeterminate}
reducedMotion={reducedMotion}
/>
<span className="flex-1 text-[13px] font-medium text-foreground">
{label}
</span>
<span aria-hidden="true" className="font-mono text-[11px] text-muted">
{checkedCount}/{total}
</span>
</label>
<div className="relative">
<div
ref={tickRef}
aria-hidden="true"
className="pointer-events-none absolute left-0 top-0 z-10 w-[2px] bg-foreground opacity-0"
style={{ height: ROW_H, willChange: "transform" }}
/>
<ul id={groupId}>
{rows.map((row, i) => (
<li
key={row.id}
ref={(el) => {
rowElRefs.current[i] = el;
}}
style={{
height: ROW_H,
transition: reducedMotion
? undefined
: `transform ${LEAN_MS}ms cubic-bezier(0.34, 1.56, 0.64, 1)`,
}}
className={i < rows.length - 1 ? "border-b border-border" : ""}
>
<label className="relative flex h-full cursor-pointer select-none items-center gap-3 px-4">
<input
type="checkbox"
className="peer absolute inset-0 z-10 h-full w-full cursor-pointer opacity-0 focus:outline-none"
checked={row.checked}
onChange={(e) => handleRowChange(i, e.target.checked)}
/>
<CheckboxVisual
checked={row.checked}
reducedMotion={reducedMotion}
/>
<span className="min-w-0 flex-1">
<span className="block truncate text-[13px] text-foreground">
{row.label}
</span>
{row.description && (
<span className="block truncate text-[12px] text-muted">
{row.description}
</span>
)}
</span>
</label>
</li>
))}
</ul>
</div>
{undoAvailable && (
<div className="flex items-center justify-between gap-3 border-t border-border px-4 py-3">
<span className="text-[12px] text-muted">
Batch applied — undo within {UNDO_MS / 1000}s.
</span>
<button
type="button"
onClick={handleUndo}
className="rounded-sm border border-border px-2.5 py-1 text-[12px] font-medium text-foreground transition-colors duration-150 hover:border-foreground/40 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
>
Undo
</button>
</div>
)}
</div>
);
}
function CheckboxVisual({
checked,
indeterminate = false,
reducedMotion = false,
}: {
checked: boolean;
indeterminate?: boolean;
reducedMotion?: boolean;
}) {
const springDuration = reducedMotion ? "0ms" : "120ms";
const springEase = "cubic-bezier(0.34, 1.56, 0.64, 1)";
return (
<span
aria-hidden="true"
className="relative inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-[6px] border border-border bg-background peer-checked:border-foreground peer-checked:bg-foreground peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-accent"
style={{
transition: `background-color ${springDuration} ${springEase}, border-color ${springDuration} ${springEase}`,
}}
>
<svg
viewBox="0 0 16 16"
className="h-3 w-3 text-background"
style={{
opacity: checked && !indeterminate ? 1 : 0,
transform: checked && !indeterminate ? "scale(1)" : "scale(0.5)",
transition: `opacity ${springDuration} ${springEase}, transform ${springDuration} ${springEase}`,
}}
>
<path
d="M3 8.2L6.2 11.4L13 4.4"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
<span
className="absolute h-[2px] w-2.5 rounded-full bg-background"
style={{
opacity: indeterminate ? 1 : 0,
transition: `opacity ${springDuration} ${springEase}`,
}}
/>
</span>
);
}
a bulk select-all/mark-all-read/enable-all over a visible list, where users routinely want 'all except a few' — clicking any row mid-run halts the propagating wave there, leaving rows beyond it untouched, so there's no all-then-undo. Prefer a plain checked-count select-all when the list is never long enough for mid-toggle interruption to matter; prefer this when it is.
A select-all/toggle-all whose change propagates like a domino run rather than a cosmetic stagger: flipping the master checkbox (real input[type=checkbox], aria-controls pointing at the row list's id) releases an rAF-driven wavefront index that advances at a fixed 14 rows/sec. As the front reaches row i, that row's own checkbox commits its new checked state (a 120ms CSS transition on fill/border with a slight spring overshoot, cubic-bezier(0.34,1.56,0.64,1)) and the row's <li> takes a transient translateY(2px) lean toward row i+1, held for roughly one row-interval plus a 40ms overlap so consecutive leans read as continuous handed-off contact rather than independent bumps. A thin 2px --foreground tick travels down the list's left edge on the same rAF loop, marking the front's live position. State commits per row the instant the front passes it — nothing is staged or rolled back, so stopping the run costs nothing. INTERRUPTION IS FIRST-CLASS: every row stays a fully operable native checkbox for the run's entire duration; activating ANY row, by pointer or keyboard, or pressing Escape, halts the wave immediately. Rows the front already passed keep their committed state; rows beyond the front are simply never touched, standing exactly as they were — this is the 'all except a few' path, no undo needed because nothing past that point was ever touched. A11Y: a polite live region announces once at the start ('Enabling all…'/'Disabling all…') and once at the end or halt ('Enabled 34 of 40, stopped at row 35.'), throttled to those two moments only — not per row. Every row and the master are real input[type=checkbox] elements with native label association for their accessible name; the master's own live counter is aria-hidden decoration, not part of its name. REDUCED MOTION: there is no wave to catch, so interruption is replaced by a plain safety net — the master flips every row's state in a single frame (no wavefront, no lean, no tick) and a 5s Undo affordance appears beneath the list, reverting the whole batch if pressed in time. TOKENS: --foreground for the checkbox fill and the traveling tick, --border for hairlines between rows, --muted for secondary row text and the counter, --accent for the focus ring only. Pure DOM/CSS — no canvas, no SVG filters.