DHxWhy

Drag Select Checkbox Group – Press and Drag to Check Multiple Rows in One Stroke

Forms

About

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>
  );
}

Dependencies

More by DHxWhy

View all
  • Product Rail Gallery – Fashion Store Hero Section

    Product Rail Gallery – Fashion Store Hero Section

    2.2K
  • Sushi Conveyor Belt Menu – Restaurant Hero Section

    Sushi Conveyor Belt Menu – Restaurant Hero Section

    883
  • Team Lanyard Rail – Meet the Team Section with Flip Badges

    Team Lanyard Rail – Meet the Team Section with Flip Badges

    830
  • Skateboard Deck Rail Gallery – Skate Shop Hero Section

    Skateboard Deck Rail Gallery – Skate Shop Hero Section

    431
  • Sun Moon Dark Mode Theme Toggle

    Sun Moon Dark Mode Theme Toggle

    328
  • Picture Rail Image Gallery – Art Print Shop Hero Section

    Picture Rail Image Gallery – Art Print Shop Hero Section

    268
  • Pendant Lamp Rail Gallery – Lighting Store Hero Section

    Pendant Lamp Rail Gallery – Lighting Store Hero Section

    243
  • World Testimonials Globe – Social Proof Hero Section

    World Testimonials Globe – Social Proof Hero Section

    217

More in Forms

View all
  • Premium Auth

    Premium Auth

    543
  • Contact Card

    Contact Card

    1.1K
  • Form

    Form

    17
  • Card Studio

    Card Studio

    46
  • Book A Demo 1

    Book A Demo 1

    80
  • Login 2

    Login 2

    10
  • Login 1

    Login 1

    58
  • SignInPage

    SignInPage

    710