Hooks

useKeyboardShortcut

A hook that binds human-readable key combos like mod+k to a handler, with platform-aware modifiers and an input-focus guard.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

export interface UseKeyboardShortcutOptions {
  /** Turn the listener off (e.g. a shortcut that only applies while a panel is open). Defaults to true. */
  enabled?: boolean
  /** Call `event.preventDefault()` on a match, blocking the browser's own behaviour (⌘S saves the page, ⌘K jumps to the address bar). Defaults to true. */
  preventDefault?: boolean
  /** Don't fire while focus is in an input / textarea / select / contenteditable. Defaults to true. */
  ignoreInputs?: boolean
  /**
   * Where to attach the listener, `window` by default. Pass a node, or a ref object — the latter is
   * the only reliable way to scope a shortcut to a container: `ref.current` is still null on the

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/use-keyboard-shortcut.json

Prompt

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.
  • mod as 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.key over keyCodekeyCode is deprecated and layout-dependent; event.key gives 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 enabled is true and the component is mounted; toggling enabled is the intended way to scope a binding to an open panel rather than checking state inside the handler.
  • Refs are dereferenced in the effecttarget accepts a ref object, not just a node, because a node read during render is null on 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".

On This Page