Drag Select
Layout

Drag Select

A marquee rectangular selection surface over a grid; drag to select, Shift or Cmd adds, Escape clears, Cmd+A selects all, and each item is keyboard-toggleable.

Install

npx shadcn@latest add @paragon/drag-select

drag-select.tsx

"use client";

import * as React from "react";
import { cn } from "@/lib/utils";

interface DragSelectContextValue {
  isSelected: (id: string) => boolean;
  register: (id: string, el: HTMLElement | null) => void;
  toggle: (id: string, additive: boolean) => void;
  focusItem: (id: string) => void;
  isStatic: boolean;
}

const DragSelectContext = React.createContext<DragSelectContextValue | null>(
  null,
);

export interface DragSelectProps
  extends Omit<React.ComponentProps<"div">, "onChange"> {
  /** Controlled set of selected ids. */
  value?: string[];
  defaultValue?: string[];
  onValueChange?: (ids: string[]) => void;
  /** Disables item press/check motion; selection still updates. */
  static?: boolean;
}

interface Rect {
  left: number;
  top: number;
  width: number;
  height: number;
}

/** Marquee only counts once the pointer has traveled this far, px. */
const DRAG_THRESHOLD = 3;

function setsEqual(a: Set<string>, b: Set<string>) {
  if (a.size !== b.size) return false;
  for (const id of a) if (!b.has(id)) return false;
  return true;
}

/**
 * A marquee selection surface over a grid of DragSelectItem children. Dragging
 * paints a rectangle (pointer-captured and single-pointer guarded, so a drag
 * that leaves the surface keeps tracking) and selects every item it
 * intersects; Shift or Cmd/Ctrl adds to the existing selection instead of
 * replacing it. The marquee is drawn with direct DOM writes — no re-render
 * per pointer move; React state only changes when the hit set changes. A
 * plain click on empty surface clears, Escape clears, Cmd/Ctrl+A selects all.
 * Items are focusable checkboxes that toggle with Space/Enter, and selection
 * counts are announced to a polite live region.
 */
export function DragSelect({
  value,
  defaultValue = [],
  onValueChange,
  static: isStatic = false,
  className,
  children,
  onKeyDown,
  ...props
}: DragSelectProps) {
  const surfaceRef = React.useRef<HTMLDivElement>(null);
  const marqueeRef = React.useRef<HTMLDivElement>(null);
  const items = React.useRef<Map<string, HTMLElement>>(new Map());
  const [internal, setInternal] = React.useState<string[]>(defaultValue);
  const selected = value ?? internal;
  const selectedSet = React.useMemo(() => new Set(selected), [selected]);
  const selectedRef = React.useRef(selectedSet);
  selectedRef.current = selectedSet;

  const drag = React.useRef<{
    pointerId: number;
    startX: number;
    startY: number;
    base: Set<string>;
    additive: boolean;
    moved: boolean;
  } | null>(null);

  const commit = React.useCallback(
    (next: Set<string>) => {
      if (setsEqual(next, selectedRef.current)) return;
      const arr = [...next];
      if (value === undefined) setInternal(arr);
      onValueChange?.(arr);
    },
    [value, onValueChange],
  );

  const register = React.useCallback((id: string, el: HTMLElement | null) => {
    if (el) items.current.set(id, el);
    else items.current.delete(id);
  }, []);

  const toggle = React.useCallback(
    (id: string, additive: boolean) => {
      const next = new Set(additive ? selectedRef.current : []);
      if (selectedRef.current.has(id) && additive) next.delete(id);
      else next.add(id);
      commit(next);
    },
    [commit],
  );

  const focusItem = React.useCallback((id: string) => {
    items.current.get(id)?.focus();
  }, []);

  const intersecting = (rect: Rect): Set<string> => {
    const surfaceRect = surfaceRef.current?.getBoundingClientRect();
    if (!surfaceRect) return new Set();
    const hits = new Set<string>();
    items.current.forEach((el, id) => {
      const r = el.getBoundingClientRect();
      const x = r.left - surfaceRect.left;
      const y = r.top - surfaceRect.top;
      const overlaps =
        x < rect.left + rect.width &&
        x + r.width > rect.left &&
        y < rect.top + rect.height &&
        y + r.height > rect.top;
      if (overlaps) hits.add(id);
    });
    return hits;
  };

  /** Geometry goes straight to the DOM — zero re-renders while painting. */
  const paintMarquee = (rect: Rect | null) => {
    const el = marqueeRef.current;
    if (!el) return;
    if (!rect) {
      el.style.opacity = "0";
      return;
    }
    el.style.opacity = "1";
    el.style.transform = `translate3d(${rect.left}px, ${rect.top}px, 0)`;
    el.style.width = `${rect.width}px`;
    el.style.height = `${rect.height}px`;
  };

  const handlePointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
    // Only start a marquee from empty surface, primary button, one pointer.
    if (e.button !== 0 || drag.current) return;
    if ((e.target as HTMLElement).closest("[data-drag-item]")) return;
    const rect = surfaceRef.current?.getBoundingClientRect();
    if (!rect) return;
    e.currentTarget.setPointerCapture(e.pointerId);
    const additive = e.shiftKey || e.metaKey || e.ctrlKey;
    drag.current = {
      pointerId: e.pointerId,
      startX: e.clientX - rect.left,
      startY: e.clientY - rect.top,
      base: additive ? new Set(selectedRef.current) : new Set(),
      additive,
      moved: false,
    };
  };

  const handlePointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
    const state = drag.current;
    if (!state || e.pointerId !== state.pointerId) return;
    const rect = surfaceRef.current?.getBoundingClientRect();
    if (!rect) return;
    const x = e.clientX - rect.left;
    const y = e.clientY - rect.top;
    if (
      !state.moved &&
      Math.abs(x - state.startX) < DRAG_THRESHOLD &&
      Math.abs(y - state.startY) < DRAG_THRESHOLD
    ) {
      return;
    }
    state.moved = true;
    const box: Rect = {
      left: Math.min(x, state.startX),
      top: Math.min(y, state.startY),
      width: Math.abs(x - state.startX),
      height: Math.abs(y - state.startY),
    };
    paintMarquee(box);
    const next = new Set(state.base);
    intersecting(box).forEach((id) => next.add(id));
    commit(next);
  };

  const endDrag = (e: React.PointerEvent<HTMLDivElement>) => {
    const state = drag.current;
    if (!state || e.pointerId !== state.pointerId) return;
    drag.current = null;
    paintMarquee(null);
    // A motionless click on empty surface clears (unless adding).
    if (!state.moved && !state.additive) commit(new Set());
  };

  const context = React.useMemo<DragSelectContextValue>(
    () => ({
      isSelected: (id) => selectedSet.has(id),
      register,
      toggle,
      focusItem,
      isStatic,
    }),
    [selectedSet, register, toggle, focusItem, isStatic],
  );

  return (
    <DragSelectContext.Provider value={context}>
      <div
        ref={surfaceRef}
        data-slot="drag-select"
        onPointerDown={handlePointerDown}
        onPointerMove={handlePointerMove}
        onPointerUp={endDrag}
        onPointerCancel={endDrag}
        onKeyDown={(e) => {
          onKeyDown?.(e);
          if (e.defaultPrevented) return;
          if (e.key === "Escape" && selectedRef.current.size > 0) {
            commit(new Set());
          } else if ((e.metaKey || e.ctrlKey) && e.key === "a") {
            e.preventDefault();
            commit(new Set(items.current.keys()));
          }
        }}
        className={cn("relative touch-none select-none", className)}
        {...props}
      >
        {children}
        <div
          ref={marqueeRef}
          aria-hidden
          className="pointer-events-none absolute top-0 left-0 z-20 rounded-sm border border-primary bg-primary/10 opacity-0"
        />
        <span aria-live="polite" className="sr-only" role="status">
          {selected.length} of {items.current.size} selected
        </span>
      </div>
    </DragSelectContext.Provider>
  );
}

export interface DragSelectItemProps extends React.ComponentProps<"button"> {
  id: string;
}

export function DragSelectItem({
  id,
  className,
  children,
  ...props
}: DragSelectItemProps) {
  const context = React.useContext(DragSelectContext);
  if (!context) {
    throw new Error("<DragSelectItem> must be used within <DragSelect>");
  }
  const selected = context.isSelected(id);

  return (
    <button
      type="button"
      data-drag-item
      role="checkbox"
      aria-checked={selected}
      ref={(el) => context.register(id, el)}
      onClick={(e) => context.toggle(id, e.shiftKey || e.metaKey || e.ctrlKey)}
      className={cn(
        "relative outline-none transition-[box-shadow,background-color,scale] duration-150 ease-out focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
        !context.isStatic && "active:scale-[0.98]",
        selected && "ring-2 ring-primary ring-offset-2 ring-offset-background",
        className,
      )}
      {...props}
    >
      {children}
      <span
        aria-hidden
        className={cn(
          "absolute top-1 right-1 flex size-4 items-center justify-center rounded-full bg-primary text-primary-foreground",
          !context.isStatic &&
            "transition-[opacity,scale] duration-(--duration-quick) ease-(--ease-out)",
          selected ? "scale-100 opacity-100" : "scale-75 opacity-0",
        )}
      >
        <svg
          viewBox="0 0 10 10"
          className="size-2.5"
          fill="none"
          stroke="currentColor"
          strokeWidth="1.5"
          strokeLinecap="round"
          strokeLinejoin="round"
        >
          <path d="M2 5.2 4.2 7.4 8 3" />
        </svg>
      </span>
    </button>
  );
}