Keyboard-First Hotkeys
Harvested from a production shop-floor kiosk worked with a barcode scan gun. Proven on React 19 · shadcn/ui (Radix Dialog/Select) · a Windows floor kiosk and macOS dev machines. Nothing here needs the scan gun — the same system carries any keyboard-first app; the gun is just the harshest test of it.
The threat model (why the design is what it is)
A scan gun is a keyboard: it types its payload into whatever has focus and ends with Enter. Two failure modes drive everything:
- Misfire — focus sits on a button, a scan arrives, its letters land as bare keypresses and trigger single-letter shortcuts. Cure: every shortcut takes Alt, so a stray scan can never fire one and no "is the user typing?" guard is needed anywhere.
- Focus adrift — a re-render (socket event, poll, an action disabling the
field it was fired from) removes the focused element; the browser drops
focus to
<body>, and from there a scan goes nowhere and Enter does nothing. Cure: a focus chain (one selector defining "the next thing") plus a focus guard that only acts when focus is genuinely adrift.
Moving parts
HOTKEYScatalogue (lib/hotkeys.ts) — oneRecord<id, HotkeySpec>(code,label,description,scope). Bindings,<Kbd>hints, and the cheatsheet all read from it, so they cannot disagree.useHotkey(id, handler, {enabled, inModal})— binds a catalogued chord while the calling component is mounted; mounting is the scoping. PlususeQueuePickHotkeysforAlt+1…9→ pick(0…8) list selection.- Focus chain (
lib/focus.ts) —FOCUSABLEselector (single definition of "next thing"),focusFirstIn,focusNextIn,focusIsAdrift,focusIsInside,pressPrimary(fires the one enabled[data-primary]button, wherever focus is). useFocusGuard(target, deps)— refocusestargetafter the renders named indeps, only when focus is adrift.<Kbd>— key-cap chip printed on the buttons themselves, so the keyboard path is discoverable from the bench, not from training.ShortcutsDialog— cheatsheet rendered from the catalogue, grouped by scope, opened by its own hotkey and viaopenShortcuts()(a window event, so no context/store for one dialog).
Workflow
- Read
REFERENCE.md— building blocks numbered as above. - Define the app's catalogue: id →
{code, label, description, scope}. Scopes are the app's screens/modes; the cheatsheet groups by them. - Drop in
useHotkey+ the modal guard; bind in the component that owns each action (mount = scope),enabled:for conditional ones. - Adapt
FOCUSABLEto the app's widgets (extension point: which elements count as "the next thing"; keep checkboxes and opt-outs excluded). - Wire the chain: autofocus effects call
focusFirstInguarded byfocusIsAdrift() || focusIsInside(card); picked dropdowns hand off viarequestAnimationFrame(() => focusNextIn(...)); mountuseFocusGuardin each screen for the places nothing else claims. - Mount
ShortcutsDialogonce in the app shell; print<Kbd>hints on the most-hit buttons; link the cheatsheet from a menu too — an operator who doesn't know shortcuts exist can't guess the one that lists them. - Verify on both platforms: macOS (Option mangling) and Windows (kiosk).
Pitfalls (each already paid for once)
- Match on
event.code, neverevent.key— on macOS Alt is Option andkeyarrives pre-mangled (Option+Q → "œ").code("KeyQ") is stable. - Always
preventDefault()on a match — it's also what stops macOS inserting the Option character into a focused text field. - Plain Alt only: require
altKey && !ctrlKey && !metaKey && !repeat, so browser/OS chords (Ctrl+Alt, Cmd+Alt, AltGr) pass through untouched. - Keep letters off Firefox's menu mnemonics (F E V H B T) — Alt+those open browser menus on Windows.
- Window listeners pierce dialogs. Without a
modalIsOpen()guard an Alt chord reaches past an open dialog and acts on the screen behind it. Detect via the dialog's DOM marker ([data-slot="dialog-content"]for shadcn). The cheatsheet itself opts in withinModal: trueso the chord that opened it can close it. - No dep array on the
useHotkeyeffect — on purpose. The handler is a fresh closure over current state every render; re-attaching one listener is cheaper than making every call site memoize correctly. - The guard must never steal focus the user placed deliberately — act
only when
focusIsAdrift(), and re-check inside arequestAnimationFrame: Radix restores focus asynchronously on unmount and should win. - "Focus pass-through" places count as adrift. A hidden print iframe (or
similar) gets focused on the way to a browser dialog; treated as a real
focus position, the Enter chain stalls there. Mark such elements with an
attribute and include them in
focusIsAdrift. - One selector, shared. The autofocus effect and the focus guard must
read the same
FOCUSABLEdefinition or they fight over where focus goes. data-skip-autofocusopts an element out when DOM order and workflow order disagree (buttons above the field the scan belongs in).pressPrimaryworks by invariant: every primary action button carries[data-primary]and app gating guarantees at most one is enabled — finding it by attribute beats threading refs through every card. Keep the invariant or the shortcut fires the wrong action.- Dropdown hand-off is deferred: after a pick, queue
focusNextInbehindrequestAnimationFrame— behind Radix's focus-restore to the trigger and the re-render that may have just disabled the next field, so a Pass lands on the confirm button and a Fail lands on the now-enabled detail field.