trove/cn

Marquee Select

Drag to select across a grid. Items commit as the edge crosses them and give themselves back when you pull off, the container scrolls harder the further past its edge you push, and pressing shift partway through changes the mode without the drag restarting.

Basic

Drag on the background and the rectangle takes what it crosses. Pull back off an item and it returns, because nothing was committed on the way in — the covered set is derived fresh every frame rather than accumulated.

hero-wide
hero-crop
onboarding-01
onboarding-02
onboarding-03
pricing-table
logo-mark
logo-lockup
avatar-set
empty-state
chart-weekly
chart-cohort
basic

Scrolling grid

The job it was built for. Push toward the bottom edge and the grid scrolls faster the further in you push, on a squared ramp so the edge of the band cannot be felt. Hold the pointer still there and it keeps going, because the scroll runs on a frame loop rather than on pointer events. The rectangle stays anchored to the row you started from, since it is stored in content coordinates rather than viewport ones.

hero-wide
hero-crop
onboarding-01
onboarding-02
onboarding-03
pricing-table
logo-mark
logo-lockup
avatar-set
empty-state
chart-weekly
chart-cohort
field-notes
release-clip
release-cut
ambient-loop
voiceover-en
voiceover-de
press-kit
brand-guide
icon-sheet
pattern-tile
texture-grain
mock-desktop
mock-mobile
screenshot-01
screenshot-02
screenshot-03
diagram-flow
diagram-arch
changelog
roadmap
retro-notes
budget-q3
budget-q4
team-offsite
team-portrait
sticker-pack
wallpaper-dark
wallpaper-light
hero-wide
hero-crop
onboarding-01
onboarding-02
onboarding-03
pricing-table
logo-mark
logo-lockup
avatar-set
empty-state
chart-weekly
chart-cohort
field-notes
release-clip
release-cut
ambient-loop
voiceover-en
voiceover-de
press-kit
brand-guide
scrolling

Modes, changed mid-drag

Shift adds, alt subtracts, ctrl or cmd toggles. Start a drag with no modifier and press shift halfway: the mode changes and the drag does not restart, because only the function combining the starting selection with the covered set changed. Escape gives you back the selection you began with.

Replace no modifierAdd shiftSubtract altToggle ctrl / cmd
hero-wide
hero-crop
onboarding-01
onboarding-02
onboarding-03
pricing-table
logo-mark
logo-lockup
avatar-set
empty-state
chart-weekly
chart-cohort
field-notes
release-clip
release-cut
ambient-loop
modes

Controlled, and the keyboard

`value` driven from outside with a bulk action bar reading it, and the two callbacks side by side — one firing on release, one every frame. This is also where the keyboard path is the interaction rather than a footnote: tab in, arrows to move, space to toggle, shift-arrow to extend, ⌘A for all.

hero-wide
hero-crop
onboarding-01
onboarding-02
onboarding-03
pricing-table
logo-mark
logo-lockup
avatar-set
empty-state
chart-weekly
chart-cohort
field-notes
release-clip
release-cut
ambient-loop
0 selectedlive 0

Tab into the grid, then arrows to move, space to toggle, shift-arrow to extend, ⌘A for all, escape to clear.

controlled

Install

Install the packages this component imports.

bun add motion lucide-react
Terminal

Also requires lib/utils.ts, lib/springs.ts (see Installation).

Source

Copy and paste the following code into your project.

"use client";

/**
 * MarqueeSelect — drag a rectangle across a grid and take what it covers, in a
 * scroll container, with the modes you can change without letting go. File
 * browsers, asset libraries, photo grids, media managers, anything that ends in
 * a bulk action.
 *
 * The incumbent is `@air/react-drag-to-select`, which says of itself that it
 * "doesn't actually select items; just draws the selection box and passes you
 * coordinates so you can determine that", and of scrolling that "you need to
 * modify the left and top of the selectionBox". That is the hard part handed
 * back to the caller. Everything below is that hard part.
 *
 * Against native this is parity, not a win. Finder and Figma already commit
 * live, give items back when you pull off, and auto-scroll. The claim is
 * against what is installable on the web, where the gap is total. Said plainly
 * here because the study says it plainly and a header that oversold it would be
 * the only dishonest file in the component.
 *
 * ## 1. The rectangle lives in content coordinates
 *
 * Everything else falls out of this. The anchor is stored in the scrollport's
 * *content* space:
 *
 *     anchor = (clientX - port.left + scrollLeft, clientY - port.top + scrollTop)
 *
 * The pointer is stored the other way round, in viewport space, and the live
 * corner is recomputed every frame from the pointer plus the *current* scroll
 * offset. So scrolling changes one term of the rectangle with no event firing
 * and nothing to correct.
 *
 * The rectangle is then an absolutely positioned child of the scrollport. An
 * abspos child of an `overflow: auto` element is laid out against its padding
 * box and scrolls with its content, so those content coordinates go straight
 * into the transform and the browser moves the rectangle for free. Every
 * implementation that stores the anchor in viewport space spends the rest of
 * its life subtracting scroll deltas back out of it.
 *
 * Item rectangles are measured once, at drag start, into the same space. Items
 * do not move relative to the content, so they hold for the whole gesture.
 *
 * `port` is the one thing that can go stale. If the *page* scrolls behind the
 * grid, or an ancestor resizes, the scrollport's viewport rect moves and every
 * conversion above is wrong by that delta. Re-reading it per frame is a forced
 * reflow in the middle of a gesture loop, which is the cost this whole design
 * exists to avoid, so it is cached and invalidated on `scroll` (captured, so
 * ancestor scrollers count) and by a `ResizeObserver` — never in the frame.
 * Nothing warns about this; it only shows up in a page that scrolls behind a
 * grid that also scrolls, which is the layout most apps that need this have.
 *
 * ## 2. Auto-scroll on a squared ramp, driven by rAF
 *
 *     depth = clamp(BAND - distanceInsideEdge, 0, BAND)
 *     v = AUTO_SCROLL_MAX * (depth / BAND) ** 2
 *
 * The square is the point, and it is the property `snap-panels` derived for its
 * capture weight: zero slope at the boundary, so the boundary cannot be felt.
 * Two pixels into the band moves the container 0.035px on the first frame. A
 * linear ramp over the same band moves it 0.833px — most of a pixel out of
 * nothing, which reads as the grid twitching as the pointer crosses an
 * invisible line. Slope at entry is 0.52 px/s per px against linear's 25.
 *
 *      px into band →  px/s  →  px/frame  →  ms to cross 640px
 *              8          33      0.56            19200
 *             16         133      2.22             4800
 *             24         300      5.00             2133
 *             32         533      8.89             1200
 *             48        1200     20.00              533
 *
 * The whole useful range sits in the last two thirds of the band, and that is
 * correct: the first eight pixels are the buffer that stops a pointer merely
 * approaching the edge from scrolling anything.
 *
 * It runs on `requestAnimationFrame`, not on `pointermove`. This is the bug in
 * most implementations and it is invisible in a demo: a pointer parked in the
 * edge band emits no move events, so an event-driven scroller stops the moment
 * you hold still, which is exactly when you are asking it to keep going. And
 * because the live corner is recomputed from `pointer + scroll` every frame,
 * the rectangle keeps growing under a motionless cursor with nothing extra
 * written.
 *
 * The rectangle is clamped to the content box before it is painted. An abspos
 * child contributes to scrollable overflow, so a rectangle allowed past the
 * content edge grows the scroll area, which auto-scroll then chases, which
 * grows it again.
 *
 * ## 3. Three sets, not one
 *
 *     base     the selection when the drag started
 *     covered  what the rectangle covers right now
 *     result   f(base, covered), recomputed every frame
 *
 *     replace   covered
 *     add       base ∪ covered      (shift)
 *     subtract  base \ covered      (alt)
 *     toggle    base △ covered      (ctrl / cmd)
 *
 * Nothing accumulates. `covered` is derived fresh each frame and `base` is
 * never touched until release, so three things other implementations list as
 * missing features fall out for free: pulling the rectangle off an item gives
 * it back, because nothing was committed; changing modifier mid-drag changes
 * only `f`, so the drag does not restart; and escape discards `covered` and
 * keeps `base`. An implementation that mutates one set as the rectangle sweeps
 * can do none of the three, which is why every version that does it that way
 * lists all three as missing.
 *
 * Modifiers are read from `keydown`/`keyup` on `window` for the life of the
 * drag, because pressing shift emits no pointer event.
 *
 * `pointercancel` cancels rather than commits, unlike `snap-panels` where
 * ending a drag in place is harmless. A cancelled pointer means the system took
 * the gesture, and committing a selection somebody was still composing on the
 * strength of an interrupted gesture is the wrong side to fail on.
 *
 * ## 4. Intersection, measured once
 *
 * `"touch"` by default: any overlap takes the item, which is Finder and what
 * people expect. `"enclose"` requires containment and is two comparisons either
 * way. The cost that matters is not the comparison, it is the measurement:
 * `getBoundingClientRect()` inside the loop is a reflow per item per frame, so
 * everything is measured once at drag start into a flat array and the frame
 * loop does no DOM reads beyond one `scrollTop`/`scrollLeft` pair.
 *
 * ## State, keyboard, screen readers
 *
 * There is no loading, error, or recovery state: this is a selection over
 * whatever the caller rendered, and an empty container is simply a container
 * with nothing to select, which needs no special case beyond a drag that
 * commits an empty set.
 *
 * **Drag to select is not an accessible interaction and cannot be made into
 * one.** So the keyboard path is the primary interaction and the marquee is an
 * accelerator on top of it. `role="listbox"` with `aria-multiselectable`,
 * `role="option"` and `aria-selected` per item, roving `tabIndex`. Arrows move
 * focus geometrically — nearest centre in the requested direction, from the
 * same measurement the drag uses — so one implementation serves a grid, a
 * wrapping flex row, and a list without knowing which it is in. `Space` and
 * `Enter` toggle, `Shift`-arrow and shift-click extend from the anchor,
 * `Cmd`/`Ctrl`-`A` selects all, `Home`/`End` jump to the ends, `Escape`
 * clears.
 *
 * The container itself is tabbable only while no item holds focus, and hands
 * focus straight to the first item when it receives it. That is what keeps a
 * grid of four hundred photos to one tab stop.
 *
 * One polite live region carries the count, updated on commit and on keyboard
 * changes and **never per frame** — a region written inside the drag loop reads
 * every intermediate count aloud and is worse than having none.
 *
 * Clicking is owned here too, because a component that only marquees would
 * leave the caller to write the ordinary cases: plain click selects one,
 * `Cmd`/`Ctrl`-click toggles, shift-click extends from the anchor.
 *
 * One of those cases is not what it looks like. A plain press on an item that
 * is *already* part of a multi-selection must not collapse the selection on the
 * way down — that would make "select five, then drag them somewhere"
 * impossible, which is the whole reason `shouldStart` leaves item presses alone
 * — but it must collapse on release if no drag followed, which is what every
 * file manager does. So the press only records the intent, `pointermove` past
 * `ENGAGE_SLOP` abandons it, and `pointerup` carries it out. Measured against
 * Finder rather than assumed, and it is the one place where doing the obvious
 * thing on `pointerdown` is wrong.
 *
 * ## Motion
 *
 * There is very little of it, and that is the finding rather than an omission.
 * This is a constant-tier interaction: the rectangle tracks the pointer one to
 * one with no spring and no smoothing, because smoothing puts lag between the
 * pointer and the box and lag is the thing the component exists to remove.
 *
 * The rectangle vanishes on release rather than fading. The selected items are
 * the result of the gesture, and a rectangle fading out sits on top of the
 * thing you just made for the length of the fade. Exits read final.
 *
 * Selection on an item is colour only — no scale, no lift. At forty items
 * crossing in half a second anything with travel in it turns the grid to
 * static.
 *
 * The one flourish is the count, spent because it carries information:
 *
 * - It appears only past `COUNT_FLOOR`, so a box drawn round a single thing has
 *   no chrome at all. Presence is the discrete change, so *that* is what gets
 *   `spring.quick`.
 * - The digits do **not** crossfade, which is a deliberate departure from the
 *   house rule that a label whose text changes never swaps. That rule is for
 *   labels that change occasionally. This one changes on every item the
 *   rectangle crosses, which makes it continuous feedback rather than a label
 *   swap, and a crossfade on continuous feedback reads as lag. Tracking the
 *   input while it is happening wins over the crossfade here.
 * - It sits `COUNT_OFFSET` from the cursor on the diagonal the drag is heading,
 *   so it is outside the rectangle and never over the corner being watched. The
 *   sign is the last *non-zero* direction, so an axis-locked drag does not park
 *   the badge under the cursor.
 * - Only the offset is sprung, on `spring.fast`, whose JSDoc names this case
 *   exactly ("live highlight rects that follow the cursor, drag indicators").
 *   The position itself is set flat. Springing the position would make the
 *   badge swim behind a fast drag; springing only the offset means reversing
 *   direction swings the badge round the cursor instead of teleporting it
 *   across.
 *
 * Reduced motion pins the offset with no spring. Auto-scroll stays, because it
 * is navigation rather than decoration.
 *
 * ## Departures, and things chosen rather than assumed
 *
 * - **The rectangle's size is written every frame without a `useReducedMotion`
 *   gate**, which reads as a violation of the house rule until you know it is
 *   not an animation. It is the pointer's position rendered as a box — the same
 *   category as the divider in `snap-panels` tracking one to one — and
 *   `<MotionConfig reducedMotion="user">` never reaches it because it is a
 *   direct style write rather than a Motion value.
 * - **Item order comes from one `querySelectorAll` in document order**, not
 *   from a registration effect. The study rejected *finding items by selector*
 *   as the public API, because a caller tagging their own divs cannot be given
 *   focus management; that is not this. `MarqueeSelectItem` is still a real
 *   component owning ARIA, focus, and `data-selected`. The DOM is only being
 *   used as what it already is: an ordered index, O(n) and always correct,
 *   where a parallel registry would be mount-ordered and drift on reorder.
 * - **Selection is written to the DOM as `data-selected` during the drag and
 *   reconciled on end.** Pushing the covered set into React state re-renders
 *   every item every frame. Only the symmetric difference against the previous
 *   frame is written, so a rectangle crossing three new items costs three
 *   attribute writes rather than five hundred. React renders the same attribute
 *   from committed state, which is what keeps SSR and hydration honest; the two
 *   writers never disagree because every end path (commit, escape, cancel)
 *   reconciles the DOM to the value React is about to render.
 * - `value` is therefore *deliberately stale* during a drag, which is why there
 *   are two callbacks. `onValueChange` fires on commit. `onValueChanging` fires
 *   every frame, and a caller who sets React state in it has re-created the
 *   per-frame render this design exists to avoid. Same shape and same warning as
 *   `snap-panels`' `onLayoutChanging`.
 * - The context value's identity is memoised. A context carrying a fresh object
 *   re-renders all N items on every parent render, which at the sizes this
 *   component is for is the difference between a grid and a slideshow.
 * - The root is the scrollport *and* the layout container. Splitting them reads
 *   as more honest and is worse: it makes "which element scrolls" a question
 *   the caller answers, and answering it wrong breaks the coordinate space
 *   everything above rests on.
 * - The count badge is positioned in content coordinates inside the scrollport
 *   rather than fixed to the viewport. Same conversion as the rectangle, so it
 *   sits under the cursor for free — including while auto-scroll runs under a
 *   motionless pointer — and it cannot be re-parented by an ancestor transform
 *   the way `position: fixed` can.
 * - `string[]` at the boundary rather than `Set<string>`, because an array is
 *   what goes into a request, a URL, and React state without conversion. The
 *   internal representation is a `Set`.
 * - No item dragging: this selects, `dnd-kit` drags, and `shouldStart` is the
 *   seam. No virtualisation: measuring needs mounted elements, and the honest
 *   fix is a caller-supplied rectangle source, which should not be guessed at
 *   before somebody needs it. One scrollport, no nesting.
 */

import {
  createContext,
  useCallback,
  useContext,
  useEffect,
  useMemo,
  useRef,
  useState,
  type ComponentPropsWithoutRef,
  type PointerEvent as ReactPointerEvent,
  type ReactNode,
} from "react";
import { animate, motion, useMotionValue, useReducedMotion, useTransform } from "motion/react";

import { cn } from "@/lib/utils";
import { spring } from "@/lib/springs";

/** How the rectangle decides it has an item. `"touch"` is Finder and the
 *  default; `"enclose"` is the CAD convention and costs the same. */
export type MarqueeIntersection = "touch" | "enclose";

type Mode = "replace" | "add" | "subtract" | "toggle";

/** Pointer travel before the rectangle paints. Same figure as
 *  `list-detail-morph`'s `ENGAGE_SLOP`, for a different reason: there it stops
 *  a scroll being stolen, here nothing is at stake either way and it stops a
 *  rectangle flashing on an ordinary click. */
const ENGAGE_SLOP = 6;
/** Edge band, in px, measured inward from the scrollport's edge. */
const AUTO_SCROLL_BAND = 48;
/** Speed at the edge and beyond, in px/s. Crosses a 640px viewport in 533ms. */
const AUTO_SCROLL_MAX = 1200;
/** The badge stays out of the way below this. A box drawn round one thing does
 *  not need to be told it contains one thing. */
const COUNT_FLOOR = 1;
/** Badge offset from the cursor, on the diagonal the drag is heading. */
const COUNT_OFFSET = 14;
/** Trailing debounce on the live region, so a held arrow key announces where it
 *  stopped rather than every cell on the way. */
const ANNOUNCE_DELAY = 150;

const ITEM_SELECTOR = "[data-slot='marquee-select-item']";

function clamp(value: number, min: number, max: number) {
  return Math.min(Math.max(value, min), max);
}

/** Zero slope at `depth = 0`, so the edge of the band cannot be felt. */
function autoScrollVelocity(depth: number, band: number, max: number) {
  if (depth <= 0 || band <= 0) return 0;
  const t = Math.min(1, depth / band);
  return max * t * t;
}

function modeFromEvent(event: {
  shiftKey: boolean;
  altKey: boolean;
  ctrlKey: boolean;
  metaKey: boolean;
}): Mode {
  if (event.altKey) return "subtract";
  if (event.metaKey || event.ctrlKey) return "toggle";
  if (event.shiftKey) return "add";
  return "replace";
}

function combine(base: Set<string>, covered: Set<string>, mode: Mode): Set<string> {
  if (mode === "replace") return new Set(covered);
  const next = new Set(base);
  if (mode === "add") {
    for (const value of covered) next.add(value);
    return next;
  }
  if (mode === "subtract") {
    for (const value of covered) next.delete(value);
    return next;
  }
  for (const value of covered) {
    if (next.has(value)) next.delete(value);
    else next.add(value);
  }
  return next;
}

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

/** Content-space box for one item, flattened to numbers so the frame loop
 *  touches no objects it did not allocate at drag start. */
interface ItemBox {
  value: string;
  element: HTMLElement;
  x: number;
  y: number;
  right: number;
  bottom: number;
}

interface Port {
  left: number;
  top: number;
}

/** `new DOMRect()` at module scope returns a 500 — a `"use client"` file still
 *  runs on the server and Node has no `DOMRect`. Nothing here needs one, but
 *  the fallback is spelled out as a plain object for the same reason. */
const EMPTY_PORT: Port = { left: 0, top: 0 };

interface DragState {
  pointerId: number;
  /** Client coordinates the press landed at, for the engage threshold. */
  startClientX: number;
  startClientY: number;
  /** Latest client coordinates. The frame loop turns these into content space. */
  clientX: number;
  clientY: number;
  /** Content coordinates, fixed for the gesture. */
  anchorX: number;
  anchorY: number;
  base: Set<string>;
  /** Last result written to the DOM, so each frame only writes its difference. */
  applied: Set<string>;
  boxes: ItemBox[];
  byValue: Map<string, HTMLElement>;
  mode: Mode;
  engaged: boolean;
  frame: number | null;
  lastTime: number;
  signX: number;
  signY: number;
  contentWidth: number;
  contentHeight: number;
  maxScrollX: number;
  maxScrollY: number;
  viewWidth: number;
  viewHeight: number;
}

interface CountHandle {
  set(count: number, x: number, y: number, signX: number, signY: number): void;
  hide(): void;
}

interface MarqueeContextValue {
  selected: Set<string>;
  focusValue: string | null;
  disabled: boolean;
  onItemPointerDown(value: string, event: ReactPointerEvent<HTMLElement>): void;
}

const MarqueeContext = createContext<MarqueeContextValue | null>(null);

function useMarqueeContext(component: string) {
  const context = useContext(MarqueeContext);
  if (!context) {
    throw new Error(`<${component}> must be rendered inside <MarqueeSelect>.`);
  }
  return context;
}

export interface MarqueeSelectProps extends Omit<
  ComponentPropsWithoutRef<"div">,
  "onChange" | "defaultValue"
> {
  /** Controlled selection, in the order items were added to it. */
  value?: string[];
  defaultValue?: string[];
  /** Fires once, on release or on a keyboard change. Persist here. */
  onValueChange?: (value: string[]) => void;
  /** Fires every frame of a drag. Read the header before using it. */
  onValueChanging?: (value: string[]) => void;
  /** `"touch"` takes an item on any overlap, `"enclose"` on containment. */
  intersection?: MarqueeIntersection;
  /** Whether a press may start a marquee. Defaults to "the target is not an
   *  item", which is the seam that lets per-item dragging coexist with this. */
  shouldStart?: (event: PointerEvent) => boolean;
  autoScroll?: boolean | { band?: number; max?: number };
  disabled?: boolean;
  /** Replaces the count badge. `false` removes it. */
  renderCount?: ((count: number) => ReactNode) | false;
}

export function MarqueeSelect({
  value,
  defaultValue,
  onValueChange,
  onValueChanging,
  intersection = "touch",
  shouldStart,
  autoScroll = true,
  disabled = false,
  renderCount,
  className,
  children,
  onPointerDown,
  onKeyDown,
  onFocus,
  ...props
}: MarqueeSelectProps) {
  const rootRef = useRef<HTMLDivElement>(null);
  const rectRef = useRef<HTMLDivElement>(null);
  const countRef = useRef<CountHandle>(null);
  const portRef = useRef<Port>(EMPTY_PORT);
  const dragRef = useRef<DragState | null>(null);
  const reduceMotion = useReducedMotion();

  const [uncontrolled, setUncontrolled] = useState<string[]>(() => defaultValue ?? []);
  const committed = value ?? uncontrolled;
  const selected = useMemo(() => new Set(committed), [committed]);
  const selectedRef = useRef(selected);
  selectedRef.current = selected;

  const [focusValue, setFocusValue] = useState<string | null>(null);
  /** Where a shift-extend measures from. Set by every non-extending selection. */
  const anchorValueRef = useRef<string | null>(null);
  /** A plain press on an item that is already part of a multi-selection. It
   *  must not collapse the selection on the way down — that would make "select
   *  five, then drag them somewhere" impossible, which is the whole point of
   *  `shouldStart` leaving item presses alone — but it must collapse on release
   *  if no drag followed, which is what every file manager does. So the
   *  decision is deferred to `pointerup` and abandoned by `pointermove`. */
  const pendingClickRef = useRef<{ value: string; x: number; y: number } | null>(null);
  const [announcement, setAnnouncement] = useState("");

  const band =
    typeof autoScroll === "object" ? (autoScroll.band ?? AUTO_SCROLL_BAND) : AUTO_SCROLL_BAND;
  const maxSpeed =
    typeof autoScroll === "object" ? (autoScroll.max ?? AUTO_SCROLL_MAX) : AUTO_SCROLL_MAX;
  const autoScrollOn = autoScroll !== false;

  // Callbacks are read through refs so the frame loop and the window listeners
  // never need re-binding, and so the context value below can stay stable.
  const changeRef = useRef(onValueChange);
  changeRef.current = onValueChange;
  const changingRef = useRef(onValueChanging);
  changingRef.current = onValueChanging;
  const shouldStartRef = useRef(shouldStart);
  shouldStartRef.current = shouldStart;
  const intersectionRef = useRef(intersection);
  intersectionRef.current = intersection;
  const autoScrollRef = useRef({ on: autoScrollOn, band, max: maxSpeed });
  autoScrollRef.current = { on: autoScrollOn, band, max: maxSpeed };

  const readPort = useCallback(() => {
    const element = rootRef.current;
    if (!element) return;
    const rect = element.getBoundingClientRect();
    portRef.current = { left: rect.left, top: rect.top };
  }, []);

  useEffect(() => {
    const element = rootRef.current;
    if (!element) return;
    readPort();
    const observer = new ResizeObserver(readPort);
    observer.observe(element);
    return () => observer.disconnect();
  }, [readPort]);

  /** Document order, straight off the DOM. Always current, never drifts. */
  const readItems = useCallback(() => {
    const element = rootRef.current;
    if (!element) return [] as HTMLElement[];
    return Array.from(element.querySelectorAll<HTMLElement>(ITEM_SELECTOR)).filter(
      (item) => item.dataset.disabled === undefined,
    );
  }, []);

  const commit = useCallback(
    (next: Set<string>) => {
      const array = Array.from(next);
      if (value === undefined) setUncontrolled(array);
      changeRef.current?.(array);
    },
    [value],
  );

  // ---------------------------------------------------------------- keyboard

  const applyKeyboardSelection = useCallback(
    (next: Set<string>) => {
      selectedRef.current = next;
      commit(next);
    },
    [commit],
  );

  const selectRange = useCallback(
    (from: string | null, to: string) => {
      const items = readItems();
      const values = items.map((item) => item.dataset.value!);
      const start = from === null ? 0 : values.indexOf(from);
      const end = values.indexOf(to);
      if (start < 0 || end < 0) return new Set([to]);
      const [low, high] = start <= end ? [start, end] : [end, start];
      return new Set(values.slice(low, high + 1));
    },
    [readItems],
  );

  /** Nearest centre in the requested direction. One implementation serves a
   *  grid, a wrapping row, and a list, because it never has to know which. */
  const neighbourIn = useCallback(
    (from: string, dx: number, dy: number) => {
      const items = readItems();
      if (items.length === 0) return null;
      const port = portRef.current;
      const boxes = items.map((item) => {
        const rect = item.getBoundingClientRect();
        return {
          value: item.dataset.value!,
          cx: rect.left - port.left + rect.width / 2,
          cy: rect.top - port.top + rect.height / 2,
        };
      });
      const origin = boxes.find((box) => box.value === from);
      if (!origin) return boxes[0]?.value ?? null;

      let best: string | null = null;
      let bestScore = Infinity;
      for (const box of boxes) {
        if (box.value === from) continue;
        const offX = box.cx - origin.cx;
        const offY = box.cy - origin.cy;
        // Travel along the axis asked for, drift across it. Anything not
        // actually in that direction is out.
        const along = dx !== 0 ? offX * dx : offY * dy;
        if (along <= 0.5) continue;
        const across = dx !== 0 ? Math.abs(offY) : Math.abs(offX);
        // Drift is weighted heavily so a grid steps down its own column
        // rather than jumping to whatever happens to be nearest by hypotenuse.
        const score = along + across * 3;
        if (score < bestScore) {
          bestScore = score;
          best = box.value;
        }
      }
      return best;
    },
    [readItems],
  );

  const moveFocus = useCallback((next: string | null) => {
    if (!next) return;
    setFocusValue(next);
    const element = rootRef.current?.querySelector<HTMLElement>(
      `${ITEM_SELECTOR}[data-value="${CSS.escape(next)}"]`,
    );
    element?.focus();
  }, []);

  const handleKeyDown = useCallback(
    (event: React.KeyboardEvent<HTMLDivElement>) => {
      onKeyDown?.(event);
      if (event.defaultPrevented || disabled) return;

      const items = readItems();
      if (items.length === 0) return;
      const values = items.map((item) => item.dataset.value!);
      const current = focusValue ?? values[0]!;

      const directions: Record<string, [number, number]> = {
        ArrowRight: [1, 0],
        ArrowLeft: [-1, 0],
        ArrowDown: [0, 1],
        ArrowUp: [0, -1],
      };

      if (event.key in directions) {
        const [dx, dy] = directions[event.key]!;
        const next = neighbourIn(current, dx, dy);
        if (!next) return;
        event.preventDefault();
        moveFocus(next);
        if (event.shiftKey) {
          applyKeyboardSelection(selectRange(anchorValueRef.current, next));
        }
        return;
      }

      if (event.key === "Home" || event.key === "End") {
        event.preventDefault();
        const next = event.key === "Home" ? values[0]! : values[values.length - 1]!;
        moveFocus(next);
        if (event.shiftKey) applyKeyboardSelection(selectRange(anchorValueRef.current, next));
        return;
      }

      if (event.key === " " || event.key === "Enter") {
        event.preventDefault();
        if (event.shiftKey) {
          applyKeyboardSelection(selectRange(anchorValueRef.current, current));
          return;
        }
        const next = new Set(selectedRef.current);
        if (next.has(current)) next.delete(current);
        else next.add(current);
        anchorValueRef.current = current;
        applyKeyboardSelection(next);
        return;
      }

      if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "a") {
        event.preventDefault();
        applyKeyboardSelection(new Set(values));
        return;
      }

      if (event.key === "Escape") {
        if (dragRef.current) return; // the drag's own window listener owns this
        if (selectedRef.current.size === 0) return;
        event.preventDefault();
        applyKeyboardSelection(new Set());
      }
    },
    [
      applyKeyboardSelection,
      disabled,
      focusValue,
      moveFocus,
      neighbourIn,
      onKeyDown,
      readItems,
      selectRange,
    ],
  );

  const handleFocus = useCallback(
    (event: React.FocusEvent<HTMLDivElement>) => {
      onFocus?.(event);
      if (event.target !== event.currentTarget || disabled) return;
      const first = readItems()[0];
      if (!first) return;
      setFocusValue(first.dataset.value!);
      first.focus();
    },
    [disabled, onFocus, readItems],
  );

  // -------------------------------------------------------------------- drag

  const paintRect = useCallback((x: number, y: number, width: number, height: number) => {
    const element = rectRef.current;
    if (!element) return;
    element.style.display = "block";
    element.style.transform = `translate3d(${x}px, ${y}px, 0)`;
    element.style.width = `${width}px`;
    element.style.height = `${height}px`;
  }, []);

  const hideRect = useCallback(() => {
    const element = rectRef.current;
    if (element) element.style.display = "none";
    countRef.current?.hide();
  }, []);

  /** Write only what changed. A rectangle crossing three new items costs three
   *  attribute writes, not five hundred. */
  const applyToDom = useCallback((drag: DragState, next: Set<string>) => {
    for (const id of next) {
      if (drag.applied.has(id)) continue;
      const element = drag.byValue.get(id);
      if (!element) continue;
      element.setAttribute("data-selected", "true");
      element.setAttribute("aria-selected", "true");
    }
    for (const id of drag.applied) {
      if (next.has(id)) continue;
      const element = drag.byValue.get(id);
      if (!element) continue;
      element.removeAttribute("data-selected");
      element.setAttribute("aria-selected", "false");
    }
    drag.applied = next;
  }, []);

  const endDrag = useCallback(
    (outcome: "commit" | "cancel") => {
      const drag = dragRef.current;
      if (!drag) return;
      dragRef.current = null;
      if (drag.frame !== null) cancelAnimationFrame(drag.frame);

      const root = rootRef.current;
      root?.releasePointerCapture?.(drag.pointerId);
      if (root) {
        root.style.touchAction = "";
        root.style.userSelect = "";
      }
      hideRect();

      // Both paths reconcile the DOM to whatever React is about to render, so
      // the imperative writes above and React's own never disagree.
      const next = outcome === "cancel" ? drag.base : drag.applied;
      applyToDom(drag, next);
      if (outcome === "cancel") {
        selectedRef.current = next;
        return;
      }
      if (sameSet(next, selectedRef.current)) return;
      selectedRef.current = next;
      commit(next);
    },
    [applyToDom, commit, hideRect],
  );

  const frame = useCallback(
    (time: number) => {
      const drag = dragRef.current;
      const root = rootRef.current;
      if (!drag || !root) return;
      drag.frame = requestAnimationFrame(frame);

      const dt = drag.lastTime === 0 ? 0 : Math.min(0.05, (time - drag.lastTime) / 1000);
      drag.lastTime = time;

      // --- reads, all of them, before any write ------------------------------
      const scrollX = root.scrollLeft;
      const scrollY = root.scrollTop;
      const port = portRef.current;
      const config = autoScrollRef.current;

      // --- auto-scroll -------------------------------------------------------
      let nextScrollX = scrollX;
      let nextScrollY = scrollY;
      if (config.on && dt > 0) {
        const localX = drag.clientX - port.left;
        const localY = drag.clientY - port.top;
        const leftDepth = clamp(config.band - localX, 0, config.band);
        const rightDepth = clamp(config.band - (drag.viewWidth - localX), 0, config.band);
        const topDepth = clamp(config.band - localY, 0, config.band);
        const bottomDepth = clamp(config.band - (drag.viewHeight - localY), 0, config.band);

        const vx =
          autoScrollVelocity(rightDepth, config.band, config.max) -
          autoScrollVelocity(leftDepth, config.band, config.max);
        const vy =
          autoScrollVelocity(bottomDepth, config.band, config.max) -
          autoScrollVelocity(topDepth, config.band, config.max);

        if (vx !== 0) nextScrollX = clamp(scrollX + vx * dt, 0, drag.maxScrollX);
        if (vy !== 0) nextScrollY = clamp(scrollY + vy * dt, 0, drag.maxScrollY);
      }

      // --- the rectangle, in content space -----------------------------------
      const pointerX = clamp(drag.clientX - port.left + nextScrollX, 0, drag.contentWidth);
      const pointerY = clamp(drag.clientY - port.top + nextScrollY, 0, drag.contentHeight);
      const x = Math.min(drag.anchorX, pointerX);
      const y = Math.min(drag.anchorY, pointerY);
      const width = Math.abs(pointerX - drag.anchorX);
      const height = Math.abs(pointerY - drag.anchorY);

      // --- covered, then result ----------------------------------------------
      const right = x + width;
      const bottom = y + height;
      const enclose = intersectionRef.current === "enclose";
      const covered = new Set<string>();
      for (const box of drag.boxes) {
        const hit = enclose
          ? box.x >= x && box.y >= y && box.right <= right && box.bottom <= bottom
          : box.x < right && box.right > x && box.y < bottom && box.bottom > y;
        if (hit) covered.add(box.value);
      }
      const result = combine(drag.base, covered, drag.mode);

      // --- writes ------------------------------------------------------------
      if (nextScrollX !== scrollX) root.scrollLeft = nextScrollX;
      if (nextScrollY !== scrollY) root.scrollTop = nextScrollY;
      paintRect(x, y, width, height);

      if (!sameSet(result, drag.applied)) {
        applyToDom(drag, result);
        changingRef.current?.(Array.from(result));
      }

      const dx = pointerX - drag.anchorX;
      const dy = pointerY - drag.anchorY;
      if (dx !== 0) drag.signX = Math.sign(dx);
      if (dy !== 0) drag.signY = Math.sign(dy);
      countRef.current?.set(result.size, pointerX, pointerY, drag.signX, drag.signY);
    },
    [applyToDom, paintRect],
  );

  const handlePointerDown = useCallback(
    (event: ReactPointerEvent<HTMLDivElement>) => {
      onPointerDown?.(event);
      if (event.defaultPrevented || disabled || dragRef.current) return;
      if (event.button !== 0 || !event.isPrimary) return;

      const root = rootRef.current;
      if (!root) return;

      const native = event.nativeEvent;
      const allowed = shouldStartRef.current
        ? shouldStartRef.current(native)
        : !(native.target as Element | null)?.closest?.(ITEM_SELECTOR);
      if (!allowed) return;

      readPort();
      const port = portRef.current;
      const scrollX = root.scrollLeft;
      const scrollY = root.scrollTop;

      const items = readItems();
      const byValue = new Map<string, HTMLElement>();
      const boxes: ItemBox[] = items.map((item) => {
        const rect = item.getBoundingClientRect();
        const id = item.dataset.value!;
        byValue.set(id, item);
        const x = rect.left - port.left + scrollX;
        const y = rect.top - port.top + scrollY;
        return { value: id, element: item, x, y, right: x + rect.width, bottom: y + rect.height };
      });

      dragRef.current = {
        pointerId: event.pointerId,
        startClientX: event.clientX,
        startClientY: event.clientY,
        clientX: event.clientX,
        clientY: event.clientY,
        anchorX: event.clientX - port.left + scrollX,
        anchorY: event.clientY - port.top + scrollY,
        base: new Set(selectedRef.current),
        applied: new Set(selectedRef.current),
        boxes,
        byValue,
        mode: modeFromEvent(event),
        engaged: false,
        frame: null,
        lastTime: 0,
        signX: 1,
        signY: 1,
        contentWidth: root.scrollWidth,
        contentHeight: root.scrollHeight,
        maxScrollX: Math.max(0, root.scrollWidth - root.clientWidth),
        maxScrollY: Math.max(0, root.scrollHeight - root.clientHeight),
        viewWidth: root.clientWidth,
        viewHeight: root.clientHeight,
      };
    },
    [disabled, onPointerDown, readItems, readPort],
  );

  // The move/up/key listeners live on `window` for the life of a drag rather
  // than on the element: a modifier press emits no pointer event, and a release
  // outside the scrollport still has to end the gesture.
  useEffect(() => {
    const root = rootRef.current;
    if (!root) return;

    const onMove = (event: PointerEvent) => {
      const pending = pendingClickRef.current;
      if (pending) {
        const travelled = Math.hypot(event.clientX - pending.x, event.clientY - pending.y);
        // Something else is dragging these items. Leave the selection alone.
        if (travelled >= ENGAGE_SLOP) pendingClickRef.current = null;
      }
      const drag = dragRef.current;
      if (!drag || event.pointerId !== drag.pointerId) return;
      drag.clientX = event.clientX;
      drag.clientY = event.clientY;

      if (!drag.engaged) {
        const travelled = Math.hypot(
          event.clientX - drag.startClientX,
          event.clientY - drag.startClientY,
        );
        if (travelled < ENGAGE_SLOP) return;
        drag.engaged = true;
        // Only now is this a gesture rather than a click, so only now is it
        // right to take the pointer and suppress text selection.
        root.setPointerCapture?.(drag.pointerId);
        root.style.touchAction = "none";
        root.style.userSelect = "none";
        drag.lastTime = 0;
        drag.frame = requestAnimationFrame(frame);
      }
      event.preventDefault();
    };

    const onUp = (event: PointerEvent) => {
      const pending = pendingClickRef.current;
      if (pending) {
        pendingClickRef.current = null;
        const next = new Set([pending.value]);
        selectedRef.current = next;
        commit(next);
        return;
      }
      const drag = dragRef.current;
      if (!drag || event.pointerId !== drag.pointerId) return;
      // A press that never engaged is a background click, which clears.
      if (!drag.engaged) {
        dragRef.current = null;
        if (drag.mode === "replace" && selectedRef.current.size > 0) {
          selectedRef.current = new Set();
          anchorValueRef.current = null;
          commit(new Set());
        }
        return;
      }
      endDrag("commit");
    };

    const onCancel = (event: PointerEvent) => {
      pendingClickRef.current = null;
      const drag = dragRef.current;
      if (!drag || event.pointerId !== drag.pointerId) return;
      if (!drag.engaged) {
        dragRef.current = null;
        return;
      }
      endDrag("cancel");
    };

    const onModifier = (event: KeyboardEvent) => {
      const drag = dragRef.current;
      if (!drag) return;
      if (event.type === "keydown" && event.key === "Escape") {
        event.preventDefault();
        endDrag("cancel");
        return;
      }
      drag.mode = modeFromEvent(event);
    };

    const onScroll = () => {
      if (dragRef.current) readPort();
    };

    window.addEventListener("pointermove", onMove, { passive: false });
    window.addEventListener("pointerup", onUp);
    window.addEventListener("pointercancel", onCancel);
    window.addEventListener("keydown", onModifier);
    window.addEventListener("keyup", onModifier);
    // Captured, so a scrolling ancestor counts too.
    window.addEventListener("scroll", onScroll, { capture: true, passive: true });
    return () => {
      window.removeEventListener("pointermove", onMove);
      window.removeEventListener("pointerup", onUp);
      window.removeEventListener("pointercancel", onCancel);
      window.removeEventListener("keydown", onModifier);
      window.removeEventListener("keyup", onModifier);
      window.removeEventListener("scroll", onScroll, { capture: true });
    };
  }, [commit, endDrag, frame, readPort]);

  useEffect(() => {
    return () => {
      const drag = dragRef.current;
      if (drag?.frame !== null && drag?.frame !== undefined) cancelAnimationFrame(drag.frame);
    };
  }, []);

  // ------------------------------------------------------------------- click

  const onItemPointerDown = useCallback(
    (itemValue: string, event: ReactPointerEvent<HTMLElement>) => {
      if (disabled || event.button !== 0 || !event.isPrimary) return;
      setFocusValue(itemValue);

      const mode = modeFromEvent(event);
      if (mode === "add") {
        applyKeyboardSelection(selectRange(anchorValueRef.current, itemValue));
        return;
      }
      if (mode === "toggle") {
        const next = new Set(selectedRef.current);
        if (next.has(itemValue)) next.delete(itemValue);
        else next.add(itemValue);
        anchorValueRef.current = itemValue;
        applyKeyboardSelection(next);
        return;
      }
      anchorValueRef.current = itemValue;
      if (selectedRef.current.has(itemValue)) {
        // Already selected. Collapsing to it now would break dragging a
        // multi-selection, so the decision waits for the release; see
        // `pendingClickRef`. A single-item selection has nothing to collapse.
        if (selectedRef.current.size > 1) {
          pendingClickRef.current = { value: itemValue, x: event.clientX, y: event.clientY };
        }
        return;
      }
      applyKeyboardSelection(new Set([itemValue]));
    },
    [applyKeyboardSelection, disabled, selectRange],
  );

  // ---------------------------------------------------------------- announce

  useEffect(() => {
    const timer = window.setTimeout(() => {
      setAnnouncement(
        committed.length === 0 ? "No items selected" : `${committed.length} selected`,
      );
    }, ANNOUNCE_DELAY);
    return () => window.clearTimeout(timer);
  }, [committed]);

  const context = useMemo<MarqueeContextValue>(
    () => ({ selected, focusValue, disabled, onItemPointerDown }),
    [selected, focusValue, disabled, onItemPointerDown],
  );

  return (
    <MarqueeContext.Provider value={context}>
      <div
        ref={rootRef}
        data-slot="marquee-select"
        role="listbox"
        aria-multiselectable="true"
        aria-disabled={disabled || undefined}
        tabIndex={focusValue === null && !disabled ? 0 : -1}
        onPointerDown={handlePointerDown}
        onKeyDown={handleKeyDown}
        onFocus={handleFocus}
        className={cn(
          "relative outline-none",
          // The house focus ring, on the container only while it is the tab
          // stop. Once an item holds focus the ring belongs to the item.
          "focus-visible:ring-2 focus-visible:ring-ring",
          className,
        )}
        {...props}
      >
        {children}
        <div
          ref={rectRef}
          data-slot="marquee-select-rect"
          aria-hidden="true"
          className="pointer-events-none absolute left-0 top-0 z-20 rounded-sm border border-marquee-stroke bg-marquee-fill"
          style={{ display: "none" }}
        />
        {renderCount !== false && (
          <MarqueeCount ref={countRef} reduceMotion={!!reduceMotion} render={renderCount} />
        )}
        <span className="sr-only" role="status" aria-live="polite">
          {announcement}
        </span>
      </div>
    </MarqueeContext.Provider>
  );
}

/** Isolated so the frame loop can push a count into it without re-rendering the
 *  grid. Position rides motion values (no render at all); the number is state,
 *  and only sets when it actually changes. */
function MarqueeCount({
  ref,
  reduceMotion,
  render,
}: {
  ref: React.Ref<CountHandle>;
  reduceMotion: boolean;
  render?: (count: number) => ReactNode;
}) {
  const [count, setCount] = useState(0);
  const [visible, setVisible] = useState(false);
  const posX = useMotionValue(0);
  const posY = useMotionValue(0);
  const offX = useMotionValue(COUNT_OFFSET);
  const offY = useMotionValue(COUNT_OFFSET);
  const signRef = useRef({ x: 1, y: 1 });
  const countRef = useRef(0);

  const x = useTransform([posX, offX], ([a, b]: number[]) => (a ?? 0) + (b ?? 0));
  const y = useTransform([posY, offY], ([a, b]: number[]) => (a ?? 0) + (b ?? 0));

  const handle = useMemo<CountHandle>(
    () => ({
      set(next, px, py, signX, signY) {
        posX.set(px);
        posY.set(py);
        if (signX !== signRef.current.x) {
          signRef.current.x = signX;
          const target = signX * COUNT_OFFSET;
          if (reduceMotion) offX.set(target);
          else animate(offX, target, spring.fast.enter);
        }
        if (signY !== signRef.current.y) {
          signRef.current.y = signY;
          const target = signY * COUNT_OFFSET;
          if (reduceMotion) offY.set(target);
          else animate(offY, target, spring.fast.enter);
        }
        if (next !== countRef.current) {
          countRef.current = next;
          setCount(next);
        }
        setVisible(next > COUNT_FLOOR);
      },
      hide() {
        setVisible(false);
      },
    }),
    [offX, offY, posX, posY, reduceMotion],
  );

  useMemo(() => {
    if (typeof ref === "function") ref(handle);
    else if (ref) (ref as React.RefObject<CountHandle | null>).current = handle;
  }, [handle, ref]);

  return (
    <motion.div
      data-slot="marquee-select-count"
      aria-hidden="true"
      style={{ x, y }}
      initial={false}
      // Presence is the discrete change here, so this is where the spring goes.
      // The digits inside deliberately do not crossfade; see the header.
      animate={{ opacity: visible ? 1 : 0, scale: visible ? 1 : 0.85 }}
      transition={visible ? spring.quick.enter : spring.quick.exit}
      className="pointer-events-none absolute left-0 top-0 z-30 origin-center"
    >
      {render ? (
        render(count)
      ) : (
        <span className="block rounded-full bg-foreground px-2 py-0.5 text-minor font-medium tabular-nums text-background shadow-card">
          {count}
        </span>
      )}
    </motion.div>
  );
}

export interface MarqueeSelectItemProps extends ComponentPropsWithoutRef<"div"> {
  /** Identity in the selection array. Required. */
  value: string;
  disabled?: boolean;
}

export function MarqueeSelectItem({
  value,
  disabled,
  className,
  onPointerDown,
  ...props
}: MarqueeSelectItemProps) {
  const context = useMarqueeContext("MarqueeSelectItem");
  const selected = context.selected.has(value);
  const isDisabled = disabled || context.disabled;

  return (
    <div
      data-slot="marquee-select-item"
      data-value={value}
      data-selected={selected ? "true" : undefined}
      data-disabled={disabled ? "" : undefined}
      role="option"
      aria-selected={selected}
      aria-disabled={isDisabled || undefined}
      tabIndex={context.focusValue === value ? 0 : -1}
      onPointerDown={(event) => {
        onPointerDown?.(event);
        if (event.defaultPrevented || disabled) return;
        context.onItemPointerDown(value, event);
      }}
      className={cn(
        "relative outline-none focus-visible:ring-2 focus-visible:ring-ring",
        isDisabled && "pointer-events-none opacity-50",
        className,
      )}
      {...props}
    />
  );
}
marquee-select.tsx

API Reference

MarqueeSelect
value / defaultValue[]

string[]

Controlled and uncontrolled selection, as an array because that is what goes into a request, a URL, and React state without conversion. Deliberately stale during a drag: selection is written straight to the DOM while the gesture runs and reconciled when it ends, so React is not re-rendering every item every frame.

onValueChange

(value: string[]) => void

Fires once, on release or on a keyboard change. This is the one to persist on.

onValueChanging

(value: string[]) => void

Fires every frame the covered set changes. Setting React state from the array hands back the per-frame render the imperative writes exist to avoid — use it to mirror a count somewhere else on the page.

intersection"touch"

"touch" | "enclose"

`touch` takes an item on any overlap, which is Finder and what people expect. `enclose` requires the item to be fully inside the rectangle, which is the CAD convention.

shouldStarttarget is not an item

(event: PointerEvent) => boolean

Whether a press may begin a marquee. The default is the seam that lets per-item dragging coexist with this: presses that land on an item fall through to whatever drag library is handling them, and presses on the background start a rectangle.

autoScroll{ band: 48, max: 1200 }

boolean | { band?: number; max?: number }

Edge band in pixels and top speed in px/s. Velocity is the square of the depth into the band, so the boundary has zero slope and cannot be felt; two pixels in moves the container 0.035px on the first frame where a linear ramp would move it 0.833px.

renderCount

((count: number) => ReactNode) | false

Replaces the count badge that rides beside the cursor, or removes it. The badge only appears past one item, sits on the diagonal the drag is heading so it never covers the corner you are watching, and springs around the cursor when you reverse.

disabledfalse

boolean

Stops the gesture, the click handling, and the keyboard path.

API Reference

MarqueeSelectItem
value

string

Required. Identity in the selection array, and what the component reads back off the DOM to build its ordered index — document order comes from the elements themselves, so it never drifts when the list is reordered.

disabledfalse

boolean

Skipped by the rectangle, by clicks, and by roving focus.

className

string

`data-selected="true"` is set during a drag and after it, so style the selected state from it rather than from your own copy of the selection. Keep it to colour: forty items crossing in half a second turns anything with travel in it into static.