useKeyboardShortcut
A hook that binds human-readable key combos like mod+k to a handler, with platform-aware modifiers and an input-focus guard.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/use-keyboard-shortcut.jsonPrompt
Build a React + TypeScript "useKeyboardShortcut" hook (no dependencies
beyond React; uses KeyboardEvent only).
Contract
- `useKeyboardShortcut(keys, handler, options?): void` where
`keys: string | string[]`, `handler: (event: KeyboardEvent) => void`, and
`options?: { enabled?: boolean; preventDefault?: boolean;
ignoreInputs?: boolean; target?: EventTarget |
React.RefObject<EventTarget | null> | null }`.
- Defaults: `enabled: true`, `preventDefault: true`, `ignoreInputs: true`,
`target: window`.
- Combo syntax: `"mod+k"`, `"mod+shift+s"`, `"shift+?"`, `"escape"`,
case-insensitive. Modifier tokens: `mod` (⌘ on Apple platforms, Ctrl
elsewhere), `ctrl`/`control`, `meta`/`cmd`/`command`, `shift`,
`alt`/`option`. Aliases for keys that don't type as themselves: `esc`,
`space`, `plus`, `up`/`down`/`left`/`right`, `return`, `del`.
- Passing an array binds several combos to the same action
(`["mod+k", "mod+/"]`); the hook returns `void` — it is a subscription,
not a value.
Behavior
- Parse each combo once per effect run into `{ key, mod, ctrl, meta, shift,
alt }`. A combo made of modifiers only (no real key) is dropped rather than
matching everything.
- Match on `event.key.toLowerCase()`, never the deprecated `keyCode`. All
four modifier flags are compared **exactly**: `"escape"` must not fire
while Shift is held, and `"mod+k"` must not be satisfied by ⌘⇧K. Expand
`mod` to `metaKey` on Apple platforms and `ctrlKey` everywhere else before
comparing, detecting the platform inside the effect (never at module scope
or during render, so server rendering stays clean).
- `ignoreInputs` (default on): skip the handler when `event.target` is an
`<input>`, `<textarea>`, `<select>` or a `contenteditable` element. This is
what makes single-letter shortcuts usable at all — without it, typing "e"
in a search field silently toggles whatever "e" is bound to.
- `preventDefault` (default on): call `preventDefault()` only after a combo
matched, so unrelated keystrokes keep their native behaviour while ⌘S / ⌘K
stop triggering the browser's own save/address-bar actions.
- Keep `handler` in a ref refreshed on every render, and derive a serialized
`keys` key for the dependency array. Consumers pass inline arrows and
inline arrays constantly; depending on them directly would tear down and
re-add the `keydown` listener on every render.
- Resolve `target` inside the effect: `target instanceof EventTarget ? target :
target?.current`, then fall back to `window`. Accepting the ref object is what makes
scoping to a container work at all — on the first render `panelRef.current`
is still null, and assigning a ref does not re-render, so a consumer who
passes `panelRef.current` binds silently to `window` and the effect never
re-runs to correct it. The ref object's identity is stable, so it can sit in
the dependency array without re-attaching anything.
- Attach one `keydown` listener to the resolved node inside an effect and
remove it on unmount, when `enabled` goes false, and before any re-attach.
Type the resolved node as `EventTarget` so window, document and elements
all work through one call signature.
- Known limitation to document rather than paper over: on macOS, Option+letter
produces a different `event.key` character (option+s → "ß"), so
alt-based letter combos are unreliable across layouts; matching on
`event.code` would be the fix if a project needs them.
Rendering & styling
- The hook renders nothing. Consumers own the visible affordance and should
render the binding as a real hint — `<kbd>` chips styled with `bg-muted`,
`border`, `text-muted-foreground` — and label it per platform (⌘ vs Ctrl)
by reading the platform after mount, via `useSyncExternalStore` with a
fixed server snapshot so the markup doesn't mismatch during hydration.
- SSR note: nothing is read from `window`/`navigator` during render, so the
server output is stable; only the platform-dependent *label* needs the
hydration-safe treatment above.
- Accessibility: a shortcut must never be the only way to reach an action —
keep the button/menu item that does the same thing, and consider a
"shift+?" cheat sheet listing the bindings.
Customization levers
- `target` — scope a binding to a container so it only fires while the
pointer/focus context is that widget, instead of globally on `window`. Pass
the ref object (`{ target: panelRef }`), not `panelRef.current`: the hook
dereferences it inside the effect, after the node exists.
- `enabled` — bind a combo only while a layer is open (`enabled: open` for
Escape) so no listener exists the rest of the time.
- `preventDefault: false` — for shortcuts that should coexist with native
behaviour (single letters that must still type into unguarded fields).
- Combo vocabulary — the alias table and modifier token list are plain
objects/switch cases; extend them with project-specific names (`"del"`,
`"pageup"`, media keys) without touching the matching logic.
- Repeat handling — auto-repeat while a key is held currently fires the
handler repeatedly; add an `event.repeat` early return if the bound action
is expensive or non-idempotent.
- Chords (`g` then `h`) and long-press are deliberately out of scope; build
them as a separate hook with its own timing state machine rather than
overloading this contract.Concepts
- Combo string as the contract — bindings are authored the way they are documented to users (
"mod+shift+s"), so the string in the code and the chip in the UI stay in sync; parsing happens once per effect run, not per keystroke. modas the platform seam — one token absorbs the ⌘/Ctrl split, keeping application code free of platform branches; the detection is done inside the effect so the server never runs it.- Exact modifier matching — comparing all four modifier flags for equality (not "at least these") is what keeps
"mod+s"and"mod+shift+s"distinguishable and stops"escape"from firing during Shift+Escape. - Input-focus guard — the single biggest cause of "shortcuts fight with typing"; the guard checks tag names plus
isContentEditable, and is a plain option so a search field's own"escape"binding can opt out. event.keyoverkeyCode—keyCodeis deprecated and layout-dependent;event.keygives the character the user actually produced, which is why"shift+?"works as written on a US layout and why Option+letter combos on macOS are called out as unreliable.- Subscription lifecycle — the listener exists exactly while
enabledis true and the component is mounted; togglingenabledis the intended way to scope a binding to an open panel rather than checking state inside the handler. - Refs are dereferenced in the effect —
targetaccepts a ref object, not just a node, because a node read during render isnullon the first pass and ref assignment never triggers a re-render; deferring the read to the effect is the difference between "scoped to this panel" and "silently global forever".