Where a shortcut binds, and why the document's capture phase is the only place that survives an overlay.
Cross-cutting laws·docs/keyboard.md·68 lines
Kit src/hooks/use-hotkeys.tsLiving example · Keyboard
How shortcuts get bound. A cross-cutting law like feedback.md — it applies inside every page class and, more to the point, inside every overlay. The kit is src/hooks/use-hotkeys.ts; the living example is /labs/foundations/keyboard, where the failure mode is a switch you can flip.
01
A shortcut that works on the page and dies inside a dialog is the default outcome, not an edge case. Overlay primitives (Base UI, Radix) trap focus by intercepting keyboard events in the capture phase. A listener on window — the shape every tutorial shows — sits in the bubble phase and therefore never runs while an overlay is open. The panel prints ← → 切换, the user presses the key, nothing happens.
This law pins the three decisions that prevent it.
02
document, capture phaseuseHotkeys attaches with addEventListener("keydown", handler, true). That puts the handler on the same node as the overlay's own listener, where stopPropagation cannot reach it — stopPropagation silences later nodes, not other listeners on the same one.
Measured on a live dialog: a probe on document-capture fires; probes on document-bubble and window-bubble never do.
The phase: "bubble" option exists only so the living example can demonstrate the failure. Production code does not pass it.
If document.activeElement is a button, a, summary, or role="button", those two keys are its native activation and the hook stands down. Otherwise "space = play" quietly steals the keypress from a keyboard user aiming at Close.
Corollary for overlay authors: move focus to the content region on open, or the first chrome button owns the space bar for the whole session.
Inside input, textarea, select, or contenteditable, letters are text. The hook returns early unless allowInFormFields is set — reach for that only for a global escape hatch (a command palette), never for single letters.
03
No scopes or priorities between overlapping contexts, no key sequences (g then i), no user-remappable bindings, no generated shortcut sheet. When a screen needs any of those, that is the moment a library earns its dependency — and note that the popular ones (react-hotkeys-hook, @tanstack/react-hotkeys) bind on the bubble phase by default too, so ruling 1 stays your problem either way.
04
"ArrowRight" · "f" · "Space" · "Mod+K" · "Shift+?". Mod resolves to ⌘ on Apple platforms and Ctrl elsewhere. Shift is only enforced when written out, so "?" still matches the keypress that produces it. formatHotkey renders a binding for a hint line (Mod+K → ⌘K / Ctrl+K).
Handlers live in a ref, so callers need no useCallback and the listener is not rebound on every render.