Skip to content

Rubber-band selection that scrolls at the edges and honors Shift and ⌘.

Gestures & dragmotion@number-flow/react

01Preview

1file selected
Research14 items
Exports6 items
q3-forecast​.xlsx412 KB
board-deck-v4​.pdf8.2 MB
pricing-page​.fig21 MB
churn-by-cohort​.csv96 KB
launch-plan​.docx188 KB
hero-dark@​2x​.png1.4 MB
hero-light@​2x​.png1.3 MB
okrs-q3-final-final​.docx64 KB
invoice-0423​.pdf220 KB
invoice-0424​.pdf218 KB
headcount-plan​.xlsx305 KB
onboarding-flow​.fig34 MB
nps-responses​.csv1.1 MB
offsite-agenda​.docx42 KB
team-photo​.png4.8 MB
security-review​.pdf2.6 MB
roadmap-h2​.fig12 MB
vendor-costs​.xlsx150 KB
usage-export​.csv3.9 MB
brand-guidelines​.pdf18 MB
interview-notes​.docx77 KB
logo-mark​.png88 KB
Arrow keys move, Space selects, Shift with an arrow extends the selection, Control or Command A selects all, Escape clears.

02Install

Copy the source into your project. It becomes yours: no package to update, no wrapper between you and the markup. It needs:

npm install motion @number-flow/react

03Usage

import { DragSelect, DragSelectArea, DragSelectCount, DragSelectItem } from "@/components/ui/drag-select";

<DragSelect value={selected} onValueChange={setSelected} onOpenItem={openFile} className="h-96">
  <DragSelectCount noun={["file", "files"]} />
  <DragSelectArea label="Files in Q3 planning">
    {files.map((f) => (
      <DragSelectItem key={f.id} value={f.id} label={f.name}>
        <FileTile file={f} />
      </DragSelectItem>
    ))}
  </DragSelectArea>
</DragSelect>

04Source

"use client";
import NumberFlow from "@number-flow/react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { createContext, useContext, useEffect, useId, useRef, useState } from "react";
import { cn } from "@/lib/cn";
import { ease } from "@/lib/motion";
import { useControllableState } from "@/lib/use-controllable-state";

/* -------------------------------------------------------------------------------------------------
 * Context
 * -----------------------------------------------------------------------------------------------*/

type Ctx = {
  selected: string[];
  setSelected: (next: string[]) => void;
  focusId: string | null;
  setFocusId: (id: string | null) => void;
  disabled: boolean;
  reduce: boolean;
  onOpenItem?: (id: string) => void;
  areaRef: React.RefObject<HTMLDivElement | null>;
  anchorRef: React.RefObject<string | null>;
  total: number;
  first: string | null;
  setInfo: React.Dispatch<React.SetStateAction<{ total: number; first: string | null }>>;
};

const SelectCtx = createContext<Ctx | null>(null);
const useCtx = (part: string) => {
  const ctx = useContext(SelectCtx);
  if (!ctx) throw new Error(`${part} must be used inside DragSelect`);
  return ctx;
};

// A press only becomes a marquee after the pointer travels this far, so clicks stay clicks.
const THRESHOLD = 4;
// Within this distance of the top or bottom edge, the area scrolls itself while you drag.
const EDGE = 36;

const itemsIn = (area: HTMLElement | null) => Array.from(area?.querySelectorAll<HTMLElement>("[data-select-id]") ?? []);
const idOf = (el: Element) => (el as HTMLElement).dataset.selectId!;
const same = (a: string[], b: string[]) => a.length === b.length && a.every((v, i) => v === b[i]);

/* -------------------------------------------------------------------------------------------------
 * DragSelect
 * -----------------------------------------------------------------------------------------------*/

export type DragSelectProps = Omit<React.ComponentProps<"div">, "defaultValue" | "onChange"> & {
  /** Selected item values. Controlled. */
  value?: string[];
  defaultValue?: string[];
  onValueChange?: (value: string[]) => void;
  /** Double-click or Enter on an item. */
  onOpenItem?: (value: string) => void;
  disabled?: boolean;
};

/** Holds the selection. Put a DragSelectArea (and optionally a DragSelectCount) inside. */
export function DragSelect({ value, defaultValue, onValueChange, onOpenItem, disabled = false, className, children, ...rest }: DragSelectProps) {
  const [selected, setSelected] = useControllableState({ value, defaultValue: defaultValue ?? [], onChange: onValueChange });
  const [focusId, setFocusId] = useState<string | null>(null);
  const [{ total, first }, setInfo] = useState<{ total: number; first: string | null }>({ total: 0, first: null });
  const reduce = !!useReducedMotion();
  const areaRef = useRef<HTMLDivElement | null>(null);
  const anchorRef = useRef<string | null>(null);

  return (
    <SelectCtx.Provider
      value={{ selected, setSelected, focusId, setFocusId, disabled, reduce, onOpenItem, areaRef, anchorRef, total, first, setInfo }}
    >
      <div data-disabled={disabled ? "" : undefined} className={cn("flex min-h-0 flex-col", className)} {...rest}>
        {children}
      </div>
    </SelectCtx.Provider>
  );
}

/** The selection and helpers, for toolbars that act on it. */
export function useDragSelect() {
  const ctx = useCtx("useDragSelect");
  return {
    selected: ctx.selected,
    total: ctx.total,
    clear: () => ctx.setSelected([]),
    selectAll: () => ctx.setSelected(itemsIn(ctx.areaRef.current).map(idOf)),
  };
}

/* -------------------------------------------------------------------------------------------------
 * DragSelectArea
 * -----------------------------------------------------------------------------------------------*/

type Rect = { x: number; y: number; w: number; h: number };

export type DragSelectAreaProps = Omit<React.ComponentProps<"div">, "children"> & {
  /** Names the list for assistive tech, e.g. "Files in Q3 planning". */
  label: string;
  /** Classes for the inner grid that lays out the items. */
  gridClassName?: string;
  children: React.ReactNode;
};

/** The scrolling surface you drag across. Items go inside. */
export function DragSelectArea({ label, gridClassName, className, children, onPointerDown, onKeyDown, ...rest }: DragSelectAreaProps) {
  const ctx = useCtx("DragSelectArea");
  const { selected, setSelected, setFocusId, disabled, reduce, areaRef, anchorRef, setInfo } = ctx;
  const hintId = useId();
  const [box, setBox] = useState<Rect | null>(null);
  const [dragging, setDragging] = useState(false);

  const session = useRef<{
    pointerId: number;
    startX: number;
    startY: number;
    clientX: number;
    clientY: number;
    item: string | null;
    mode: "replace" | "add" | "toggle";
    base: string[];
    rects: { id: string; x: number; y: number; w: number; h: number }[];
    started: boolean;
    last: string[];
    frame?: number;
    cleanup: () => void;
  } | null>(null);

  // Keep the item count (for DragSelectCount) and the first item (the default tab stop) in step with the DOM.
  useEffect(() => {
    const area = areaRef.current;
    if (!area) return;
    const sync = () => {
      const els = itemsIn(area);
      const total = els.length;
      const first = els[0] ? idOf(els[0]) : null;
      setInfo((prev) => (prev.total === total && prev.first === first ? prev : { total, first }));
    };
    const mo = new MutationObserver(sync);
    mo.observe(area, { childList: true, subtree: true });
    const raf = requestAnimationFrame(sync);
    return () => {
      mo.disconnect();
      cancelAnimationFrame(raf);
    };
  }, [areaRef, setInfo]);

  useEffect(
    () => () => {
      const s = session.current;
      if (!s) return;
      if (s.frame) cancelAnimationFrame(s.frame);
      s.cleanup();
    },
    [],
  );

  // A point in the area's scrollable content, so the marquee stays pinned to the content as it scrolls.
  const toContent = (clientX: number, clientY: number) => {
    const area = areaRef.current!;
    const r = area.getBoundingClientRect();
    return { x: clientX - r.left - area.clientLeft + area.scrollLeft, y: clientY - r.top - area.clientTop + area.scrollTop };
  };

  const update = () => {
    const s = session.current;
    if (!s) return;
    const p = toContent(s.clientX, s.clientY);
    const rect = { x: Math.min(s.startX, p.x), y: Math.min(s.startY, p.y), w: Math.abs(p.x - s.startX), h: Math.abs(p.y - s.startY) };
    setBox(rect);
    const hits = s.rects
      .filter((r) => r.x < rect.x + rect.w && r.x + r.w > rect.x && r.y < rect.y + rect.h && r.y + r.h > rect.y)
      .map((r) => r.id);
    const order = s.rects.map((r) => r.id);
    const next =
      s.mode === "replace"
        ? hits
        : s.mode === "add"
          ? order.filter((id) => s.base.includes(id) || hits.includes(id))
          : order.filter((id) => s.base.includes(id) !== hits.includes(id));
    if (!same(next, s.last)) {
      s.last = next;
      setSelected(next);
    }
  };

  const autoScroll = () => {
    const s = session.current;
    const area = areaRef.current;
    if (!s?.started || !area) return;
    const r = area.getBoundingClientRect();
    const speed = (d: number) => Math.ceil((1 - Math.max(0, d) / EDGE) * 16);
    const max = area.scrollHeight - area.clientHeight;
    const dy =
      s.clientY < r.top + EDGE && area.scrollTop > 0 ? -speed(s.clientY - r.top) : s.clientY > r.bottom - EDGE && area.scrollTop < max ? speed(r.bottom - s.clientY) : 0;
    if (dy) {
      area.scrollTop += dy;
      update();
    }
    s.frame = requestAnimationFrame(autoScroll);
  };

  const finish = (cancel: boolean) => {
    const s = session.current;
    if (!s) return;
    if (s.frame) cancelAnimationFrame(s.frame);
    s.cleanup();
    session.current = null;
    setBox(null);
    setDragging(false);
    if (cancel) setSelected(s.base);
  };

  const clickItem = (id: string, e: { shiftKey: boolean; metaKey: boolean; ctrlKey: boolean }, touch: boolean) => {
    const order = itemsIn(areaRef.current).map(idOf);
    const toggle = e.metaKey || e.ctrlKey || touch;
    if (e.shiftKey && anchorRef.current && order.includes(anchorRef.current)) {
      const [a, b] = [order.indexOf(anchorRef.current), order.indexOf(id)].sort((x, y) => x - y);
      const range = order.slice(a, b + 1);
      setSelected(toggle ? order.filter((v) => selected.includes(v) || range.includes(v)) : range);
    } else if (toggle) {
      setSelected(selected.includes(id) ? selected.filter((v) => v !== id) : order.filter((v) => v === id || selected.includes(v)));
      anchorRef.current = id;
    } else {
      setSelected([id]);
      anchorRef.current = id;
    }
    setFocusId(id);
    itemsIn(areaRef.current)
      .find((el) => idOf(el) === id)
      ?.focus({ preventScroll: true });
  };

  return (
    <div
      ref={areaRef}
      data-dragging={dragging ? "" : undefined}
      data-has-selection={selected.length ? "" : undefined}
      onPointerDown={(e) => {
        onPointerDown?.(e);
        if (e.defaultPrevented || disabled) return;
        const touch = e.pointerType === "touch";
        if (!touch && e.button !== 0) return;
        // Leave the scrollbar alone.
        const area = e.currentTarget;
        const r = area.getBoundingClientRect();
        if (e.clientX > r.left + area.clientLeft + area.clientWidth) return;
        const itemEl = (e.target as Element).closest<HTMLElement>("[data-select-id]");
        const item = itemEl ? idOf(itemEl) : null;
        const start = toContent(e.clientX, e.clientY);
        const mode = e.shiftKey ? "add" : e.metaKey || e.ctrlKey ? "toggle" : "replace";
        if (!touch) e.preventDefault();

        const onMove = (ev: PointerEvent) => {
          const s = session.current;
          if (!s || ev.pointerId !== s.pointerId) return;
          s.clientX = ev.clientX;
          s.clientY = ev.clientY;
          if (!s.started) {
            // A finger dragging over the grid is a scroll; marquee is for mouse and pen.
            if (touch) return;
            const p = toContent(ev.clientX, ev.clientY);
            if (Math.hypot(p.x - s.startX, p.y - s.startY) < THRESHOLD) return;
            s.started = true;
            setDragging(true);
            s.frame = requestAnimationFrame(autoScroll);
          }
          update();
        };
        const onUp = (ev: PointerEvent) => {
          const s = session.current;
          if (!s || ev.pointerId !== s.pointerId) return;
          const wasClick = !s.started;
          finish(false);
          if (!wasClick) return;
          if (s.item) clickItem(s.item, ev, touch);
          else if (!touch && s.mode === "replace") setSelected([]);
        };
        const onCancel = (ev: PointerEvent) => {
          if (session.current?.pointerId === ev.pointerId) finish(true);
        };
        const onKey = (ev: KeyboardEvent) => {
          if (ev.key !== "Escape" || !session.current?.started) return;
          ev.preventDefault();
          ev.stopPropagation();
          finish(true);
        };
        window.addEventListener("pointermove", onMove);
        window.addEventListener("pointerup", onUp);
        window.addEventListener("pointercancel", onCancel);
        window.addEventListener("keydown", onKey, true);

        // Item boxes in content coordinates, read once: nothing moves while you drag.
        const rects = itemsIn(area).map((el) => {
          const b = el.getBoundingClientRect();
          const p = toContent(b.left, b.top);
          return { id: idOf(el), x: p.x, y: p.y, w: b.width, h: b.height };
        });
        session.current = {
          pointerId: e.pointerId,
          startX: start.x,
          startY: start.y,
          clientX: e.clientX,
          clientY: e.clientY,
          item,
          mode,
          base: selected,
          rects,
          started: false,
          last: selected,
          cleanup: () => {
            window.removeEventListener("pointermove", onMove);
            window.removeEventListener("pointerup", onUp);
            window.removeEventListener("pointercancel", onCancel);
            window.removeEventListener("keydown", onKey, true);
          },
        };
      }}
      onKeyDown={(e) => {
        onKeyDown?.(e);
        if (e.defaultPrevented || disabled) return;
        handleKey(e, ctx);
      }}
      className={cn(
        "relative min-h-0 flex-1 select-none overflow-y-auto overscroll-contain [scrollbar-gutter:stable] [-webkit-touch-callout:none]",
        "data-dragging:cursor-crosshair",
        className,
      )}
      {...rest}
    >
      <div
        role="listbox"
        aria-multiselectable
        aria-label={label}
        aria-describedby={hintId}
        aria-disabled={disabled || undefined}
        className={cn("grid grid-cols-[repeat(auto-fill,minmax(96px,1fr))] gap-1 p-2", gridClassName)}
      >
        {children}
      </div>
      <AnimatePresence>
        {box && (box.w > 0 || box.h > 0) && (
          <motion.div
            aria-hidden
            className="pointer-events-none absolute left-0 top-0 z-10 rounded-[3px] border border-fg-3/60 bg-fg/[0.06]"
            initial={{ opacity: 0 }}
            animate={{ opacity: 1 }}
            exit={{ opacity: 0, transition: { duration: reduce ? 0 : 0.12, ease: ease.out } }}
            transition={{ duration: reduce ? 0 : 0.08 }}
            style={{ x: box.x, y: box.y, width: box.w, height: box.h }}
          />
        )}
      </AnimatePresence>
      <span id={hintId} className="sr-only">
        Arrow keys move, Space selects, Shift with an arrow extends the selection, Control or Command A selects all, Escape clears.
      </span>
    </div>
  );
}

// The listbox keyboard model, in a grid: arrows move in two dimensions and Shift extends from the anchorRef.
function handleKey(e: React.KeyboardEvent, ctx: Ctx) {
  const { selected, setSelected, focusId, setFocusId, anchorRef, onOpenItem, areaRef } = ctx;
  const els = itemsIn(areaRef.current);
  if (!els.length) return;
  const order = els.map(idOf);
  const current = focusId && order.includes(focusId) ? order.indexOf(focusId) : 0;
  const top = els[0].offsetTop;
  const cols = Math.max(1, els.filter((el) => Math.abs(el.offsetTop - top) < 2).length);
  const focus = (i: number) => {
    const id = order[i];
    setFocusId(id);
    els[i].focus({ preventScroll: true });
    els[i].scrollIntoView({ block: "nearest" });
    return id;
  };

  if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "a") {
    e.preventDefault();
    setSelected(order);
    return;
  }
  const delta =
    e.key === "ArrowRight" ? 1 : e.key === "ArrowLeft" ? -1 : e.key === "ArrowDown" ? cols : e.key === "ArrowUp" ? -cols : 0;
  const jump = e.key === "Home" ? 0 : e.key === "End" ? order.length - 1 : null;
  if (delta || jump !== null) {
    e.preventDefault();
    const to = jump ?? Math.max(0, Math.min(order.length - 1, current + delta));
    const id = focus(to);
    if (e.shiftKey) {
      const from = anchorRef.current && order.includes(anchorRef.current) ? order.indexOf(anchorRef.current) : current;
      if (!anchorRef.current) anchorRef.current = order[current];
      const [a, b] = [from, to].sort((x, y) => x - y);
      setSelected(order.slice(a, b + 1));
    } else {
      anchorRef.current = id;
    }
  } else if (e.key === " ") {
    e.preventDefault();
    const id = order[current];
    anchorRef.current = id;
    setSelected(selected.includes(id) ? selected.filter((v) => v !== id) : order.filter((v) => v === id || selected.includes(v)));
  } else if (e.key === "Enter" && onOpenItem) {
    e.preventDefault();
    onOpenItem(order[current]);
  } else if (e.key === "Escape" && selected.length) {
    e.preventDefault();
    e.stopPropagation();
    setSelected([]);
  }
}

/* -------------------------------------------------------------------------------------------------
 * DragSelectItem
 * -----------------------------------------------------------------------------------------------*/

export type DragSelectItemProps = Omit<React.ComponentProps<"div">, "children"> & {
  /** Unique within the area. */
  value: string;
  /** Accessible name, e.g. the file name. */
  label: string;
  children: React.ReactNode;
};

export function DragSelectItem({ value, label, className, children, onDoubleClick, ...rest }: DragSelectItemProps) {
  const ctx = useCtx("DragSelectItem");
  const checked = ctx.selected.includes(value);
  // Before anything has focus, the first item holds the tab stop.
  const tabbable = ctx.focusId ? ctx.focusId === value : ctx.first === value;

  return (
    <div
      role="option"
      aria-selected={checked}
      aria-label={label}
      tabIndex={tabbable ? 0 : -1}
      data-select-id={value}
      data-selected={checked ? "" : undefined}
      onFocus={() => ctx.setFocusId(value)}
      onDoubleClick={(e) => {
        onDoubleClick?.(e);
        ctx.onOpenItem?.(value);
      }}
      className={cn(
        "group/item relative min-w-0 rounded-lg p-2 outline-none",
        "transition-[background-color,box-shadow] duration-150 ease-out-quart",
        "hover:bg-fg/[0.04] in-data-dragging:hover:bg-transparent",
        "data-selected:bg-fg/[0.07] data-selected:shadow-[inset_0_0_0_1px_var(--line-2)] data-selected:hover:bg-fg/[0.08]",
        "focus-visible:outline-solid focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-fg-3",
        className,
      )}
      {...rest}
    >
      <Check checked={checked} reduce={ctx.reduce} />
      {children}
    </div>
  );
}

// The corner check: present on hover, focus and whenever anything is selected; always there on touch.
function Check({ checked, reduce }: { checked: boolean; reduce: boolean }) {
  return (
    <span
      aria-hidden
      className={cn(
        "absolute left-1.5 top-1.5 z-1 grid size-4 place-items-center rounded-full border transition-[opacity,background-color,border-color,scale] duration-150",
        checked
          ? "scale-100 border-fg bg-fg text-frame opacity-100"
          : "border-fg-4 bg-raised text-transparent opacity-0 group-hover/item:opacity-100 group-focus-visible/item:opacity-100 pointer-coarse:opacity-100 in-[[data-has-selection]]:opacity-100",
      )}
    >
      <svg width="10" height="10" viewBox="0 0 10 10" fill="none">
        <motion.path
          d="M2.25 5.25 4.25 7.25 7.75 3"
          stroke="currentColor"
          strokeWidth={1.5}
          strokeLinecap="round"
          strokeLinejoin="round"
          initial={false}
          animate={{ pathLength: checked ? 1 : 0 }}
          transition={{ duration: reduce ? 0 : checked ? 0.22 : 0.08, ease: ease.out }}
        />
      </svg>
    </span>
  );
}

/* -------------------------------------------------------------------------------------------------
 * DragSelectCount
 * -----------------------------------------------------------------------------------------------*/

export type DragSelectCountProps = Omit<React.ComponentProps<"span">, "children"> & {
  /** What's being counted, singular and plural. */
  noun?: [string, string];
};

/** "3 files selected", rolling as the marquee grows. Falls back to the total when nothing is selected. */
export function DragSelectCount({ noun = ["item", "items"], className, ...rest }: DragSelectCountProps) {
  const ctx = useCtx("DragSelectCount");
  const n = ctx.selected.length;
  const shown = n || ctx.total;
  return (
    <span role="status" aria-live="polite" className={cn("inline-flex items-baseline gap-1 text-[12.5px] tabular", className)} {...rest}>
      <NumberFlow value={shown} animated={!ctx.reduce} className={n ? "font-medium text-fg" : "text-fg-2"} />
      <span className={n ? "text-fg-2" : "text-fg-3"}>
        {shown === 1 ? noun[0] : noun[1]}
        {n ? " selected" : ""}
      </span>
    </span>
  );
}

05Props

DragSelect

PropTypeDefaultDescription
valuestring[]Selected item values. Controlled.
defaultValuestring[][]Starting selection when uncontrolled.
onValueChange(value: string[]) => voidCalled whenever the selection changes, live while the marquee grows.
onOpenItem(value: string) => voidDouble-click or Enter on an item.
disabledbooleanfalseNo selecting by pointer or keyboard.

DragSelectArea

PropTypeDefaultDescription
label*stringNames the listbox, e.g. "Files in Q3 planning".
gridClassNamestring"grid-cols-[repeat(auto-fill,minmax(96px,1fr))]"Classes for the grid inside the scroll area. Any layout works; keyboard columns are read from it.

DragSelectItem

PropTypeDefaultDescription
value*stringUnique within the area.
label*stringThe option's accessible name.

DragSelectCount

PropTypeDefaultDescription
noun[singular: string, plural: string]["item", "items"]"3 files selected", or the total ("24 files") when nothing is.

useDragSelect

PropTypeDefaultDescription
(returns){ selected: string[]; total: number; clear(): void; selectAll(): void }For toolbars that act on the selection. Call inside DragSelect.

06Notes

Behavior

  • A press becomes a marquee after 4px, from empty space or on top of items, so a click still selects one item. Click empty space to clear; Shift-click selects a range from the last item you chose; ⌘ or Ctrl-click toggles one.
  • Holding Shift when the drag starts adds to the selection; ⌘ or Ctrl toggles everything the marquee touches. Escape mid-drag restores the selection from before it began.
  • Near the top or bottom edge the area scrolls itself, faster the closer you get, and the marquee stays pinned to the content so it keeps growing.
  • Fingers scroll rather than draw a marquee: on touch a tap toggles an item, and the corner checks are always shown.

Motion

  • The marquee fades in over 80ms and out over 120ms; its box tracks the pointer directly, with no easing, because lag reads as broken.
  • Items change state as the marquee crosses them, and each corner check draws its tick in 220ms on the expo ease-out. The count rolls rather than jumps.
  • Reduced motion keeps the marquee and the live selection but drops the fades, the tick drawing and the rolling digits.

Accessibility

  • A multi-select listbox of options with aria-selected and a single tab stop. Arrow keys move in two dimensions, Home and End jump to the ends.
  • Space toggles, Shift with an arrow extends the range from the anchor, ⌘ or Ctrl+A selects all, Escape clears, Enter opens.
  • DragSelectCount is a polite status, so the new count is read out after a drag or a key press.