Data

Selection area

Drag a marquee over items to select several at once, the way a file manager does.

Built on
Pointer captureARIA listbox
Client JavaScript
Yes, for state
Works without React
React only (needs JavaScript)
Source, gzipped
4.5 kB
Dependencies
React
brief.pdf
logo.svg
q3.xlsx
intro.mp4
notes.md
hero.png
deck.key
data.csv
Code
import { SelectionArea, SelectionItem } from "@/components/equip/selection-area";

const files = ["brief.pdf", "logo.svg", "q3.xlsx", "intro.mp4", "notes.md", "hero.png", "deck.key", "data.csv"];

export default function Demo() {
  return (
    <SelectionArea
      label="Files"
      defaultSelected={["logo.svg", "hero.png"]}
      style={{ display: "grid", gridTemplateColumns: "repeat(4, 44px)", gap: 10, padding: 8 }}
    >
      {files.map((name) => (
        <SelectionItem
          key={name}
          id={name}
          style={{ display: "grid", justifyItems: "center", gap: 2, paddingBlock: 6, fontSize: "var(--eq-text-xs)" }}
        >
          <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
            <path d="M6 3h8l4 4v14H6zM14 3v4h4" />
          </svg>
          <span style={{ maxWidth: 40, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{name}</span>
        </SelectionItem>
      ))}
    </SelectionArea>
  );
}

Examples

Bulk actions

Controlled: the toolbar reads the same selection the marquee writes, and Escape clears it.

Drag across photos to select
Harbour at dawn
Market stalls
Ferry deck
Old lighthouse
Fish auction
Sea wall
Night ferry
Rope store
Dry dock
Code
"use client";

import { useState } from "react";
import { Button } from "@/components/equip/button";
import { SelectionArea, SelectionItem } from "@/components/equip/selection-area";

const photos = ["Harbour at dawn", "Market stalls", "Ferry deck", "Old lighthouse", "Fish auction", "Sea wall", "Night ferry", "Rope store", "Dry dock"];

export default function Example() {
  const [selected, setSelected] = useState<string[]>([]);
  return (
    <div style={{ display: "grid", gap: 12 }}>
      <div style={{ display: "flex", alignItems: "center", gap: 8, minHeight: 34 }}>
        <span style={{ flex: 1, color: "var(--eq-ink-2)", fontSize: "var(--eq-text-s)" }}>
          {selected.length === 0 ? "Drag across photos to select" : `${selected.length} of ${photos.length} selected`}
        </span>
        <Button variant="outline" size="s" disabled={selected.length === 0} onClick={() => setSelected([])}>
          Clear
        </Button>
        <Button variant="danger" size="s" disabled={selected.length === 0}>
          Move to trash
        </Button>
      </div>
      <SelectionArea
        label="Album photos"
        selected={selected}
        onSelectedChange={setSelected}
        style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(96px, 1fr))", gap: 16, padding: 16, border: "1px solid var(--eq-line)", borderRadius: "var(--eq-radius-l)" }}
      >
        {photos.map((name) => (
          <SelectionItem key={name} id={name} style={{ display: "grid", gap: 6, padding: 6, fontSize: "var(--eq-text-xs)" }}>
            <span style={{ height: 56, borderRadius: "var(--eq-radius-s)", background: "var(--eq-tint-strong)" }} />
            {name}
          </SelectionItem>
        ))}
      </SelectionArea>
    </div>
  );
}

Scrolling list

Drag past the top or bottom edge and the list scrolls on its own, selecting rows as they pass, except the locked main branch.

main
release/2.4
fix/login-redirect
feat/billing-export
feat/sso-okta
chore/deps-november
fix/timezone-drift
feat/audit-log
spike/edge-cache
fix/csv-quotes
feat/dark-charts
chore/ci-cache
Code
import { SelectionArea, SelectionItem } from "@/components/equip/selection-area";

const branches = [
  "main",
  "release/2.4",
  "fix/login-redirect",
  "feat/billing-export",
  "feat/sso-okta",
  "chore/deps-november",
  "fix/timezone-drift",
  "feat/audit-log",
  "spike/edge-cache",
  "fix/csv-quotes",
  "feat/dark-charts",
  "chore/ci-cache",
];

export default function Example() {
  return (
    <SelectionArea
      label="Branches to delete"
      style={{ display: "grid", justifyItems: "start", gap: 2, height: 200, padding: 4, border: "1px solid var(--eq-line)", borderRadius: "var(--eq-radius-l)" }}
    >
      {branches.map((name) => (
        <SelectionItem
          key={name}
          id={name}
          disabled={name === "main"}
          style={{ display: "flex", alignItems: "center", height: "var(--eq-h-s)", paddingInline: 8, fontFamily: "var(--eq-font-mono)", fontSize: "var(--eq-text-s)" }}
        >
          {name}
        </SelectionItem>
      ))}
    </SelectionArea>
  );
}

Props

PropTypeDefaultWhat it does
labelstringnoneNames the set of items for assistive tech.
selectedstring[]noneSelected item ids, when you control them.
defaultSelectedstring[][]Selected item ids on first render, when you do not.
onSelectedChange(selected: string[]) => voidnoneCalled whenever the set changes, live during a drag.
...restComponentProps<"div">noneEvery native div attribute. Lay the items out here with style or className.
SelectionItem idstringnoneIdentifies the item in the selection. It is not used as the DOM id.
SelectionItem disabledbooleanfalseKeeps the item focusable but out of every selection.

Anatomy

root.eq-selection-areaThe listbox and its own scroll container.
item.eq-selection-area-itemOne option; rendered by SelectionItem.
tick.eq-selection-area-tickDraws in when the item is selected.
marquee.eq-selection-area-marqueeThe rectangle drawn while dragging.

Keyboard

Arrow keysMoves focus to the nearest item in that direction on screen.
Shift+Arrow keysMoves focus and adds both items to the selection.
HomeEndMoves focus to the first or last item.
SpaceToggles the focused item.
Ctrl+ACmd+ASelects every item that is not disabled.
EscapeClears the selection.

Notes

  • A drag from empty space replaces the selection, with Shift it adds, and with Ctrl or Cmd it toggles what it touches.
  • A click selects one item, Ctrl or Cmd click toggles it, and Shift click selects the range from the last clicked item in reading order.
  • Clicking empty space clears the selection; a drag never does, even though the browser fires a click after it.
  • The marquee is for mouse and pen only, so touch keeps scrolling the area; on touch, tap items to select them.
  • Arrow keys follow the layout on screen rather than source order, so grids, lists and wrapped rows all move as they look.
  • Items are options in a multi-select listbox, so put only plain content inside them, not buttons or links.

Source

Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.

components/equip/selection-area.tsx389 lines
"use client";

import {
  createContext,
  useContext,
  useEffect,
  useRef,
  useState,
  type ComponentProps,
  type KeyboardEvent,
  type MouseEvent,
  type PointerEvent,
} from "react";
import "./selection-area.css";

export type SelectionAreaProps = ComponentProps<"div"> & {
  /** Names the set of items for assistive tech. */
  label: string;
  /** Selected item ids, when you control them. */
  selected?: string[];
  /** Selected item ids on first render, when you do not. */
  defaultSelected?: string[];
  onSelectedChange?: (selected: string[]) => void;
};

export type SelectionItemProps = Omit<ComponentProps<"div">, "id"> & {
  /** Identifies the item in the selection. Not used as the DOM id. */
  id: string;
  disabled?: boolean;
};

type Area = {
  selected: ReadonlySet<string>;
  focusId: string | null;
  pick: (id: string, event: MouseEvent) => void;
  focus: (id: string) => void;
};

const AreaContext = createContext<Area | null>(null);

/* Distance a press must travel before it becomes a marquee. */
const THRESHOLD = 4;
/* Band along each edge where a drag scrolls the area, and the fastest it goes per frame. */
const EDGE = 28;
const SPEED = 14;

type Box = { x: number; y: number; w: number; h: number };
type Drag = {
  pointerId: number;
  x: number;
  y: number;
  clientX: number;
  clientY: number;
  base: string[];
  mode: "replace" | "add" | "toggle";
  moved: boolean;
  frame: number;
};

const same = (a: readonly string[], b: readonly string[]) =>
  a.length === b.length && a.every((id) => b.includes(id));

export function SelectionArea({
  label,
  selected: controlled,
  defaultSelected = [],
  onSelectedChange,
  className,
  children,
  onPointerDown,
  onPointerMove,
  onPointerUp,
  onPointerCancel,
  onKeyDown,
  ...rest
}: SelectionAreaProps) {
  const [uncontrolled, setUncontrolled] = useState(defaultSelected);
  const selected = controlled ?? uncontrolled;
  const [focusId, setFocusId] = useState<string | null>(null);
  const [marquee, setMarquee] = useState<Box | null>(null);
  const rootRef = useRef<HTMLDivElement>(null);
  const drag = useRef<Drag | null>(null);
  const anchor = useRef<string | null>(null);
  const latest = useRef(selected);
  latest.current = selected;

  useEffect(() => () => cancelAnimationFrame(drag.current?.frame ?? 0), []);

  /* Keep exactly one item in the tab order: the focused one, else the first selected, else the first. */
  useEffect(() => {
    const els = items();
    if (focusId !== null && els.some((el) => el.dataset.id === focusId)) return;
    const first = els.find((el) => selected.includes(el.dataset.id ?? "")) ?? els[0];
    setFocusId(first?.dataset.id ?? null);
  });

  function items() {
    const root = rootRef.current;
    if (!root) return [];
    return [...root.querySelectorAll<HTMLElement>(".eq-selection-area-item")].filter(
      (el) => el.closest(".eq-selection-area") === root,
    );
  }

  const enabledIds = () =>
    items()
      .filter((el) => el.getAttribute("aria-disabled") !== "true")
      .map((el) => el.dataset.id ?? "");

  function commit(next: string[]) {
    if (same(next, latest.current)) return;
    latest.current = next;
    if (controlled === undefined) setUncontrolled(next);
    onSelectedChange?.(next);
  }

  function moveFocus(el: HTMLElement | undefined) {
    if (!el?.dataset.id) return;
    setFocusId(el.dataset.id);
    el.focus();
    el.scrollIntoView({ block: "nearest", inline: "nearest" });
  }

  function pick(id: string, event: MouseEvent) {
    const current = latest.current;
    if (event.metaKey || event.ctrlKey) {
      commit(current.includes(id) ? current.filter((x) => x !== id) : [...current, id]);
      anchor.current = id;
    } else if (event.shiftKey && anchor.current !== null) {
      const ids = enabledIds();
      const a = ids.indexOf(anchor.current);
      const b = ids.indexOf(id);
      if (a < 0 || b < 0) commit([id]);
      else commit(ids.slice(Math.min(a, b), Math.max(a, b) + 1));
    } else {
      commit([id]);
      anchor.current = id;
    }
    setFocusId(id);
  }

  /** Pointer position in the area's scrolled content, kept inside it. */
  function toContent(root: HTMLElement, clientX: number, clientY: number) {
    const box = root.getBoundingClientRect();
    const x = clientX - box.left - root.clientLeft + root.scrollLeft;
    const y = clientY - box.top - root.clientTop + root.scrollTop;
    return {
      x: Math.min(Math.max(x, 0), root.scrollWidth),
      y: Math.min(Math.max(y, 0), root.scrollHeight),
    };
  }

  function sweep() {
    const root = rootRef.current;
    const d = drag.current;
    if (!root || !d) return;
    const p = toContent(root, d.clientX, d.clientY);
    const area = {
      x: Math.min(d.x, p.x),
      y: Math.min(d.y, p.y),
      w: Math.abs(p.x - d.x),
      h: Math.abs(p.y - d.y),
    };
    setMarquee(area);
    const box = root.getBoundingClientRect();
    const hits: string[] = [];
    for (const el of items()) {
      if (el.getAttribute("aria-disabled") === "true") continue;
      const r = el.getBoundingClientRect();
      const left = r.left - box.left - root.clientLeft + root.scrollLeft;
      const top = r.top - box.top - root.clientTop + root.scrollTop;
      if (left < area.x + area.w && left + r.width > area.x && top < area.y + area.h && top + r.height > area.y) {
        hits.push(el.dataset.id ?? "");
      }
    }
    if (d.mode === "add") commit([...d.base, ...hits.filter((id) => !d.base.includes(id))]);
    else if (d.mode === "toggle")
      commit([...d.base.filter((id) => !hits.includes(id)), ...hits.filter((id) => !d.base.includes(id))]);
    else commit(hits);
  }

  function tick() {
    const root = rootRef.current;
    const d = drag.current;
    if (!root || !d) return;
    if (d.moved) {
      const box = root.getBoundingClientRect();
      const push = (pos: number, start: number, end: number) =>
        pos < start + EDGE ? -Math.ceil(((start + EDGE - pos) / EDGE) * SPEED) : pos > end - EDGE ? Math.ceil(((pos - end + EDGE) / EDGE) * SPEED) : 0;
      const dx = push(d.clientX, box.left, box.right);
      const dy = push(d.clientY, box.top, box.bottom);
      const before = root.scrollLeft + root.scrollTop;
      if (dx || dy) root.scrollBy(dx, dy);
      if (root.scrollLeft + root.scrollTop !== before) sweep();
    }
    d.frame = requestAnimationFrame(tick);
  }

  function end() {
    const d = drag.current;
    if (!d) return;
    cancelAnimationFrame(d.frame);
    drag.current = null;
    setMarquee(null);
  }

  function handlePointerDown(event: PointerEvent<HTMLDivElement>) {
    onPointerDown?.(event);
    const root = event.currentTarget;
    if (event.defaultPrevented || event.button !== 0 || event.pointerType === "touch") return;
    if (event.target instanceof Element && event.target.closest(".eq-selection-area-item")) return;
    const box = root.getBoundingClientRect();
    /* A press on a scroll bar is not a marquee. */
    if (event.clientX - box.left - root.clientLeft >= root.clientWidth) return;
    if (event.clientY - box.top - root.clientTop >= root.clientHeight) return;
    event.preventDefault();
    root.setPointerCapture(event.pointerId);
    const start = toContent(root, event.clientX, event.clientY);
    const additive = event.shiftKey;
    const toggle = event.metaKey || event.ctrlKey;
    drag.current = {
      pointerId: event.pointerId,
      ...start,
      clientX: event.clientX,
      clientY: event.clientY,
      base: additive || toggle ? latest.current : [],
      mode: toggle ? "toggle" : additive ? "add" : "replace",
      moved: false,
      frame: 0,
    };
    drag.current.frame = requestAnimationFrame(tick);
  }

  function handlePointerMove(event: PointerEvent<HTMLDivElement>) {
    onPointerMove?.(event);
    const d = drag.current;
    if (!d || d.pointerId !== event.pointerId) return;
    d.clientX = event.clientX;
    d.clientY = event.clientY;
    if (!d.moved) {
      const root = event.currentTarget;
      const p = toContent(root, event.clientX, event.clientY);
      if (Math.hypot(p.x - d.x, p.y - d.y) < THRESHOLD) return;
      d.moved = true;
    }
    sweep();
  }

  function handlePointerUp(event: PointerEvent<HTMLDivElement>) {
    onPointerUp?.(event);
    const d = drag.current;
    if (!d || d.pointerId !== event.pointerId) return;
    /* A plain click on empty space clears; a drag has already set the selection. */
    if (!d.moved && d.mode === "replace") commit([]);
    end();
  }

  function handlePointerCancel(event: PointerEvent<HTMLDivElement>) {
    onPointerCancel?.(event);
    end();
  }

  function handleKeyDown(event: KeyboardEvent<HTMLDivElement>) {
    onKeyDown?.(event);
    if (event.defaultPrevented) return;
    const els = items();
    const current = event.target instanceof Element ? event.target.closest<HTMLElement>(".eq-selection-area-item") : null;
    const id = current?.dataset.id;
    const mod = event.metaKey || event.ctrlKey;
    const { key } = event;
    let handled = true;

    if (key.startsWith("Arrow") && current && id !== undefined) {
      const next = nearest(current, els, key);
      if (next) {
        if (event.shiftKey) {
          const add = [id, next.dataset.id ?? ""].filter(
            (x) => !latest.current.includes(x) && els.find((el) => el.dataset.id === x)?.getAttribute("aria-disabled") !== "true",
          );
          if (add.length) commit([...latest.current, ...add]);
        }
        moveFocus(next);
      }
    } else if (key === "Home") moveFocus(els[0]);
    else if (key === "End") moveFocus(els[els.length - 1]);
    else if (key === " " && current && id !== undefined) {
      if (current.getAttribute("aria-disabled") !== "true") {
        commit(latest.current.includes(id) ? latest.current.filter((x) => x !== id) : [...latest.current, id]);
        anchor.current = id;
      }
    } else if (mod && key.toLowerCase() === "a") commit(enabledIds());
    else if (key === "Escape" && latest.current.length > 0) commit([]);
    else handled = false;

    if (handled) {
      event.preventDefault();
      event.stopPropagation();
    }
  }

  const context: Area = {
    selected: new Set(selected),
    focusId,
    pick,
    focus: setFocusId,
  };

  return (
    <div
      role="listbox"
      aria-multiselectable="true"
      aria-label={label}
      {...rest}
      ref={rootRef}
      className={["eq-selection-area", className].filter(Boolean).join(" ")}
      data-dragging={marquee ? "" : undefined}
      onPointerDown={handlePointerDown}
      onPointerMove={handlePointerMove}
      onPointerUp={handlePointerUp}
      onPointerCancel={handlePointerCancel}
      onLostPointerCapture={end}
      onKeyDown={handleKeyDown}
    >
      <AreaContext.Provider value={context}>{children}</AreaContext.Provider>
      {marquee && (
        <div
          className="eq-selection-area-marquee"
          aria-hidden="true"
          style={{ left: marquee.x, top: marquee.y, width: marquee.w, height: marquee.h }}
        />
      )}
    </div>
  );
}

/** The closest item whose centre lies in the arrow's direction, favouring the same row or column. */
function nearest(from: HTMLElement, els: HTMLElement[], key: string) {
  const a = from.getBoundingClientRect();
  const ax = a.left + a.width / 2;
  const ay = a.top + a.height / 2;
  let best: HTMLElement | undefined;
  let bestScore = Infinity;
  for (const el of els) {
    if (el === from) continue;
    const b = el.getBoundingClientRect();
    const dx = b.left + b.width / 2 - ax;
    const dy = b.top + b.height / 2 - ay;
    const main = key === "ArrowRight" ? dx : key === "ArrowLeft" ? -dx : key === "ArrowDown" ? dy : -dy;
    const cross = key === "ArrowRight" || key === "ArrowLeft" ? Math.abs(dy) : Math.abs(dx);
    if (main <= 1) continue;
    const score = main + cross * 2;
    if (score < bestScore) {
      bestScore = score;
      best = el;
    }
  }
  return best;
}

export function SelectionItem({ id, disabled = false, className, children, onClick, onFocus, ...rest }: SelectionItemProps) {
  const area = useContext(AreaContext);
  const isSelected = area?.selected.has(id) ?? false;
  return (
    <div
      role="option"
      {...rest}
      className={["eq-selection-area-item", className].filter(Boolean).join(" ")}
      data-id={id}
      aria-selected={isSelected}
      aria-disabled={disabled || undefined}
      tabIndex={area?.focusId === id ? 0 : -1}
      onClick={(event) => {
        onClick?.(event);
        if (!event.defaultPrevented && !disabled) area?.pick(id, event);
      }}
      onFocus={(event) => {
        onFocus?.(event);
        area?.focus(id);
      }}
    >
      {children}
      <span className="eq-selection-area-tick" aria-hidden="true">
        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
          <path d="M4.5 8.2l2.3 2.3 4.7-4.9" pathLength={1} />
        </svg>
      </span>
    </div>
  );
}
components/equip/selection-area.css96 lines
@layer equip {
  .eq-selection-area {
    position: relative;
    overflow: auto;
    user-select: none;
    -webkit-user-select: none;
  }

  .eq-selection-area[data-dragging] {
    cursor: crosshair;
  }

  .eq-selection-area-item {
    position: relative;
    border-radius: var(--eq-radius-m);
    color: var(--eq-ink);
    cursor: default;
    transition:
      background-color var(--eq-dur) var(--eq-ease),
      scale var(--eq-dur) var(--eq-ease);
  }

  @media (hover: hover) {
    .eq-selection-area-item:hover:not([aria-selected="true"], [aria-disabled="true"]) {
      background: var(--eq-tint);
    }
  }

  .eq-selection-area-item:active:not([aria-disabled="true"]) {
    scale: var(--eq-press);
  }

  .eq-selection-area-item:focus-visible {
    outline: 2px solid var(--eq-focus);
    outline-offset: 2px;
  }

  .eq-selection-area-item[aria-selected="true"] {
    background: var(--eq-tint-strong);
  }

  .eq-selection-area-item[aria-disabled="true"] {
    opacity: 0.45;
    cursor: not-allowed;
  }

  .eq-selection-area-tick {
    position: absolute;
    top: var(--eq-space-1);
    right: var(--eq-space-1);
    display: grid;
    place-items: center;
    width: 16px;
    height: 16px;
    border-radius: var(--eq-radius-full);
    background: var(--eq-solid);
    color: var(--eq-solid-ink);
    pointer-events: none;
    opacity: 0;
    scale: 0.5;
    transition:
      opacity var(--eq-dur) var(--eq-ease),
      scale var(--eq-dur) var(--eq-ease);
  }

  .eq-selection-area-tick > svg {
    width: 12px;
    height: 12px;
    stroke-dasharray: 1;
    stroke-dashoffset: 1;
    transition: stroke-dashoffset var(--eq-dur) var(--eq-ease);
  }

  .eq-selection-area-item[aria-selected="true"] .eq-selection-area-tick {
    opacity: 1;
    scale: none;
    transition:
      opacity var(--eq-dur) var(--eq-ease),
      scale var(--eq-dur-move) var(--eq-spring);
  }

  .eq-selection-area-item[aria-selected="true"] .eq-selection-area-tick > svg {
    stroke-dashoffset: 0;
    transition: stroke-dashoffset var(--eq-dur-move) var(--eq-spring);
  }

  .eq-selection-area-marquee {
    position: absolute;
    z-index: 1;
    box-sizing: border-box;
    border: 1px solid var(--eq-focus);
    border-radius: 2px;
    background: color-mix(in srgb, var(--eq-focus) 12%, transparent);
    pointer-events: none;
  }
}

EqUIp is in beta: free for non-commercial use, no warranty, and things can change. Terms

Nothing picked yet. Press A on a part, or use the plus on the board, and the install command builds here.

Loading the index.

Loading the index