Skip to content

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

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 test with 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/hotkeys 0.17.0 and @tanstack/react-hotkeys 0.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

Type-safe keyboard shortcuts with scopes, sequences, and user rebinding.

Pros

  • Typed bindings: Mod+S for chords, ['G', 'G'] for sequences; a typo is a type error. Mod resolves 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 / Meta chords and Escape still 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 exclusive scope — a dialog’s, say — suspends every other scope, including global. 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: enabled for a user setting, app hotkeys for remapping, target for focus-only
  • Attaches no listeners during server rendering; MIT; peer range react >=16.8; @tanstack/react-hotkeys depends on exactly @tanstack/hotkeys 0.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 useHotkeySequence and useHotkeySequences, renamed Hotkey to HotkeyChord and SequenceManager to HotkeyManager; 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: preventDefault and stopPropagation are 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

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.

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.

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.

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.

  • Modifier chords and non-character keys by default; character-key shortcuts only behind an off switch. Mod chords, Escape, and function keys are exempt from WCAG 2.1.4; ?, /, or G H need 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

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-hotkeys 0.16.0, installed with bun add --exact. It re-exports the core and pins @tanstack/hotkeys 0.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 useHotkey or useHotkeys; the shortcut exists while the component is mounted. Write bindings with Mod, never Control+S or Meta+S, so each platform gets its own modifier. Type them as HotkeyChord or Hotkey
  • Every shortcut a user can see is an app hotkey. Declare it with defineAppHotkeys in one module of declarations only, with meta.name and meta.group, and pass it to the root HotkeysProvider. Ids are a stable contract — saved custom hotkeys are keyed by them. One-off local keys, such as Escape in a popup, can be plain useHotkey
  • Scopes follow regions of the UI. Page shortcuts stay in global. Each dialog, popover, or palette wraps its hotkeys in HotkeysProvider 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 Escape through the dialog’s onOpenChange, not with a second Escape hotkey. 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, or Alt chords. So: no bare arrow, Enter, or Space shortcut at document level; a region that needs them gets a target ref to its own element
    • Text fields: leave ignoreInputs at its default, so single keys never fire while typing. Enter is the native form submit (ADR-0013), never a hotkey
    • Use capture: true only with a written reason — it runs before a focused widget’s own handler
  • Accessibility rules (WCAG 2.1.4, Level A, and the WAI-ARIA Authoring Practices):
    1. 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 a target so it works only on focus. The default is one “Single-key shortcuts” setting that gates them all. Chords with Mod, Control, or Alt, and keys such as Escape and function keys, are exempt
    2. Never the only path. Every action stays reachable through normal focus order and visible controls
    3. Avoid key conflicts with the operating system, browser, and assistive technology. No modifier plus Tab, Enter, Space, or Escape; no Meta plus a single other key; no Alt plus a function key; nothing built on Caps Lock, Insert, or Scroll Lock (screen readers use them as modifiers); on macOS, no Control+Option combinations; mind the browser’s address bar, refresh, bookmarks and history, and find. Browsers keep Mod+T, Mod+N, and Mod+W anyway. An intentional conflict, such as Mod+S in 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
    4. Discoverable. Show the shortcut on the control itself — <kbd> (shadcn/ui’s Kbd) with formatForDisplay of the same binding that is registered — and provide a help dialog built from useAppHotkeyList and opened from a visible control. The shortcut for the help dialog is an extra, never the only way in
    5. Settings screen: the single-key toggle, plus rebinding with useHotkeyRecorder, findConflicts, and useCustomHotkeys. Where custom hotkeys are saved is a product choice, but they must be loaded before the provider mounts: it reads customHotkeys once
  • Server rendering: Hotkeys attach no listeners on the server, and Mod is detected from navigator — 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+K and 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 onSelect and a shortcut label, 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 around useHotkey: 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.

Easier:

  • Typed bindings; Mod and 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) or tinykeys. 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.