Drag Select Checkbox Group for React and Tailwind CSS (shadcn/ui): press one checkbox and drag to check every row you pass in one stroke; back up and the passed rows are restored. It is built to make bulk selection one gesture instead of many clicks. A translucent band follows the pointer, ticks draw themselves and select-all shows a mixed state. Shift+click ranges, arrow keys, Ctrl/⌘+A, touch long-press, controlled or uncontrolled value, aria labels, reduced motion and light/dark are included. Use it for mail, files, members and tables. The included demo is a mock inbox with embedded headshots and makes no network requests. Requires React only, with no animation library.
Built with React and Tailwind. Copy the prompt and paste it into Claude Code, Cursor or Lovable: your agent adds Drag Select Checkbox Group – Press and Drag to Check Multiple Rows in One Stroke to your project and fits it to your theme.
Code
How Drag Select Checkbox Group – Press and Drag to Check Multiple Rows in One Stroke is used: the demo's code. The component's own source comes with a free account.
Usage.tsx
"use client";
import * as React from "react";
import { Archive, Trash2 } from "lucide-react";
import { DragSelectCheckboxGroup } from "@/components/ui/drag-select-checkbox-group";
import type { DragSelectItem, DragSelectMode } from "@/components/ui/drag-select-checkbox-group";
const HEADSHOTS = {
"photo-1573497019940-1c28c88b4f3e": "data:image/webp;base64,UklGRo4CAABXRUJQVlA4IIICAADQDQCdASpAAEAAPpVAl0glpCIhLBquYLASiWIAqo/e0WKUIW1UlGg0LODnyPg8llhEqlTGJelz4+tInPnyRdqamVIqk4zISyDDKvRRclPiXARfbVTIDnqdAXbqf/NT4lw5QBKHVGRo9hY5Hp9A0G+Th1UqpTuPwAD9BNJ9UR4uJb+svYvPzl3dGJPHG99cMBxgeRDDdk5zMt/1lvz/Dk4fiv6+h0Sb4aW09CTC75wXnogFs/Vm09QuGsc1yDQYkh74X0/JvNukugjBMp474kpyG1CRNWa4aB1co5XTStoWmR0ejpugEpN2d3osMKgL+5p6O+t6JWLl9KXnMcQ0wxqTyOobZtt6JGGN/b/q+uRU0p1JH0BLdeAZ0i5P8QkxgKIIx4KtlO761Q5WVk9uE+csE00WkLIsnl35nSy0+BOQGSTJdnsPrDHJ//aPg10yVu7mO9Aehrtdso1HZW61NVfBetpg5ExOVcs15elLA+QYOtNeN8/eqs2oH+a00ecG0r38+VRLthoNLxcWMN+qlyC7ZGsZymuUGMmTB6MH7k92X4qljAiaNvF7zBGU6IOkUpH9K0QUPZW7Nm5ZLLMBgvvgzOCAHddyHWVjznl6lIisSs6IEEDIsVlMVfPGchKbCPopH1MjhU4EN9xHi858TyIvNEv1sPMV6lddIcFTUl9dfj/Gb6cjmtTk3eWT6hM3xWNYGw7TduyhhsM1j9UTv/MHZk/76KEtmbZ2jBwY5R0Izs+7pbKgroTCoDkH4vOpTuqDW3mY+OVt4ec5vIvflLVkCxXuWZSZiU16cH4hh7uHxZ0sA8if/8ZTCpWCG3zeV5usf9z+mszT5Sevb8CLWhCgAAA=",
"photo-1560250097-0b93528c311a": "data:image/webp;base64,UklGRvICAABXRUJQVlA4IOYCAABQDgCdASpAAEAAPpVAlkglpCIhLhtriLASiWMAxQw+Tcl+unTu23aUWsvTZ/6UBmb5q/vSM/c1T1VG3j60qt84QAvZ7zxfNONqxyhaR31n+Y0Qi7FPCS6YSL7zvqG4KQDuRV4nARHQFRYxbK1vmMAqELO7qQxm2CoGCAAA/v025ZrPbB7cgIffyHRaWOO2SkNEaagn4fVhFSxYpVlx57ShkY42HQ4ZO1u/ZOVpdj4WM4/8ewlLAj8jK3K5SGll3im17eZg7at9gcYhjSu+TZ8hvd3rveMwN8yWWYTwdC3fCegRxzJr6ti5WHJV6chmE+U7zIrcX7LFB8kCcDcDjDBnU4ohwB+o0C3eL+WryygdtR0dsYi0E7D6yXQZWIzcqzwGvlxf+5vj4cRwrwVtLzIcMi0oLyVmUKQz26hkNvNmZwPY+Ct1TMmJtS1Y8RMcDHPZhEJuUKEQSWmXJ2c2EBFYlsLszBjXWHISvhQOk1s9tr8IhTnzau4/YXhiIDEXLMOHIHl2aw8RdrAk7LUqjn9ow9LUMI48HsTCiBDZU9L3n41o2kuOH642wUlnDfn0ccDfsDTPkkC4AAqJfX0yTOolxh2NYrL4CfSKsDD53m4bSlH1aocDEZzudTuKXeN4vxQmzBP8c3rZJ/8hh+qzFNsozH8pqvQ9abtTxuO8b7D0K/BQ+92STJ8ArDcn5gPG+pXZ8WJbeCzvHSDOr/78skca8TpPYmf74MEYysvf6V9gqPJqfcnk2G+oC1GqZGt4uYfI6RW0cdZycfhBgYdddn8zaW6y/rugGIRbyc9lQI7ye+ADUW9V/sUK6TcYuPg44BtxqkxEDjgEn2zXxn4L+gbFV4L3K4DWXBFGf6nxlmHx7SIDa19uGeAzxgG2Kjywk/Ld4U7WtPURPgN+rofjUWiBGc+dbrvyHBi4sWR3PEdOxz/PU/WXfMcickqzPWrBy23P8TNWv6x00Vn7PLs9FVe29AHZAAAA",
"photo-1573496358961-3c82861ab8f4": "data:image/webp;base64,UklGRgYDAABXRUJQVlA4IPoCAADwDwCdASpAAEAAPpVEnUqlo6KnKhZpcOASiWMAzIwPvXJrRqTt2vrLCmXV9eq65dBRuWlh4RBuarCqvRNHzBCLq2SsaIW45gv0UMqs5su8586TxvmInKZ3o98qoWxrCWGLy03U7Lm+AJ5iEJtaTKPTwWal5T6DsJzWk61BL33NH6yO25i5S2VgAP76l9iRXzMKAbkTS1HfR2GEsEvyLnpj+rltzQAJh2wWhfKMkRNcd6RJOYoVZT6TUcpbvkAipH9tRuJK0NgP2E9B9gLy7iV/UtmVJn8Dh4TPG9csa1QTdD8uCzCIa/46OOx0V6QTZCsWi54vscUeNdMN2C8xnuLL6OU4/LGz42UiN/c/2w8w35a758nDs8pq5+HaYGBH2GMtNoFkT4rObHWB8jXS/ydbHeYtG/WxYbyIfS5Azh6em/Byunxdxpj+Uqp++dWevfTII3lU+BCorx30NVlJjdx30Vq/8XJFOYSj578iwLjCXRnZ2u9LCavf3XPWIpyQ9B4S8wKuORZ4ASCnTGmIpyDmpylNQkDs+y1Lllw1DJorXrmh2r4mUKn8Y7+DQQCQKgPnv3urDJ8qvmauNO9UYK50aKp45K7jT/Vxcs+fSqn9vum8eKKyc05xBAbt/4WO8nC3Q//iIOYksZL+ucZlwKtGsoZtrq4DI/p4e5pq3ix4ps4ZaBTMGitkKaBQpen0aDYpHpfW1jyOMNcjZcAVjvNc2ktJpbBjgLSW6+TwdXE/P91j06Gd/UhU9itsF8vUdo17KLZdH4sn7y2W9Ag8slmx7FWOKvn+bohLcXkJCVL3Nmxghb+Md6B/gbFNs6JQ0WMfCbrDQ3cRM96xUD1SNaN1EeR8POjjW6cf4FRGKnjQOrAaoMXYwU4QN3ZnQH14BHNa3+Cx/+6JB365HoRM4D5kr/p9aevfhBwnvir74RTI1odb44XhDkqgIXxXvSMyotV1Tm6h7STRIdrGYCxFNIjnxozyU7h9/iwUOZP4mcJIiaBvGXq7gSKAAAA=",
"photo-1540569014015-19a7be504e3a": "data:image/webp;base64,UklGRp4EAABXRUJQVlA4IJIEAADQEwCdASpAAEAAPpVEmkklpCIhKhZsWLASiWIAsR+YDQihb5ekDb0Mxo3Z/cvAvyie3JF3cFqI9q8AsxNAB+S/1fv39VBXlNP82UN1C5jYL4pkUUPfRzdg7W9l74YFR06KzZzHO3qxnWrAbyJVGhm8bKV22M5l7suH+t41Mb6DuXUotXXmGdsIWz/d2XCebFdHhrpyAXHuS8ahi+kcalgXayc4TGCvMAD+/dpU3DK3rzJNsCYSoW0fV5s28Z4TzFuZZsEnIVTMc8cQjkJbDmbHxmxO3KHtqYmggYpD6HpPhG2EyzezFOv8ftqQU/yH/3fRvel+/WCY+ypUhPa+DLUZrqKlDlRB2ZiRLMLQ/INC8+7jfXWVd04Puf19ogVK7U1LO/h400y2tDIcjr9r38+KJrq2YPzWwbgVLIYtK5rv4Z9kCk7r2zzuG+Dpp6hSGKs11+R9yvYWvV30VdOaNPiYn0Uhhu+YUtqb4B5de24b+q6de3hIGQVuow5C1V+SZ3bBFLhSWXVnu6JP251uG3q040Eok+Pc3TvK2v5WWhdFvzJsN3e3FJlXZMg9ePAfpzBG89i0wG394+9iPMFE9aGiOzFDRpRJPImpkvLdQmDtuhiVbqln5BpLnmXeUjFf0UXzTsTW92V54b+N1Hw1/NAflG77RVFmw6n+XbL3bFwM1DT7gMcLffAuA03bs9BTfEpgtMvKozCYPWzdWt43KDJn0Xj0EOqB8JNgS+PJZccuNwARkbCD9WV/teO/Cn/lOi8FKFrk+AQepvUQjfIT9YtYlJucXyOAopSxHDKNIZTccI1KQoO835mincC4UqWqe9XYZ8YK9v1O5JSd3Nc9vBg42X5REHursiT4Mh3LejHeSfcRQ8CtfugpKqqmAvyrFh0eYMlFlgo1v/ftqnn0igcqddgKhnutPLQ5rxNhfIrF7GTPqXj+eQ9i9fAjz7R0M0MkTs2QbPY1PS2hfmNjmLzAP0JK0v7nuheabDRqDzbNOgxfQvGGfJkRnJXPmqpJGvqvvFCJByo3W9DYuC2wAwLXTL+nm9K/WxHO7kTZr7D82tquax/rVq8fxr14SQKT3xU7wh4zho0W+w61GWuU0ByOZruHdIILwqIKnoHRBcJBPXz+8ufrA8LWTZaGbDjcULYSCnKyGyFjAjI8eb6fLfUY8vKOZdm2nkKBMqN5EaGej59r4a415TUf0hf7aihq3X7fTMv5aU+scKdhKMX5cSMJKoQEzkG5G+xbLqRMq6G/rDOBCNwFl4m8q0z+BJJKIAAP36mE8BndQFK2eXiMdwmpvxsBNEL8WVXB38DbY544wEEglj7j1AFNerLEMbA/W5nLLiZggdZr6lxRBd6KuKAgKgLgHuhkQ/uOqM7RuvVPbJsdgorthz0GnmM/1kmAAiGXW19JrTwJCvxqv9IO6IB8lPar+CaCtk0ouQ8IBCRe4+qx4MfOczMHsGCe4F6qzWg+PrRK3gc1Hta948M+BwTsQ331b52pYQhiWrfuUVUJjTZN4dO1UQ7mYEVftNvNL5VcM4HKOxltVvZ/uLxc3DNxkAA=",
} as const;
const SENDERS = {
maria: HEADSHOTS["photo-1573497019940-1c28c88b4f3e"],
tom: HEADSHOTS["photo-1560250097-0b93528c311a"],
aisha: HEADSHOTS["photo-1573496358961-3c82861ab8f4"],
daniel: HEADSHOTS["photo-1540569014015-19a7be504e3a"],
} as const;
const MAIL: DragSelectItem[] = [
{ id: "design-review", label: "Design review notes", description: "Notes from this morning's review, with the open questions at the end.", meta: "9:41", avatarUrl: SENDERS.maria },
{ id: "invoice", label: "Invoice 10231", description: "October invoice attached. Payment is due on the 30th.", meta: "9:12", avatarUrl: SENDERS.tom },
{ id: "standup", label: "Standup moved to 10:30", description: "Shifting tomorrow by half an hour so the Berlin team can join.", meta: "8:55", avatarUrl: SENDERS.aisha },
{ id: "deploy", label: "Re: staging deploy window", description: "Thursday 6–7pm works. I'll post in #releases once it's done.", meta: "8:20", avatarUrl: SENDERS.daniel },
{ id: "onboarding", label: "Onboarding copy, round 2", description: "Reworked the welcome screens based on Friday's feedback.", meta: "Yesterday", avatarUrl: SENDERS.maria },
{ id: "metrics", label: "October metrics are in", description: "Activation is up 4 points. Full breakdown in the attached deck.", meta: "Yesterday", avatarUrl: SENDERS.tom },
{ id: "offsite", label: "Offsite agenda draft", description: "Two days, three tracks. Tell me what's missing before it goes out.", meta: "Nov 6", avatarUrl: SENDERS.aisha },
{ id: "access", label: "Access request: analytics", description: "Can you approve read access for the two new analysts?", meta: "Nov 6", avatarUrl: SENDERS.daniel },
];
const CYCLE = 9000;
const POINTER_ID = 9001;
function fire(target: EventTarget, type: string, clientX: number, clientY: number) {
const released = type === "pointerup" || type === "pointercancel";
target.dispatchEvent(new PointerEvent(type, {
bubbles: true, cancelable: true, composed: true, pointerId: POINTER_ID, pointerType: "mouse", isPrimary: true,
button: 0, buttons: released ? 0 : 1, clientX, clientY,
}));
}
const centre = (node: Element) => { const box = node.getBoundingClientRect(); return { x: box.left + box.width / 2, y: box.top + box.height / 2 }; };
function simulateDrag(root: HTMLElement, from: number, to: number, duration: number, hold: number, done: () => void) {
const rows = root.querySelectorAll("[data-row]");
const start = rows[from]?.querySelector("input");
const end = rows[to]?.querySelector("input");
if (!start || !end) { done(); return () => {}; }
const a = centre(start);
const b = centre(end);
fire(start, "pointerdown", a.x, a.y);
let frame = 0;
let timer = 0;
let released = false;
const started = performance.now();
const tick = (now: number) => {
const progress = Math.min(1, (now - started) / duration);
fire(window, "pointermove", a.x, a.y + (b.y - a.y) * progress);
if (progress < 1) { frame = requestAnimationFrame(tick); return; }
timer = window.setTimeout(() => { released = true; fire(window, "pointerup", b.x, b.y); done(); }, hold);
};
frame = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(frame);
window.clearTimeout(timer);
if (!released) { released = true; fire(window, "pointercancel", a.x, a.y); }
};
}
function shiftClick(root: HTMLElement, index: number) {
const input = root.querySelectorAll("[data-row]")[index]?.querySelector("input");
input?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, composed: true, button: 0, shiftKey: true }));
}
function press(root: HTMLElement, selector: string) {
const node = root.querySelector(selector);
if (node instanceof HTMLElement) node.click();
}
function ActionButton({ icon, label, disabled, onClick }: { icon: React.ReactNode; label: string; disabled: boolean; onClick: () => void }) {
return (
<button
type="button"
data-action={label.toLowerCase()}
disabled={disabled}
onClick={onClick}
className="inline-flex h-7 items-center gap-1.5 rounded-md px-2 text-xs font-medium text-muted-foreground outline-none transition-colors duration-150 hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none motion-reduce:transition-none"
>
{icon}
{label}
</button>
);
}
export default function Demo({ autoplay = true }: { autoplay?: boolean }) {
const params = React.useMemo(() => new URLSearchParams(typeof location === "undefined" ? "" : location.search), []);
const freeze = params.get("freeze") === "1";
const uncontrolled = params.get("uncontrolled") === "1";
const disabledId = params.get("disabled");
const [items, setItems] = React.useState(() => MAIL.map((mail) => (mail.id === disabledId ? { ...mail, disabled: true } : mail)));
const [value, setValue] = React.useState([] as string[]);
const [changes, setChanges] = React.useState(0);
const [last, setLast] = React.useState("");
const [dragEnd, setDragEnd] = React.useState("");
const [stopped, setStopped] = React.useState(false);
const [stage, setStage] = React.useState("idle");
const [note, setNote] = React.useState("");
const rootRef = React.useRef(null as HTMLDivElement | null);
const playing = autoplay && !stopped;
React.useEffect(() => {
if (!playing) return;
const root = rootRef.current;
if (!root) return;
const timers: number[] = [];
let cancelDrag = () => {};
const run = (first: boolean) => {
const at = (ms: number, task: () => void) => timers.push(window.setTimeout(task, ms));
if (!first) at(0, () => { setStage("reset"); setValue([]); });
at(500, () => { setStage("drag-down"); cancelDrag = simulateDrag(root, 1, 5, 1400, 0, () => setStage("idle")); });
at(2800, () => { setStage("shift-click"); shiftClick(root, 6); });
at(3800, () => { setStage("select-all"); press(root, "[data-select-all]"); });
at(5000, () => { setStage("drag-up"); cancelDrag = simulateDrag(root, 1, 0, 350, 250, () => setStage("idle")); });
};
run(true);
const loop = window.setInterval(() => run(false), CYCLE);
return () => {
window.clearInterval(loop);
for (const timer of timers) window.clearTimeout(timer);
cancelDrag();
};
}, [playing]);
const stop = () => { if (autoplay) setStopped(true); };
const remove = (verb: string) => {
stop();
const picked = items.filter((item) => value.includes(item.id));
if (picked.length === 0) return;
setItems((current) => current.filter((item) => !value.includes(item.id)));
setValue([]);
setNote(`${picked.length} ${picked.length === 1 ? "conversation" : "conversations"} ${verb}`);
};
const onValueChange = (ids: string[]) => {
setChanges((count) => count + 1);
setLast(ids.join(","));
if (!freeze) setValue(ids);
};
const onDragSelectEnd = (ids: string[], mode: DragSelectMode) => setDragEnd(`${mode}:${ids.join(",")}`);
return (
<main
data-demo-root
data-stage={stage}
data-playing={playing ? "true" : "false"}
data-changes={changes}
data-last={last}
data-drag-end={dragEnd}
className="flex min-h-[min(100svh,760px)] w-full items-center justify-center bg-background px-4 py-6 text-foreground sm:px-6"
>
<div ref={rootRef} className="w-full max-w-[640px] overflow-hidden rounded-2xl bg-card text-card-foreground shadow-sm ring-1 ring-border">
<DragSelectCheckboxGroup
items={items}
{...(uncontrolled ? { defaultValue: [] as string[] } : { value })}
onValueChange={onValueChange}
onDragSelectEnd={onDragSelectEnd}
onInteractionStart={(event) => { if (event.isTrusted) stop(); }}
header={{
title: "Inbox",
actions: (
<>
<ActionButton icon={<Archive className="size-3.5" aria-hidden="true" />} label="Archive" disabled={value.length === 0} onClick={() => remove("archived")} />
<ActionButton icon={<Trash2 className="size-3.5" aria-hidden="true" />} label="Delete" disabled={value.length === 0} onClick={() => remove("deleted")} />
</>
),
}}
aria-label="Inbox"
/>
{items.length === 0 && <p data-empty className="px-4 py-8 text-center text-sm text-muted-foreground">You're all caught up.</p>}
{note && <p data-note role="status" className="border-t border-border px-4 py-2 text-xs text-muted-foreground">{note}</p>}
</div>
</main>
);
}
















