ADR-0024 Keyboard shortcuts
Company standard keyboard shortcuts — TanStack Hotkeys, pinned to exact versions while pre-1.0, with WCAG 2.1.4 and discoverability rules for every shortcut.
- Decision
- TanStack Hotkeys, pinnedBuild the app
- Status
- Accepted
- Date
- Scope
- Company-wide default for all new apps
Connections
Context
Section titled “Context”Dense interfaces — tables, editors, command palettes, dialogs — want keyboard shortcuts. Getting them right is mostly edge cases: keyboard layouts, Command on macOS versus Control elsewhere, typing in fields, focused widgets that use the same keys, IME composition, a dialog and the page behind it both wanting Escape. Done carelessly, shortcuts are also an accessibility hazard: WCAG 2.1.4 Character Key Shortcuts (Level A) requires that a shortcut made only of letter, number, punctuation, or symbol keys can be turned off, remapped to include a modifier such as Control or Alt, or is active only while its component has focus.
The surrounding decisions are made:
- Components: shadcn/ui on Base UI, which owns focus and keys inside dialogs, menus, and selects (ADR-0011)
- Forms: TanStack Form with a native
<form>submit (ADR-0013) - Rendering: TanStack Start renders on the server, then hydrates (ADR-0002)
- Tests:
bun testwith happy-dom for components, Playwright for keyboard behavior and end-to-end (ADR-0014)
What is open: which library registers shortcuts, how shortcuts live next to Base UI’s own keyboard handling, and what accessibility rules every shortcut must meet.
Constraints:
- A shortcut is never the only way. The WAI-ARIA Authoring Practices say not to use keyboard shortcuts as a substitute for access through normal navigation
- Pre-1.0, and moving fast. Hotkeys’ overview calls it beta with an API “still subject to change”; the packages are
@tanstack/hotkeys0.17.0 and@tanstack/react-hotkeys0.16.0 (both published 2026-10-19). The policy is the one ADR-0008 set for Drizzle v1 while it was a release candidate: adopt, pin exact versions, upgrade deliberately
Options considered
Section titled “Options considered”Library
Section titled “Library”1. TanStack Hotkeys
Section titled “1. TanStack Hotkeys”Type-safe keyboard shortcuts with scopes, sequences, and user rebinding.
Pros
- Typed bindings:
Mod+Sfor chords,['G', 'G']for sequences; a typo is a type error.Modresolves to Command on macOS and Control on Windows and Linux - Sensible defaults: single keys are ignored while the user types in a text field, while
Control/Metachords andEscapestill fire. Focused buttons, menus, listboxes, tabs, and sliders keep the keys they use, following the WAI-ARIA Authoring Practices. Layouts, AltGr, and IME composition are handled - Scopes: a hotkey is active while its component is mounted; nested scopes win; an
exclusivescope — a dialog’s, say — suspends every other scope, includingglobal. Scopes follow the component tree, so portals work - App hotkeys: declare every shortcut once with
defineAppHotkeys, so help dialogs and settings screens list shortcuts on pages that haven’t loaded. Users can rebind or unbind through a recorder, and conflicts are checked across all declarations. The library stores nothing; the app decides where - Its docs address WCAG 2.1.4 directly:
enabledfor a user setting, app hotkeys for remapping,targetfor focus-only - Attaches no listeners during server rendering; MIT; peer range
react >=16.8;@tanstack/react-hotkeysdepends on exactly@tanstack/hotkeys0.17.0 and re-exports it
Cons
- Beta, and the API moves fast. First published 2026-02-10; from 0.11.0 (2026-10-04) to 0.17.0 (2026-10-19) there were six minor releases in 15 days. 0.12.0 (2026-10-14) removed
useHotkeySequenceanduseHotkeySequences, renamedHotkeytoHotkeyChordandSequenceManagertoHotkeyManager; 0.9.0 (2026-09-21) made recorders default to physical key codes. The docs ask early adopters to help with “edge cases across multiple keyboard layouts, locales, and operating systems” - Young: the repository was created in 2026-01, so there is far less community experience than with the alternatives
- Defaults take over the key press:
preventDefaultandstopPropagationare on, so a registered shortcut hides the browser’s action and other listeners - The library offers the accessibility tools but doesn’t enforce WCAG 2.1.4 — that is our rule
2. react-hotkeys-hook
Section titled “2. react-hotkeys-hook”5.3.3 (2026-06-26), MIT, actively maintained.
Pros: a stable major with a much longer track record; useHotkeys with scopes (HotkeysProvider, toggleScope); hotkeys off in form fields by default (enableOnFormTags); sequences (sequenceTimeoutMs, default 1000 ms); useRecordHotkeys for settings screens; a description option for help panels.
Cons: its docs list no declared registry with user rebinding and conflict checks, and no cheat-sheet helpers — we would build those. preventDefault is off by default, so every Mod+S needs it set. The choice if pre-1.0 risk outweighs those features, and our fallback if Hotkeys disappoints.
3. tinykeys
Section titled “3. tinykeys”4.0.1 (2026-09-25), MIT, “~1KB” by its README.
Pros: tiny; $mod for the platform modifier; sequences ("g i", 1000 ms timeout); ignores events from input, textarea, select, and contenteditable by default; returns an unsubscribe function.
Cons: framework-agnostic, so we write the useEffect and cleanup per use. Its README has no scopes, recorder, rebinding, or conflict detection, and no awareness of focused widgets beyond form tags.
4. Mousetrap
Section titled “4. Mousetrap”1.6.5; Apache-2.0 with the LLVM exception.
Pros: long-established — releases go back to at least 2015. Cons: the last npm release (1.6.5) is from 2020-01-23 and the last push to its repository is from 2023-03-15. Effectively unmaintained; ruled out on that alone.
Not a library
Section titled “Not a library”A. A hand-rolled keydown listener
Section titled “A. A hand-rolled keydown listener”Pros: zero dependencies. Cons: Hotkeys’ own list of gotchas is the list we would rediscover — layouts, operating systems, custom shortcuts, conflicting scopes, ignoring inputs, key repeat, IME composition, and keys that look stuck after a window switch. Cheat sheets and settings drift from the real shortcuts.
Which shortcuts we allow
Section titled “Which shortcuts we allow”- Modifier chords and non-character keys by default; character-key shortcuts only behind an off switch.
Modchords,Escape, and function keys are exempt from WCAG 2.1.4;?,/, orG Hneed a user setting. One rule meets the criterion - Anything goes. Fastest for power users, but fails WCAG 2.1.4 and misfires for speech-input users, whose dictation arrives as key presses
Decision
Section titled “Decision”TanStack Hotkeys, through @tanstack/react-hotkeys pinned to exact versions, is the company standard for keyboard shortcuts in all new apps. Every shortcut follows the accessibility rules below.
Concretely:
- Package:
@tanstack/react-hotkeys0.16.0, installed withbun add --exact. It re-exports the core and pins@tanstack/hotkeys0.17.0 itself, so the core is not installed separately - Pin exact versions (no
^) while Hotkeys is pre-1.0; upgrade deliberately, as in ADR-0008. Renovate (ADR-0016) opens the PR but never auto-merges it; a human reads the release notes first — the 0.12.0 notes carry a before-and-after table of every renamed API. Revisit at 1.0 - Register in the component that owns the action with
useHotkeyoruseHotkeys; the shortcut exists while the component is mounted. Write bindings withMod, neverControl+SorMeta+S, so each platform gets its own modifier. Type them asHotkeyChordorHotkey - Every shortcut a user can see is an app hotkey. Declare it with
defineAppHotkeysin one module of declarations only, withmeta.nameandmeta.group, and pass it to the rootHotkeysProvider. Ids are a stable contract — saved custom hotkeys are keyed by them. One-off local keys, such asEscapein a popup, can be plainuseHotkey - Scopes follow regions of the UI. Page shortcuts stay in
global. Each dialog, popover, or palette wraps its hotkeys inHotkeysProvider scope="…" exclusive, with a unique scope name - Base UI owns its own keys (ADR-0011):
- Dialogs: Base UI moves focus in, loops Tab, and “Esc requests close”. Close on
Escapethrough the dialog’sonOpenChange, not with a secondEscapehotkey. Dialog-only shortcuts live in an exclusive scope, so the page’s shortcuts are suspended while it is open - Menus, selects, tabs, and other widgets keep arrows, Home, End, Enter, Space, and typeahead. Hotkeys skips the keys a focused ARIA widget uses, but never skips
Control,Meta, orAltchords. So: no bare arrow,Enter, orSpaceshortcut at document level; a region that needs them gets atargetref to its own element - Text fields: leave
ignoreInputsat its default, so single keys never fire while typing.Enteris the native form submit (ADR-0013), never a hotkey - Use
capture: trueonly with a written reason — it runs before a focused widget’s own handler
- Dialogs: Base UI moves focus in, loops Tab, and “Esc requests close”. Close on
- Accessibility rules (WCAG 2.1.4, Level A, and the WAI-ARIA Authoring Practices):
- Character-key shortcuts need an off switch. Any shortcut made of a letter, number, punctuation, or symbol key — alone, with
Shift, or in a sequence — has one of: a user setting that turns it off (enabled), user remapping (app hotkeys with the recorder), or atargetso it works only on focus. The default is one “Single-key shortcuts” setting that gates them all. Chords withMod,Control, orAlt, and keys such asEscapeand function keys, are exempt - Never the only path. Every action stays reachable through normal focus order and visible controls
- Avoid key conflicts with the operating system, browser, and assistive technology. No modifier plus
Tab,Enter,Space, orEscape; noMetaplus a single other key; noAltplus a function key; nothing built onCaps Lock,Insert, orScroll Lock(screen readers use them as modifiers); on macOS, noControl+Optioncombinations; mind the browser’s address bar, refresh, bookmarks and history, and find. Browsers keepMod+T,Mod+N, andMod+Wanyway. An intentional conflict, such asMod+Sin an editor, is allowed only for a frequently used app function when the browser’s own function is rarely used and has another path — the Practices’ own test - Discoverable. Show the shortcut on the control itself —
<kbd>(shadcn/ui’sKbd) withformatForDisplayof the same binding that is registered — and provide a help dialog built fromuseAppHotkeyListand opened from a visible control. The shortcut for the help dialog is an extra, never the only way in - Settings screen: the single-key toggle, plus rebinding with
useHotkeyRecorder,findConflicts, anduseCustomHotkeys. Where custom hotkeys are saved is a product choice, but they must be loaded before the provider mounts: it readscustomHotkeysonce
- Character-key shortcuts need an off switch. Any shortcut made of a letter, number, punctuation, or symbol key — alone, with
- Server rendering: Hotkeys attach no listeners on the server, and
Modis detected fromnavigator— the server doesn’t know the user’s platform. Render shortcut labels after hydration (useHydrated()from@tanstack/react-router, as in ADR-0013), not in the server HTML, and register only through hooks, never at module level - Tests (ADR-0014): Playwright presses
ControlOrMeta+Kand similar, so one test covers both platforms. Each product tests that turning single-key shortcuts off disables them, and that an open dialog suspends page shortcuts - SOLID: hooks own behavior; components stay presentational. A button or menu item receives
onSelectand ashortcutlabel, and renders both. The page or feature hook registers the hotkey and calls the same handler the click uses — one handler, two triggers. The declarations module is the single source for registration, the help dialog, and the settings screen. There is no wrapper arounduseHotkey: the library’s API is already the abstraction, and a wrapper would only copy its surface. Revisit if upgrades start touching many files
The deciding factors: the layout, input-field, focused-widget, and scope edge cases handled for us; one declared registry that feeds shortcuts, help, and rebinding; and its docs addressing WCAG 2.1.4. We accept the pre-1.0 risk, which is higher than Pacer’s, and a smaller community.
Consequences
Section titled “Consequences”Easier:
- Typed bindings;
Modand layouts work across platforms without per-OS code - Typing in fields, using menus and listboxes, and stacking dialogs don’t trigger page shortcuts by accident
- One declaration feeds the shortcut, its on-screen label, the help dialog, and the rebinding screen, so they can’t drift apart
- WCAG 2.1.4 becomes one setting and one test per product, not a per-shortcut debate
Harder:
- Pre-1.0 risk, stated plainly. Hotkeys’ docs call it beta, and it is moving fast: six minor releases in 15 days, with breaking renames in 0.12.0 (2026-10-14) and 0.9.0 (2026-09-21). Exact pins mean no automatic fixes; every bump is a deliberate PR checked against the release notes, and older tutorials, and AI tools, will show APIs that no longer exist (
useHotkeySequence) - Revisit triggers: Hotkeys reaches 1.0 (plan the upgrade window and review the pin policy); a bump breaks something we can’t fix inside its upgrade PR; or the project stops releasing. The exit costs more than Pacer’s: leaving means rebuilding app hotkeys, scopes, and rebinding on
react-hotkeys-hook(stable 5.x) ortinykeys. That is why the declarations live in one module - Accessibility is our rule, not the library’s. Review for bare-key shortcuts without the off switch, and for actions reachable only by shortcut
- Hotkeys decides what a focused widget keeps by ARIA role. We haven’t checked each Base UI component against that table, so test it before shipping a bare-key shortcut on a page with menus or listboxes
- App hotkey ids are a contract: renaming one drops users’ saved custom hotkey for it, and Hotkeys ignores unknown ids with a warning
- Browser and OS shortcuts cannot be overridden; some wanted combinations will be taken
- Shortcut labels depend on the user’s platform; printed in the server HTML they can disagree with the client, so they render after hydration
Given up: react-hotkeys-hook’s stable major and track record, tinykeys’s footprint, and the zero-dependency simplicity of a hand-rolled listener. Accepted — a declared registry with scopes, rebinding, and accessibility guidance is worth a pinned, pre-1.0 dependency and a planned upgrade at 1.0.

