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.
"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.
useSelectedmanages theSetand exposes achange(addOrRemove, items)callback.useShiftSelecttakes the items array and that callback, and returns anonChangehandler. This keeps the selection state liftable — swapuseSelectedfor your own store/reducer without touching the shift logic. - Global shift tracking, not
nativeEvent. React's syntheticChangeEventfor checkboxes wraps a plainEvent, not aMouseEvent— it has noshiftKeyproperty. The hook listens forkeydown/keyupondocumentand stores the shift state in a ref. Ablurlistener onwindowresets it so alt-tabbing while holding Shift doesn't leave the hook stuck. - Select-all with indeterminate. The header checkbox uses the
native
indeterminateproperty (set via a callback ref, since there's no HTML attribute for it) so it shows a dash when some items are selected. It usessetSelecteddirectly fromuseSelectedrather than going through the shift hook. - Accessibility. The list uses
role="listbox"+aria-multiselectableand each row usesrole="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.