Star on GitHub
npm install react-driftkit
import { MarqueeSelect } from 'react-driftkit';
Hit test
intersect — any node the box touches
Additive
off — a plain drag replaces it (hold shift to add)
Edge auto-scroll
on — drag past the right edge and the board follows
Selection
0 nodes · last mode —
Ingestsource
Webhooksource
Schedulesource
Parsetransform
Validatetransform
Enrichtransform
Dedupetransform
Scoretransform
Routetransform
Warehousesink
Alertssink
Archivesink
Replaysource
Backfilltransform
Audit logsink
Exportsink
Drag anywhere on the empty canvas to lasso nodes. Hold shift (or ⌘/ctrl) to add to the selection, alt to subtract from it, and press Escape mid-drag to roll the whole thing back. Click empty space to clear. Drag past the right or bottom edge and the board scrolls under you while the box stays pinned to the point you pressed — it works in content coordinates, not screen ones. Pressing a node does not start a marquee, which is what leaves room for per-node drag behaviour in a real editor; behavior.startOnItem opts back in. Nothing here re-renders on hover: the dashed outline is pure CSS off the data-marquee-hit attribute the component writes onto each node.
Prop Type Default
children
The selectable surface — a canvas, board, or grid. Rendered inside a position: relative root.
ReactNode —
itemSelector
CSS selector matching the selectable items, queried inside the root. Selector-based rather than registration-based, so virtualised lists and third-party children work unchanged.
string '[data-marquee-item]'
selection
Controlled selection. When provided, the component renders exactly what you pass and nothing moves until you apply on.select.
string[] —
defaultSelection
Starting selection in uncontrolled mode.
string[] []
additive
Treat an unmodified drag as add instead of replace, so every drag extends the selection.
boolean false
disabled
Turns the gesture off without unmounting. An in-flight drag is rolled back immediately.
boolean false
behavior
Gesture tuning — hit testing, what starts a drag, identity, and auto-scroll.
MarqueeSelectBehavior —
behavior.hitTest
intersect catches anything the box touches; contain only catches items the box fully encloses.
'intersect' | 'contain' 'intersect'
behavior.threshold
Pointer travel in px before a press counts as a marquee. Below it, the press is an empty-space click instead.
number 4
behavior.startOnItem
Allow a marquee to start on top of an item. Off by default so pressing an item leaves the gesture to whatever drag logic that item owns.
boolean false
behavior.ignoreSelector
Elements whose press is left entirely alone, so buttons, links, and text fields inside the surface keep working.
string form controls, a[href], editable content, [data-marquee-ignore]
behavior.clearOnEmptyClick
A press that never crosses threshold clears the selection. Skipped when a modifier is held, so shift-clicking empty space does not wipe your work.
boolean true
behavior.markItems
Mirror state onto the item elements as data-marquee-selected and data-marquee-hit, so CSS can style them without a re-render. Marks are removed on unmount.
boolean true
behavior.autoScroll
Scroll the root when the pointer nears its edge during a drag. Requires the root to actually scroll — put overflow: auto and a height on it via style.
boolean true
behavior.autoScrollMargin
Distance in px from the edge where auto-scroll begins.
number 48
behavior.autoScrollSpeed
Peak auto-scroll speed in px per frame, reached at the very edge. It ramps up as the pointer gets closer.
number 14
behavior.touch
Let touch pointers start a marquee. Off by default so a touch drag still pans the board; turning it on sets touch-action: none on the root.
boolean false
behavior.mouseButton
Mouse button that starts a marquee. Set it to 1 or 2 to reserve the primary button for something else.
number 0
behavior.getItemId
Identity for an item element. Use it when ids already live somewhere else in your markup.
(element: HTMLElement, index: number) => string data-marquee-id → id → index
behavior.resolveMode
Replaces the whole modifier-key mapping. This is the only route to toggle mode, which flips each caught item in and out of the selection.
(event: PointerEvent) => MarqueeMode shift/ctrl/meta → add, alt → subtract
on
Event handlers: select, start, end, cancel. All optional.
MarqueeSelectEvents —
on.select
Fires whenever the effective selection changes, live during the drag. Skipped when a move leaves the result unchanged, so it is safe to wire straight to a state setter.
(ids: string[], info: MarqueeSelectInfo) => void —
on.start
Fires once the press has crossed threshold and the box has appeared — not on every press.
(info: MarqueeSelectInfo) => void —
on.end
Fires on release with the committed selection. Use it when the expensive work should only run once, not on every frame.
(ids: string[], info: MarqueeSelectInfo) => void —
on.cancel
Fires when Escape or a pointer cancel aborts the drag. The selection is rolled back to its pre-drag value first, and on.end does not fire.
(info: MarqueeSelectInfo) => void —
marquee
Replaces the default box entirely — render a count badge, a snapped grid outline, whatever you like. Only mounted while a drag is live.
(state: MarqueeSelectState) => ReactNode —
className
CSS class added to the root container.
string ''
style
Inline styles merged onto the root container. Put overflow: auto and a height here for a scrolling board with edge auto-scroll.
CSSProperties —
marqueeClassName
CSS class added to the default marquee box.
string ''
marqueeStyle
Inline styles merged onto the default marquee box. Set a zIndex here if your items create stacking contexts of their own.
CSSProperties —

Item boxes are measured once per drag, at pointer-down, and stored in container content coordinates — client pixels minus the root's padding-box origin, plus its scroll offset. That makes every subsequent move a plain rectangle overlap test with no DOM reads at all, and it is what keeps the box glued to the point you pressed while the edges auto-scroll under it. The trade-off is that items which move during a drag are hit-tested against where they were when it started. The pre-drag selection is kept separately from the live hit set and the mode is re-applied from scratch on every frame, which is why dragging back out of an area cleanly undoes itself and why Escape can roll the whole drag back in one step. The root exposes data-marquee-root, data-marquee-active, and data-marquee-mode; the items get data-marquee-selected and data-marquee-hit. Like the other drag primitives here, the gesture is not keyboard-reachable — pair it with checkboxes or a Select all control when selection is the only route to an action.

tsx
import { useState } from 'react';
import { MarqueeSelect } from 'react-driftkit';

function Board({ nodes }) {
  const [selected, setSelected] = useState<string[]>([]);

  return (
    <MarqueeSelect
      selection={selected}
      on={{ select: setSelected }}
      style={{ height: 600, overflow: 'auto' }}
    >
      {nodes.map((node) => (
        <div
          key={node.id}
          data-marquee-item
          data-marquee-id={node.id}
          className="node"
          style={{ position: 'absolute', left: node.x, top: node.y }}
        >
          {node.label}
        </div>
      ))}
    </MarqueeSelect>
  );
}
typescript
type MarqueeHitTest = 'intersect' | 'contain';

type MarqueeMode = 'replace' | 'add' | 'subtract' | 'toggle';

// A box in container content coordinates — the root's padding-box
// origin, with its scroll offset already folded in.
interface MarqueeRect {
  x: number;
  y: number;
  width: number;
  height: number;
}

interface MarqueeSelectState {
  active: boolean;         // true from threshold crossing until release
  rect: MarqueeRect | null;
  ids: string[];           // the effective selection, live
  hit: string[];           // caught by the box, before the mode applies
  mode: MarqueeMode;
}

interface MarqueeSelectInfo {
  rect: MarqueeRect | null;
  mode: MarqueeMode;
  hit: string[];
  base: string[];          // the selection as it was when the drag began
  elements: HTMLElement[];
}

interface MarqueeSelectProps {
  children?: ReactNode;

  itemSelector?: string;       // default '[data-marquee-item]'

  selection?: string[];        // controlled
  defaultSelection?: string[]; // uncontrolled, default []

  additive?: boolean;          // default false
  disabled?: boolean;          // default false

  behavior?: {
    hitTest?: MarqueeHitTest;    // default 'intersect'
    threshold?: number;          // default 4 — px before it is a drag
    startOnItem?: boolean;       // default false
    ignoreSelector?: string;     // form controls, links, editable content
    clearOnEmptyClick?: boolean; // default true
    markItems?: boolean;         // default true — data-attrs on the items
    autoScroll?: boolean;        // default true
    autoScrollMargin?: number;   // default 48 (px from the edge)
    autoScrollSpeed?: number;    // default 14 (px per frame at the edge)
    touch?: boolean;             // default false — touch pans instead
    mouseButton?: number;        // default 0
    getItemId?: (element: HTMLElement, index: number) => string;
    resolveMode?: (event: PointerEvent) => MarqueeMode;
  };

  on?: {
    select?: (ids: string[], info: MarqueeSelectInfo) => void;
    start?: (info: MarqueeSelectInfo) => void;
    end?: (ids: string[], info: MarqueeSelectInfo) => void;
    cancel?: (info: MarqueeSelectInfo) => void;
  };

  // Replaces the default box. Rendered only while active.
  marquee?: (state: MarqueeSelectState) => ReactNode;

  className?: string;
  style?: CSSProperties;
  marqueeClassName?: string;
  marqueeStyle?: CSSProperties;
}

Enjoying react-driftkit?

Star the repo on GitHub to help more devs discover it.

Star on GitHub