UI Patterns
Hooks

Shift Select

Select multiple checkboxes by holding Shift — the Gmail / Finder multi-select pattern.

Examples

Checkbox list with Shift range

The standard multi-select pattern from Gmail, macOS Finder, and every file manager: click a checkbox, then Shift+click another, and everything in between gets the same state. It's one of those interactions users expect everywhere but that requires a custom hook to wire up — the browser gives you nothing.

Based on stereobooster.com/posts/react-hook-to-select-multiple-items-with-a-shift.

Select all

Hold Shift and click to select a range.

hooks/use-shift-select.ts
"use client";

import * as React from "react";

/**
 * Manage a set of selected items with `add`/`remove` via the `change` callback.
 */
export function useSelected<P>(initial: P[] = []) {
  const [selected, setSelected] = React.useState<Set<P>>(
    () => new Set(initial),
  );

  const change = React.useCallback((addOrRemove: boolean, items: P[]) => {
    setSelected((prev) => {
      const next = new Set(prev);
      for (const item of items) {
        if (addOrRemove) next.add(item);
        else next.delete(item);
      }
      return next;
    });
  }, []);

  return { selected, change, setSelected };
}

/**
 * Enable Shift+click range selection on a checkbox list.
 *
 * Based on https://stereobooster.com/posts/react-hook-to-select-multiple-items-with-a-shift/
 *
 * Returns an `onChange` handler to wire onto each `<input type="checkbox">`.
 * Pair with `useSelected` to manage the selection set.
 *
 * - Normal click: toggles one item, sets it as the anchor.
 * - Shift+click: fills the range `[anchor, clicked]` using the anchor's
 *   direction, and undoes any previous shift-range that extended past the
 *   new endpoint (so shift-clicking again to shrink the selection works).
 */
export function useShiftSelect<P>(
  items: P[],
  change: (addOrRemove: boolean, items: P[]) => void,
) {
  const [previousSelected, setPreviousSelected] = React.useState<P | null>(
    null,
  );
  const [previousChecked, setPreviousChecked] = React.useState(false);
  const [currentSelected, setCurrentSelected] = React.useState<P | null>(null);
  const shiftRef = React.useRef(false);

  // Track shift key globally — React's checkbox onChange nativeEvent is a
  // plain Event (not MouseEvent), so it has no shiftKey property.
  React.useEffect(() => {
    const onKey = (e: KeyboardEvent) => {
      shiftRef.current = e.shiftKey;
    };
    const onBlur = () => {
      shiftRef.current = false;
    };
    document.addEventListener("keydown", onKey);
    document.addEventListener("keyup", onKey);
    window.addEventListener("blur", onBlur);
    return () => {
      document.removeEventListener("keydown", onKey);
      document.removeEventListener("keyup", onKey);
      window.removeEventListener("blur", onBlur);
    };
  }, []);

  const onChange = React.useCallback(
    (event: React.ChangeEvent<HTMLInputElement>, item: P) => {
      if (shiftRef.current) {
        const current = items.findIndex((x) => x === item);
        const previous = items.findIndex((x) => x === previousSelected);
        const previousCurrent = items.findIndex((x) => x === currentSelected);
        const start = Math.min(current, previous);
        const end = Math.max(current, previous);

        if (start > -1 && end > -1) {
          // Apply anchor direction to new range.
          change(previousChecked, items.slice(start, end + 1));

          // Undo the old tail that is no longer in range.
          if (previousCurrent > end) {
            change(!previousChecked, items.slice(end + 1, previousCurrent + 1));
          }
          if (previousCurrent < start) {
            change(!previousChecked, items.slice(previousCurrent, start));
          }

          setCurrentSelected(item);
          return;
        }
      } else {
        setPreviousSelected(item);
        setCurrentSelected(null);
        setPreviousChecked(event.target.checked);
      }

      change(event.target.checked, [item]);
    },
    [change, items, previousSelected, previousChecked, currentSelected],
  );

  return onChange;
}

Usage:

const { selected, change } = useSelected<string>();
const onChange = useShiftSelect(items, change);

<input
  type="checkbox"
  checked={selected.has(item)}
  onChange={(e) => onChange(e, item)}
/>;

Notes:

  • Anchor + range undo. The first non-Shift click sets an anchor (which item and whether it was checked or unchecked). Shift+click fills the range [anchor, clicked] using the anchor's direction. Crucially, if you shift+click again to shrink the range, the hook undoes the old tail — items that were in the previous shift-range but not the new one get the opposite treatment. This matches Gmail: check item 0, then Shift+click item 9 (0–9 selected), then Shift+click item 4 (5–9 deselected). Same for uncheck: uncheck item 0, Shift+click item 9 (0–9 unchecked), Shift+click item 4 (5–9 re-checked).
  • Two hooks, separated concerns. useSelected manages the Set and exposes a change(addOrRemove, items) callback. useShiftSelect takes the items array and that callback, and returns an onChange handler. This keeps the selection state liftable — swap useSelected for your own store/reducer without touching the shift logic.
  • Global shift tracking, not nativeEvent. React's synthetic ChangeEvent for checkboxes wraps a plain Event, not a MouseEvent — it has no shiftKey property. The hook listens for keydown/keyup on document and stores the shift state in a ref. A blur listener on window resets it so alt-tabbing while holding Shift doesn't leave the hook stuck.
  • Select-all with indeterminate. The header checkbox uses the native indeterminate property (set via a callback ref, since there's no HTML attribute for it) so it shows a dash when some items are selected. It uses setSelected directly from useSelected rather than going through the shift hook.
  • Accessibility. The list uses role="listbox" + aria-multiselectable and each row uses role="option" + aria-selected. The Shift+click behaviour is an enhancement on top of already-functional checkboxes — every item is independently toggleable without Shift, so keyboard-only users aren't blocked.

On this page