useHotkeys
Declare keyboard shortcuts once, attach handlers where they're used, and let the focused panel's bindings take priority.
import { useState } from "react"
import { HotkeysProvider, useHotkey } from "@/hooks/use-hotkeys"
import type { HotkeyBinding } from "@/lib/hotkeys"
const BINDINGS: HotkeyBinding[] = [
{ id: "counter.increment", keys: "i", scope: "global", description: "Increment counter" },
]
function Counter() {
const [count, setCount] = useState(0)
const increment = () => setCount((value) => value + 1)
useHotkey("counter.increment", increment)
return (
<div className="w-fit max-w-full space-y-3 text-sm">
<button type="button" className="rounded border border-border px-3 py-2 hover:bg-muted" onClick={increment}>Increment</button>
<p role="status" className="lining-nums tabular-nums">Count: {count}</p>
</div>
)
}
export default function UseHotkeysDemo() {
return <HotkeysProvider bindings={BINDINGS}><Counter /></HotkeysProvider>
}
Installation
pnpm dlx shadcn@latest add tradecn/ui/use-hotkeys#v1.4.13
npx shadcn@latest add tradecn/ui/use-hotkeys#v1.4.13
yarn dlx shadcn@latest add tradecn/ui/use-hotkeys#v1.4.13
bunx --bun shadcn@latest add tradecn/ui/use-hotkeys#v1.4.13
-
Copy the files into your project:
hooks/use-hotkeys.tsx import { createContext, useContext, useEffect, useMemo, useRef, useState, useSyncExternalStore, type ComponentProps, type ReactNode } from "react" import { createHotkeyRegistry, type HandlerScope, type HotkeyBinding, type HotkeyEntry, type HotkeyHandler, type HotkeyRegistry, type HotkeyScopeName, type HotkeyTarget } from "@/lib/hotkeys" // React bindings for the hotkey registry. The registry does the work and knows nothing about React; // this file puts one in context, attaches its listener, marks scopes in the DOM, and keeps a // component's handler attached for as long as the component is mounted. const RegistryContext = createContext<HotkeyRegistry | null>(null) const ScopeContext = createContext<HandlerScope | null>(null) export interface HotkeysProviderProps { /** Bring your own to declare bindings and load overrides before the first render. One is created otherwise. */ registry?: HotkeyRegistry /** Declared on mount, removed on unmount. Keep the array's identity stable: a module constant. */ bindings?: readonly HotkeyBinding[] /** Where to listen. `document` by default; a popout window's document for a second one. */ target?: HotkeyTarget children?: ReactNode } export function HotkeysProvider({ registry, bindings, target, children }: HotkeysProviderProps) { const [own] = useState(() => createHotkeyRegistry()) const value = registry ?? own useEffect(() => value.attach(target), [value, target]) useEffect(() => { if (!bindings) return for (const binding of bindings) value.register(binding) return () => { for (const binding of bindings) value.unregister(binding.id) } }, [value, bindings]) return <RegistryContext.Provider value={value}>{children}</RegistryContext.Provider> } /** The registry from the nearest provider. */ export function useHotkeys(): HotkeyRegistry { const registry = useContext(RegistryContext) if (!registry) throw new Error("useHotkeys needs a <HotkeysProvider> above it") return registry } /** The same, or null without a provider, for components that treat hotkeys as optional. */ export function useMaybeHotkeys(): HotkeyRegistry | null { return useContext(RegistryContext) } export interface UseHotkeyOptions { /** False detaches the handler; the binding stays declared and listed. */ enabled?: boolean } /** * Attach a handler to a declared binding while this component is mounted. Inside a `HotkeyScope` of * the binding's own scope, the handler answers only for events from that scope element, so two * instances of a panel each get their own keys. */ export function useHotkey(id: string, handler: HotkeyHandler, options: UseHotkeyOptions = {}): void { const registry = useHotkeys() const scope = useContext(ScopeContext) const enabled = options.enabled !== false const latest = useRef(handler) useEffect(() => { latest.current = handler }) useEffect(() => { if (!enabled) return return registry.bind(id, (event) => latest.current(event), scope) }, [registry, id, enabled, scope]) } /** Every binding with its keys in force, for a help overlay or a settings screen. Re-renders on declare, remap, and reset, and when a handler attaches or detaches. */ export function useHotkeyList(): readonly HotkeyEntry[] { const registry = useHotkeys() return useSyncExternalStore(registry.subscribe, registry.list, registry.list) } /** The chord typed so far (`"g"`), or null. For a "g …" hint in a status bar. */ export function usePendingChord(): string | null { const registry = useHotkeys() return useSyncExternalStore(registry.subscribe, registry.pending, registry.pending) } export interface HotkeyScopeProps extends Omit<ComponentProps<"div">, "ref"> { scope: HotkeyScopeName } /** * Marks a subtree as a scope. Keys pressed with focus inside it reach this scope's bindings first. * It takes focus on click (`tabIndex={-1}`) so that clicking a panel is enough to aim the keyboard at it. */ export function HotkeyScope({ scope, children, ...props }: HotkeyScopeProps) { const [element, setElement] = useState<HTMLDivElement | null>(null) const value = useMemo<HandlerScope>(() => ({ scope, element: () => element }), [scope, element]) return ( <ScopeContext.Provider value={value}> <div tabIndex={-1} {...props} ref={setElement} data-hotkey-scope={scope}> {children} </div> </ScopeContext.Provider> ) }lib/hotkeys.ts // A hotkey registry: bindings declared as data, one keydown listener, scopes read from the DOM. // // A binding is an id, keys, a scope, and a description. It is declared once, so the same list feeds // the dispatcher, a command palette's shortcut column, and a help overlay, and a remap changes all // three. Handlers attach separately and come and go with the components that own them, which is how // two instances of one panel share a binding: the handler whose scope element holds the event wins. // // Scope is where the key came from. The chain runs from the event target outward through every // `[data-hotkey-scope]` ancestor, then `editing`, then `global`, and the innermost match wins. // Typing is protected: inside an input only `editing` bindings run. Menus and listboxes own every // key while they have focus. A dialog is a wall: only scopes declared inside it are active, so a // single-key binding cannot fire under a modal confirmation. export type Platform = "mac" | "other" /** `global` runs anywhere outside inputs. `editing` runs anywhere, inputs included. `panel:<id>` runs inside `[data-hotkey-scope="panel:<id>"]`. */ export type HotkeyScopeName = "global" | "editing" | `panel:${string}` export type HotkeyHandler = (event: KeyboardEvent) => void export interface HotkeyBinding { id: string /** `"mod+k"`, `"shift+/"`, `"?"`, or a chord of steps separated by spaces, `"g h"`. `mod` is ⌘ on a Mac and Ctrl elsewhere. `""` is unbound. */ keys: string scope: HotkeyScopeName description: string /** A heading for overlays and palette rows. */ group?: string /** Checked at keydown. False skips this binding and lets an outer scope answer. */ when?: () => boolean /** Fire again while the key is held. Off by default: a held key should not cancel thirty orders. */ repeat?: boolean /** On by default. */ preventDefault?: boolean } export interface HotkeyEntry extends HotkeyBinding { /** The keys in force: the override when there is one, else the binding's own. */ keys: string defaultKeys: string remapped: boolean } export interface HotkeyConflict { /** `duplicate`: same keys where both can fire. `prefix`: one is the start of the other's chord. `shadow`: a panel binding hides a global or editing one while focus is in that panel. */ kind: "duplicate" | "prefix" | "shadow" /** The contested keys, normalized; for a prefix, the shorter of the two. */ keys: string ids: [string, string] } /** Binding id to keys. What a consumer persists. */ export type HotkeyOverrides = Record<string, string> export type HotkeyTarget = Pick<EventTarget, "addEventListener" | "removeEventListener"> /** Limits a handler to events from inside one scope element, when the binding lives in that scope. */ export interface HandlerScope { scope: string element: () => Element | null } export interface HotkeyRegistryOptions { /** How long a chord waits for its next key. Default 1000. */ chordTimeoutMs?: number platform?: Platform } export interface HotkeyRegistry { readonly platform: Platform /** Declare a binding, or replace the one with the same id. Returns the conflicts it takes part in. */ register(binding: HotkeyBinding, handler?: HotkeyHandler): HotkeyConflict[] unregister(id: string): void /** Attach a handler to a declared (or not yet declared) binding. Returns the detach. A fenced handler can settle a conflict, so attaching and detaching wake `subscribe` as a declaration does. */ bind(id: string, handler: HotkeyHandler, within?: HandlerScope | null): () => void /** Override a binding's keys; `""` unbinds it. Notifies `onChange`. Returns the conflicts the new keys take part in. */ remap(id: string, keys: string): HotkeyConflict[] /** Drop one override, or all of them. Notifies `onChange`. */ reset(id?: string): void /** Replace every override, from the consumer's persistence. Ids not declared yet apply when they are. Does not notify `onChange`. */ load(overrides: HotkeyOverrides): void overrides(): HotkeyOverrides /** Overrides changed through `remap` or `reset`: the moment to persist. */ onChange(cb: (overrides: HotkeyOverrides) => void): () => void /** Every binding with its keys in force. Stable array reference until a binding, an override, or a handler changes. */ list(): readonly HotkeyEntry[] /** Every conflict the declarations leave standing once their handlers are read, against the document as it is now. A fresh array each call. */ conflicts(): HotkeyConflict[] /** Wakes on any change to `list()` or `pending()`, and when a handler comes or goes. */ subscribe(cb: () => void): () => void /** Listen for keydown on a target, `document` by default. Attaching the same target twice adds one listener. */ attach(target?: HotkeyTarget): () => void /** The chord typed so far, normalized (`"g"`), or null. */ pending(): string | null /** Run one event through the dispatcher. `attach` calls this; it is public for events forwarded from elsewhere. True when the event was consumed. */ handle(event: KeyboardEvent): boolean } interface Step { ctrl: boolean alt: boolean shift: boolean meta: boolean key: string } const KEY_ALIASES: Record<string, string> = { " ": "space", spacebar: "space", esc: "escape", return: "enter", del: "delete", ins: "insert", arrowup: "up", arrowdown: "down", arrowleft: "left", arrowright: "right", pgup: "pageup", pgdn: "pagedown", plus: "+", } const CODE_KEYS: Record<string, string> = { Slash: "/", Backslash: "\\", Period: ".", Comma: ",", Semicolon: ";", Quote: "'", BracketLeft: "[", BracketRight: "]", Minus: "-", Equal: "=", Backquote: "`", } const IGNORED_KEYS = new Set(["Shift", "Control", "Alt", "Meta", "AltGraph", "CapsLock", "OS", "Dead", "Process", "Unidentified"]) const NON_TEXT_INPUTS = new Set(["button", "checkbox", "color", "file", "image", "radio", "range", "reset", "submit"]) const SCOPE_ATTR = "data-hotkey-scope" const WALLS = '[role="dialog"], [role="alertdialog"]' const MAC_LABELS = { ctrl: "⌃", alt: "⌥", shift: "⇧", meta: "⌘" } const OTHER_LABELS = { ctrl: "Ctrl", alt: "Alt", shift: "Shift", meta: "Win" } const KEY_LABELS: Record<string, string> = { enter: "↵", escape: "Esc", space: "Space", tab: "Tab", backspace: "⌫", delete: "Del", insert: "Ins", up: "↑", down: "↓", left: "←", right: "→", pageup: "PgUp", pagedown: "PgDn", home: "Home", end: "End", } export function detectPlatform(): Platform { if (typeof navigator === "undefined") return "other" const nav = navigator as Navigator & { userAgentData?: { platform?: string } } return /mac|iphone|ipad|ipod/i.test(nav.userAgentData?.platform ?? nav.platform ?? "") ? "mac" : "other" } function parseStep(part: string, platform: Platform): Step { const step: Step = { ctrl: false, alt: false, shift: false, meta: false, key: "" } for (const token of part.toLowerCase().split("+")) { if (token === "mod") step[platform === "mac" ? "meta" : "ctrl"] = true else if (token === "ctrl" || token === "control") step.ctrl = true else if (token === "alt" || token === "option" || token === "opt") step.alt = true else if (token === "shift") step.shift = true else if (token === "meta" || token === "cmd" || token === "command" || token === "win" || token === "super") step.meta = true else if (step.key) throw new Error(`hotkeys: "${part}" names two keys; separate the steps of a chord with a space`) else step.key = KEY_ALIASES[token] ?? token } if (!step.key) throw new Error(`hotkeys: "${part}" has no key (write the + key as "plus")`) return step } function parseKeys(keys: string, platform: Platform): Step[] { return keys.trim().split(/\s+/).filter(Boolean).map((part) => parseStep(part, platform)) } function stepToString(step: Step): string { return [step.ctrl && "ctrl", step.alt && "alt", step.shift && "shift", step.meta && "meta", step.key].filter(Boolean).join("+") } /** Canonical form for comparing and storing: `mod` resolved, aliases folded, modifiers in one order. Throws on keys that do not parse. */ export function normalizeKeys(keys: string, platform: Platform = detectPlatform()): string { return parseKeys(keys, platform).map(stepToString).join(" ") } /** Key caps for shadcn's `Kbd`, one array per chord step: `"mod+k"` is `[["⌘", "K"]]` on a Mac and `[["Ctrl", "K"]]` elsewhere. */ export function formatKeys(keys: string, platform: Platform = detectPlatform()): string[][] { const labels = platform === "mac" ? MAC_LABELS : OTHER_LABELS return parseKeys(keys, platform).map((step) => { const caps: string[] = [] if (step.ctrl) caps.push(labels.ctrl) if (step.alt) caps.push(labels.alt) if (step.shift) caps.push(labels.shift) if (step.meta) caps.push(labels.meta) caps.push(KEY_LABELS[step.key] ?? (/^(?:[a-z]|f\d{1,2})$/.test(step.key) ? step.key.toUpperCase() : step.key)) return caps }) } const isSymbol = (key: string) => key.length === 1 && !/[a-z0-9]/.test(key) function codeKey(code: string): string | null { if (/^Key[A-Z]$/.test(code)) return code.slice(3).toLowerCase() if (/^Digit\d$/.test(code)) return code.slice(5) return CODE_KEYS[code] ?? null } function matchesStep(step: Step, event: KeyboardEvent): boolean { if (event.ctrlKey !== step.ctrl || event.altKey !== step.alt || event.metaKey !== step.meta) return false const pressed = event.key.toLowerCase() const key = KEY_ALIASES[pressed] ?? pressed // "?" already says shift on the layouts that need it and nothing on the ones that do not. if (key === step.key) return isSymbol(key) && !step.shift ? true : event.shiftKey === step.shift // A modifier changed the character: shift+1 arrives as "!", option+k as "˚". Read the physical key, // unless the modifier is AltGr, which is how half of Europe types "@". if ((event.shiftKey || event.altKey) && isSymbol(key) && !event.getModifierState?.("AltGraph")) return event.shiftKey === step.shift && codeKey(event.code) === step.key return false } /** True for a single-step `keys` this event satisfies. For a component that must answer a binding itself, behind a wall the dispatcher will not cross. */ export function matchesKeys(event: KeyboardEvent, keys: string, platform: Platform = detectPlatform()): boolean { const steps = parseKeys(keys, platform) return steps.length === 1 && matchesStep(steps[0]!, event) } /** The step a keydown spells, in the form `remap` takes, or null for a bare modifier. For a "press the new shortcut" field. */ export function keysFromEvent(event: KeyboardEvent): string | null { if (IGNORED_KEYS.has(event.key)) return null const pressed = event.key.toLowerCase() let key = KEY_ALIASES[pressed] ?? pressed let shift = event.shiftKey if (isSymbol(key)) { // option+k arrives as "˚": store the physical key. A shifted symbol such as "?" already carries its shift. const physical = event.altKey && !event.getModifierState?.("AltGraph") ? codeKey(event.code) : null if (physical) key = physical else shift = false } return stepToString({ ctrl: event.ctrlKey, alt: event.altKey, shift, meta: event.metaKey, key }) } // Duck-typed so a target from a popout window's realm still counts. function asElement(target: EventTarget | null): Element | null { const el = target as Element | null return el && typeof el.closest === "function" ? el : null } /** Text inputs, textareas, selects, and anything contenteditable. A checkbox is not typing. */ export function isEditableTarget(target: EventTarget | null): boolean { const el = asElement(target) if (!el) return false if (el.tagName === "INPUT") return !NON_TEXT_INPUTS.has((el.getAttribute("type") ?? "text").toLowerCase()) if (el.tagName === "TEXTAREA" || el.tagName === "SELECT") return true return el.closest('[contenteditable]:not([contenteditable="false"]), [role="textbox"]') !== null } /** Inside a menu, menubar, or listbox, which own arrows, letters, Enter, and Escape while focused. */ export function isMenuTarget(target: EventTarget | null): boolean { return asElement(target)?.closest('[role="menu"], [role="menubar"], [role="listbox"]') != null } /** The scopes active for an event target, innermost first. Inside a dialog only the scopes declared within it count. */ export function scopeChain(target: EventTarget | null): string[] { const chain: string[] = [] let el = asElement(target) const wall = el?.closest(WALLS) ?? null while (el) { const hit = el.closest(`[${SCOPE_ATTR}]`) if (!hit || (wall && !wall.contains(hit))) break const name = hit.getAttribute(SCOPE_ATTR) if (name && !chain.includes(name)) chain.push(name) el = hit.parentElement } if (!wall) for (const name of ["editing", "global"]) if (!chain.includes(name)) chain.push(name) return chain } interface Rec { binding: HotkeyBinding steps: Step[] sequence: string[] handler?: HotkeyHandler } interface Bound { handler: HotkeyHandler within: HandlerScope | null } const isAmbient = (scope: string) => scope === "global" || scope === "editing" function sameBinding(a: HotkeyBinding, b: HotkeyBinding): boolean { return a.keys === b.keys && a.scope === b.scope && a.description === b.description && a.group === b.group && a.when === b.when && a.repeat === b.repeat && a.preventDefault === b.preventDefault } function conflictBetween(a: Rec, b: Rec): HotkeyConflict | null { if (!a.sequence.length || !b.sequence.length) return null const [short, long] = a.sequence.length <= b.sequence.length ? [a, b] : [b, a] if (!short.sequence.every((step, i) => long.sequence[i] === step)) return null const sa = a.binding.scope const sb = b.binding.scope const together = sa === sb || (isAmbient(sa) && isAmbient(sb)) if (!together && !isAmbient(sa) && !isAmbient(sb)) return null const kind = !together ? "shadow" : short.sequence.length === long.sequence.length ? "duplicate" : "prefix" return { kind, keys: short.sequence.join(" "), ids: [a.binding.id, b.binding.id] } } export function createHotkeyRegistry(options: HotkeyRegistryOptions = {}): HotkeyRegistry { const platform = options.platform ?? detectPlatform() const chordTimeoutMs = options.chordTimeoutMs ?? 1000 const recs = new Map<string, Rec>() const overrides = new Map<string, string>() const bound = new Map<string, Bound[]>() const listeners = new Set<() => void>() const changeListeners = new Set<(overrides: HotkeyOverrides) => void>() const attached = new Map<HotkeyTarget, { count: number; listener: (event: Event) => void }>() let snapshot: readonly HotkeyEntry[] | null = null // Chord state: how many steps of which bindings have matched so far. let progress = new Map<string, number>() let pendingSteps: string[] = [] let timer: ReturnType<typeof setTimeout> | null = null function emit() { snapshot = null for (const cb of listeners) cb() } function notifyChange() { const current = Object.fromEntries(overrides) for (const cb of changeListeners) cb(current) } function clearPending(): boolean { if (timer) clearTimeout(timer) timer = null if (!pendingSteps.length) return false progress = new Map() pendingSteps = [] return true } function build(binding: HotkeyBinding, handler?: HotkeyHandler): Rec { const defaults = parseKeys(binding.keys, platform) let steps = defaults const override = overrides.get(binding.id) if (override !== undefined) { // A stale override from storage must not take the app down: fall back to the default. try { steps = parseKeys(override, platform) } catch { steps = defaults } } return { binding, steps, sequence: steps.map(stepToString), handler } } function rebuild(id?: string) { for (const [key, rec] of recs) if (id === undefined || key === id) recs.set(key, build(rec.binding, rec.handler)) clearPending() emit() } // The elements a binding's handlers are fenced to, in the binding's own scope; null when any handler runs // unfenced, empty when nothing is bound. Mirrors what handlerFor lets through. function fencesOf(rec: Rec): (Element | null)[] | null { if (rec.handler) return null const out: (Element | null)[] = [] for (const { within } of bound.get(rec.binding.id) ?? []) { if (!within || within.scope !== rec.binding.scope) return null out.push(within.element()) } return out } // Two bindings whose every handler is fenced to boxes that do not hold one another cannot meet on one key, // which is how an order ticket and an RFQ ticket on one desk share mod+enter. Declarations with no handler, // or a handler that runs anywhere, are compared as declared. function apart(a: Rec, b: Rec): boolean { const fa = fencesOf(a) const fb = fencesOf(b) if (!fa?.length || !fb?.length) return false for (const ea of fa) for (const eb of fb) if (ea && eb && (ea === eb || ea.contains(eb) || eb.contains(ea))) return false return true } function conflictsFor(id: string): HotkeyConflict[] { const rec = recs.get(id) if (!rec) return [] const out: HotkeyConflict[] = [] for (const other of recs.values()) { const c = other === rec ? null : conflictBetween(rec, other) if (c && !apart(rec, other)) out.push(c) } return out } function handlerFor(rec: Rec, el: Element | null): HotkeyHandler | null { const list = bound.get(rec.binding.id) ?? [] for (let i = list.length - 1; i >= 0; i--) { const { handler, within } = list[i]! if (!within || within.scope !== rec.binding.scope) return handler const scopeEl = within.element() if (el && scopeEl?.contains(el)) return handler } return rec.handler ?? null } function attempt(event: KeyboardEvent, chain: string[], editable: boolean, el: Element | null): boolean { let complete: { rank: number; rec: Rec; handler: HotkeyHandler } | null = null let advanceRank = Infinity let advanceStep = "" const advanced = new Map<string, number>() for (const rec of recs.values()) { const at = pendingSteps.length ? progress.get(rec.binding.id) : 0 const step = at === undefined ? undefined : rec.steps[at] if (at === undefined || !step || !matchesStep(step, event)) continue const rank = chain.indexOf(rec.binding.scope) if (rank < 0 || (editable && rec.binding.scope !== "editing")) continue if (event.repeat && !rec.binding.repeat) continue if (rec.binding.when && !rec.binding.when()) continue const handler = handlerFor(rec, el) if (!handler) continue if (at === rec.steps.length - 1) { if (!complete || rank < complete.rank) complete = { rank, rec, handler } } else { advanced.set(rec.binding.id, at + 1) if (rank < advanceRank) { advanceRank = rank advanceStep = stepToString(step) } } } // The innermost scope decides. A chord that started closer to the target outranks a finished binding further out. if (complete && complete.rank <= advanceRank) { if (clearPending()) emit() if (complete.rec.binding.preventDefault !== false) event.preventDefault() complete.handler(event) return true } if (advanced.size) { if (timer) clearTimeout(timer) progress = advanced pendingSteps = [...pendingSteps, advanceStep] timer = setTimeout(() => { timer = null if (clearPending()) emit() }, chordTimeoutMs) event.preventDefault() emit() return true } return false } function handle(event: KeyboardEvent): boolean { if (event.defaultPrevented || event.isComposing || IGNORED_KEYS.has(event.key)) return false const target = event.target if (isMenuTarget(target)) { if (clearPending()) emit() return false } if (pendingSteps.length && event.key === "Escape") { clearPending() event.preventDefault() emit() return true } const chain = scopeChain(target) const editable = isEditableTarget(target) const el = asElement(target) if (attempt(event, chain, editable, el)) return true // Not a continuation of the chord in flight: drop the chord and read the key on its own. if (clearPending()) { emit() return attempt(event, chain, editable, el) } return false } return { platform, register(binding, handler) { if (!binding.id) throw new Error("hotkeys: a binding needs an id") const previous = recs.get(binding.id) // Declaring the same thing again (a remount, a hot reload) is not a change. if (previous && !handler && sameBinding(previous.binding, binding)) return conflictsFor(binding.id) recs.set(binding.id, build(binding, handler ?? previous?.handler)) clearPending() emit() return conflictsFor(binding.id) }, unregister(id) { if (!recs.delete(id)) return clearPending() emit() }, bind(id, handler, within = null) { const entry: Bound = { handler, within } const list = bound.get(id) if (list) list.push(entry) else bound.set(id, [entry]) // A fenced handler can settle a conflict, so a settings screen wants to hear about it. emit() return () => { const current = bound.get(id) if (!current) return const i = current.indexOf(entry) if (i < 0) return current.splice(i, 1) if (!current.length) bound.delete(id) emit() } }, remap(id, keys) { const rec = recs.get(id) if (!rec) throw new Error(`hotkeys: cannot remap "${id}", no such binding`) const next = normalizeKeys(keys, platform) if (next === normalizeKeys(rec.binding.keys, platform)) overrides.delete(id) else overrides.set(id, next) rebuild(id) notifyChange() return conflictsFor(id) }, reset(id) { if (id === undefined) overrides.clear() else overrides.delete(id) rebuild(id) notifyChange() }, load(next) { overrides.clear() for (const [id, keys] of Object.entries(next)) overrides.set(id, keys) rebuild() }, overrides: () => Object.fromEntries(overrides), onChange(cb) { changeListeners.add(cb) return () => changeListeners.delete(cb) }, list() { return (snapshot ??= [...recs.values()].map((rec) => { const keys = rec.sequence.join(" ") const defaultKeys = normalizeKeys(rec.binding.keys, platform) return { ...rec.binding, keys, defaultKeys, remapped: keys !== defaultKeys } })) }, conflicts() { const all = [...recs.values()] const out: HotkeyConflict[] = [] for (let i = 0; i < all.length; i++) { for (let j = i + 1; j < all.length; j++) { const c = conflictBetween(all[i]!, all[j]!) if (c && !apart(all[i]!, all[j]!)) out.push(c) } } return out }, subscribe(cb) { listeners.add(cb) return () => listeners.delete(cb) }, attach(target) { const t = target ?? (typeof document === "undefined" ? null : document) if (!t) return () => {} let entry = attached.get(t) if (!entry) { entry = { count: 0, listener: (event) => void handle(event as KeyboardEvent) } t.addEventListener("keydown", entry.listener) attached.set(t, entry) } entry.count++ let done = false return () => { if (done) return done = true entry.count-- if (entry.count > 0) return t.removeEventListener("keydown", entry.listener) attached.delete(t) } }, pending: () => (pendingSteps.length ? pendingSteps.join(" ") : null), handle, } }
Usage
import { useState } from "react"
import { HotkeysProvider, useHotkey } from "@/hooks/use-hotkeys"
import type { HotkeyBinding } from "@/lib/hotkeys"
const BINDINGS: HotkeyBinding[] = [
{ id: "counter.increment", keys: "i", scope: "global", description: "Increment counter" },
]
function Counter() {
const [count, setCount] = useState(0)
const increment = () => setCount((value) => value + 1)
useHotkey("counter.increment", increment)
return (
<div className="w-fit max-w-full space-y-3 text-sm">
<button type="button" className="rounded border border-border px-3 py-2 hover:bg-muted" onClick={increment}>Increment</button>
<p role="status" className="lining-nums tabular-nums">Count: {count}</p>
</div>
)
}
export default function UseHotkeysDemo() {
return <HotkeysProvider bindings={BINDINGS}><Counter /></HotkeysProvider>
}
Click Increment, or focus it with Tab and press i. A binding declares the key, scope, and description; useHotkey supplies its handler beneath HotkeysProvider. Keep the binding array stable. This global shortcut stays out of text fields.
The previews have their own documents. Focus a control inside a preview before trying its keys.
Scoped shortcuts
Click Refresh in one book, then press r. Only that book's counter changes. Tab to the other book's button to move the shortcut with focus. repeat: true lets a held r keep refreshing; bindings ignore repeated keydowns by default.
Both books share one declaration. Each Book calls useHotkey beneath its own HotkeyScope, so the handler belongs to that instance. Calling the hook in a component that returns the scope would register it outside that scope. The counters stand in for refresh requests.
import { useState } from "react"
import { HotkeyScope, HotkeysProvider, useHotkey } from "@/hooks/use-hotkeys"
import type { HotkeyBinding } from "@/lib/hotkeys"
const BINDINGS: HotkeyBinding[] = [
{ id: "book.refresh", keys: "r", scope: "panel:book", description: "Refresh book", repeat: true },
]
function Book({ name }: { name: string }) {
const [requests, setRequests] = useState(0)
const refresh = () => setRequests((count) => count + 1)
useHotkey("book.refresh", refresh)
return (
<div className="space-y-3">
<h3 className="font-semibold">{name}</h3>
<button type="button" aria-label={`Refresh ${name}`} className="rounded border border-border px-2 py-1 hover:bg-muted" onClick={refresh}>Refresh</button>
<p role="status" className="lining-nums tabular-nums">Refresh requests: {requests}</p>
</div>
)
}
export default function UseHotkeysScopesDemo() {
return (
<HotkeysProvider bindings={BINDINGS}>
<div className="flex w-fit max-w-full flex-wrap justify-center gap-3 text-sm">
<HotkeyScope scope="panel:book" role="region" aria-label="Book A" className="w-60 max-w-full rounded border border-border p-3 outline-none focus-within:border-ring"><Book name="Book A" /></HotkeyScope>
<HotkeyScope scope="panel:book" role="region" aria-label="Book B" className="w-60 max-w-full rounded border border-border p-3 outline-none focus-within:border-ring"><Book name="Book B" /></HotkeyScope>
</div>
</HotkeysProvider>
)
}
While typing
Type r in the Note field: it stays in the text, and the global refresh count stays unchanged. Press mod+enter there to request a save: ⌘+Enter on a Mac, Ctrl+Enter elsewhere. Move focus to Refresh and r works again.
The save binding uses the editing scope, which runs both inside and outside text fields. Use it for shortcuts safe while typing; keep bare letters out of it. These handlers count requests; they do not persist the note.
import { useState } from "react"
import { HotkeysProvider, useHotkey } from "@/hooks/use-hotkeys"
import type { HotkeyBinding } from "@/lib/hotkeys"
const BINDINGS: HotkeyBinding[] = [
{ id: "app.refresh", keys: "r", scope: "global", description: "Refresh" },
{ id: "note.save", keys: "mod+enter", scope: "editing", description: "Save note" },
]
function Note() {
const [refreshes, setRefreshes] = useState(0)
const [saves, setSaves] = useState(0)
const refresh = () => setRefreshes((count) => count + 1)
const save = () => setSaves((count) => count + 1)
useHotkey("app.refresh", refresh)
useHotkey("note.save", save)
return (
<div className="w-80 max-w-full space-y-3 text-sm">
<label className="flex flex-col gap-2">Note<textarea className="h-20 w-full resize-none rounded border border-input bg-background px-2 py-1" placeholder="Type r here" /></label>
<div className="flex flex-wrap gap-2">
<button type="button" className="rounded border border-border px-2 py-1 hover:bg-muted" onClick={refresh}>Refresh</button>
<button type="button" className="rounded border border-border px-2 py-1 hover:bg-muted" onClick={save}>Request save</button>
</div>
<p role="status" className="lining-nums tabular-nums">Refresh requests: {refreshes}. Save requests: {saves}.</p>
</div>
)
}
export default function UseHotkeysEditingDemo() {
return <HotkeysProvider bindings={BINDINGS}><Note /></HotkeysProvider>
}
Chord sequences
Focus either button, then press g followed by b for the blotter or w for the watchlist. usePendingChord shows the prefix while it waits. The default timeout is one second between steps; Escape cancels, and an unrelated key clears the prefix and is tried on its own.
The buttons provide the same actions without a shortcut. This example changes a view label; replace the handlers with your application's navigation.
import { useState } from "react"
import { HotkeysProvider, useHotkey, usePendingChord } from "@/hooks/use-hotkeys"
import type { HotkeyBinding } from "@/lib/hotkeys"
const BINDINGS: HotkeyBinding[] = [
{ id: "go.blotter", keys: "g b", scope: "global", description: "Show blotter" },
{ id: "go.watchlist", keys: "g w", scope: "global", description: "Show watchlist" },
]
function Views() {
const [view, setView] = useState("Home")
const pending = usePendingChord()
useHotkey("go.blotter", () => setView("Blotter"))
useHotkey("go.watchlist", () => setView("Watchlist"))
return (
<div className="w-fit max-w-full space-y-3 text-sm">
<div className="flex flex-wrap gap-2">
<button type="button" className="rounded border border-border px-2 py-1 hover:bg-muted" onClick={() => setView("Blotter")}>Show blotter</button>
<button type="button" className="rounded border border-border px-2 py-1 hover:bg-muted" onClick={() => setView("Watchlist")}>Show watchlist</button>
</div>
<p role="status">{pending ? `Chord: ${pending} …` : `View: ${view}`}</p>
</div>
)
}
export default function UseHotkeysChordsDemo() {
return <HotkeysProvider bindings={BINDINGS}><Views /></HotkeysProvider>
}
Live shortcut list
Choose U, then focus Increment and press u. The old i binding no longer runs. Unbound disables the shortcut while leaving the button usable; Reset shortcut restores I without resetting the counter.
useHotkeyList supplies the current keys, and formatKeys turns them into display labels. Remapping changes the existing declaration, so the display and handler stay in sync. This preview keeps overrides in memory; Remapping covers persistence and the settings editor.
import { useState } from "react"
import { HotkeysProvider, useHotkey, useHotkeyList, useHotkeys } from "@/hooks/use-hotkeys"
import { formatKeys, type HotkeyBinding } from "@/lib/hotkeys"
const BINDINGS: HotkeyBinding[] = [
{ id: "counter.increment", keys: "i", scope: "global", description: "Increment counter" },
]
function Counter() {
const [count, setCount] = useState(0)
const hotkeys = useHotkeys()
const list = useHotkeyList()
const increment = () => setCount((value) => value + 1)
useHotkey("counter.increment", increment)
return (
<div className="w-72 max-w-full space-y-3 text-sm">
{list.map((entry) => (
<div key={entry.id} className="space-y-2">
<label className="flex flex-wrap items-center gap-2">
{entry.description} shortcut
<select className="rounded border border-border bg-background px-2 py-1" value={entry.keys} onChange={(event) => hotkeys.remap(entry.id, event.target.value)}>
<option value="i">I</option>
<option value="u">U</option>
<option value="">Unbound</option>
</select>
</label>
<p>Current shortcut: {entry.keys ? formatKeys(entry.keys).map((step) => step.join(" + ")).join(" then ") : "Unbound"}</p>
<button type="button" aria-label={`Reset shortcut for ${entry.description.toLowerCase()}`} className="rounded border border-border px-2 py-1 hover:bg-muted disabled:opacity-50" disabled={!entry.remapped} onClick={() => hotkeys.reset(entry.id)}>Reset shortcut</button>
</div>
))}
<button type="button" className="rounded border border-border px-2 py-1 hover:bg-muted" onClick={increment}>Increment</button>
<p role="status" className="lining-nums tabular-nums">Count: {count}</p>
</div>
)
}
export default function UseHotkeysRemappingDemo() {
return <HotkeysProvider bindings={BINDINGS}><Counter /></HotkeysProvider>
}
API Reference
Bindings are data; handlers attach separately. The same list can feed the dispatcher, a command palette's shortcut column, and a help overlay, keeping them in sync after a remap.
Bindings
HotkeyBinding field |
Type | Default | Purpose |
|---|---|---|---|
id |
string |
Required | Nonempty identifier; registering it again replaces the binding. |
keys |
string |
Required | Shortcut or chord; "" leaves it unbound. |
scope |
HotkeyScopeName |
Required | "global", "editing", or "panel:<id>". |
description |
string |
Required | Text for help and settings. |
group |
string |
Unset | Grouping metadata for help and settings displays. |
when |
() => boolean |
No condition | Checked at keydown; false skips this binding. |
repeat |
boolean |
false |
Accept repeated keydowns while a key is held. |
preventDefault |
boolean |
true |
Prevent the browser's default when the handler runs. Chord prefixes always prevent it. |
Join modifiers and one key with +; separate chord steps with spaces: "mod+k", "shift+/", "?", "alt+up", "g b". mod is ⌘ on a Mac and Ctrl elsewhere. Use plus for the plus key. "?" and "shift+/" both match a shifted question mark; "alt+k" matches Option+K even when it produces ˚.
React API
HotkeysProvider supplies the registry and attaches its listener. Keep bindings stable, such as a module constant: changing the array removes the previous declarations and registers the new ones. Unmounting removes those declarations and detaches the listener.
| Provider prop | Type | Default | Purpose |
|---|---|---|---|
registry |
HotkeyRegistry |
Created internally | Supply a registry configured or loaded before rendering. |
bindings |
readonly HotkeyBinding[] |
Unset | Declare these bindings while mounted. |
target |
HotkeyTarget |
document |
Event target to listen on, such as a popout's document. |
children |
ReactNode |
Unset | Content that uses the registry. |
| Hook | Result or behavior |
|---|---|
useHotkeys() |
Nearest provider's HotkeyRegistry; throws without a provider. |
useMaybeHotkeys() |
Same registry, or null without a provider. |
useHotkey(id: string, handler: HotkeyHandler, options?) |
Attach the latest (event: KeyboardEvent) => void handler while mounted. options.enabled is a boolean, default true; false detaches the handler but keeps the binding listed. |
useHotkeyList() |
Live readonly HotkeyEntry[], including normalized keys, normalized defaultKeys, and remapped when they differ. |
usePendingChord() |
Normalized steps typed so far, or null, for a status-bar hint. |
All hooks except useMaybeHotkeys require a provider.
Scopes
The dispatcher reads [data-hotkey-scope] ancestors from the event target outward, then adds editing and global outside dialogs. The innermost eligible binding wins. A false when() or missing handler lets another binding answer.
| Event context | Eligible scopes |
|---|---|
| Ordinary content | Enclosing scopes, then editing and global. |
Text input, textarea, select, contenteditable, or role="textbox" |
Only editing. Non-text inputs such as checkboxes do not count as typing. |
Inside role="menu", menubar, or listbox |
None; the widget owns its keys. |
Inside role="dialog" or alertdialog |
Only scopes declared on or within the nearest dialog, still subject to the input and menu rules. |
Use editing for shortcuts safe while typing, such as mod+k and mod+enter; avoid bare letters. A ticket inside a dialog can declare <HotkeyScope scope="editing"> to keep its shortcuts available.
HotkeyScope requires scope: HotkeyScopeName and accepts div props except ref, including children, className, and style. It marks the div with data-hotkey-scope and defaults tabIndex to -1, making the panel surface focusable on click.
For two instances of one panel, put each handler beneath its own HotkeyScope. If the nearest scope matches the binding's scope, useHotkey answers only for events inside that element. Without a matching nearest scope, the handler has no element restriction. The most recently attached eligible handler wins; a handler supplied to register is the fallback.
Chords
"g b" waits 1,000 ms for b. Set chordTimeoutMs when creating the registry to change the wait between steps. A chord starting in a nearer scope outranks a completed binding farther out; at the same scope, the completed binding wins immediately.
Already-prevented events, IME composition, and ignored keys such as bare modifiers leave a pending chord untouched. Other keydowns from a menu clear it without consuming the key. Escape elsewhere cancels it and is consumed.
A key that does not continue the chord clears it and is tried on its own, so g then mod+k can still open the palette. An unmatched repeated keydown can also clear the chord. Scope and handler eligibility are checked again for each step.
Conflicts
register and remap return the changed binding's conflicts. conflicts() returns all of them. Each result has kind, the normalized contested keys (the shorter sequence for a prefix), and the two binding ids.
| Kind | Reported overlap |
|---|---|
duplicate |
Same keys in the same scope, or between global and editing. |
prefix |
One sequence starts another in the same scope, or between global and editing. |
shadow |
A panel and a global or editing binding share keys or a chord prefix. |
Handlers can settle a duplicate. While every handler on both sides is fenced to elements that do not hold one another, the pair is not reported: an order ticket and an RFQ ticket on one desk share mod+enter with no line under either. A handler is fenced when it is bound with within in the binding's own scope, which useHotkey does inside a HotkeyScope of that scope. A binding with no handler yet, or with a handler that runs anywhere, is compared by its declaration alone, and the report returns when such a handler binds. conflicts() reads the document as it is when asked, and subscribe wakes when a handler comes or goes, as it does for a declaration.
Beyond that, these reports compare declarations, regardless of when(). They do not reject a binding or guarantee which one will run. A shadow may be intentional. Unbound bindings and pairs with different panel scope names are excluded, even when those panels are nested.
Remapping
Persistence is yours. onChange receives the full override map after remap or reset; load restores it without calling onChange. Overrides can load before or after their bindings. An override that fails to parse falls back to the binding's default keys.
For the plus key, strings such as "ctrl++" returned by normalization or capture cannot be parsed again. If a binding defaults to "x", remap(id, "ctrl+plus") stores that invalid form and falls back to "x".
For persistence, create a registry once, restore saved overrides with load, and pass it to HotkeysProvider. Subscribe to onChange to save the override map, for example as JSON in localStorage; unsubscribe when the owner unmounts. Handle missing or invalid stored data in the application. The live shortcut list shows remap and reset without storage.
| Registry method | Result or behavior |
|---|---|
remap(id: string, keys: string) |
Override a declared binding; "" unbinds it, and its default keys remove the override. Returns HotkeyConflict[]; throws for unknown ids or keys that do not parse. |
reset(id?: string) |
Remove one override, or all when omitted. |
load(overrides: HotkeyOverrides) |
Replace all overrides. HotkeyOverrides is Record<string, string>, keyed by binding id. |
overrides() |
Return the current override map. |
onChange(callback) |
Subscribe with (overrides: HotkeyOverrides) => void; returns an unsubscribe function. |
keysFromEvent(event: KeyboardEvent) captures one step for a shortcut field, or returns null for ignored keys such as bare modifiers. The playground includes a capture field; HotkeyEditor provides a settings screen.
The rest
createHotkeyRegistry(options?) accepts chordTimeoutMs (number, default 1000) and platform ("mac" or "other", default detectPlatform()). The selected platform is available as registry.platform.
| Registry method | Result or behavior |
|---|---|
register(binding: HotkeyBinding, handler?: HotkeyHandler) |
Declare or replace a binding; return its HotkeyConflict[]. |
unregister(id: string) |
Remove a declaration. |
bind(id: string, handler: HotkeyHandler, within?: HandlerScope | null) |
Attach a handler before or after declaration; return a detach function. within optionally supplies { scope: string, element: () => Element | null } for the element restriction described above. Attaching or detaching wakes subscribe, since a fenced handler can settle a conflict. |
list() |
Current readonly HotkeyEntry[], also read by useHotkeyList. |
conflicts() |
All reported HotkeyConflict[], read against the document as it is now. |
pending() |
Normalized pending chord, or null. |
subscribe(callback: () => void) |
Subscribe to list and pending-chord changes, and to a handler attaching or detaching; return an unsubscribe function. |
attach(target?: HotkeyTarget) |
Listen for keydown; return a detach function. HotkeyTarget supplies addEventListener and removeEventListener. Defaults to document, or does nothing when no document exists. |
handle(event: KeyboardEvent) |
Dispatch a forwarded event; return true when consumed. |
Attaching the same target more than once adds one listener; it stays until every attachment is detached. Use another document as the target to share bindings with a popout window.
| Key helper | Result |
|---|---|
normalizeKeys(keys: string, platform?) |
Resolve mod, fold aliases, and order modifiers; throw for keys that do not parse. |
formatKeys(keys: string, platform?) |
string[][], one key-cap array per chord step, suitable for shadcn's kbd. |
matchesKeys(event: KeyboardEvent, keys: string, platform?) |
Whether a single-step shortcut matches; chords return false. This helper does not apply the dispatcher's scope or event guards. |
Each optional helper platform is "mac" or "other" and defaults to detectPlatform(). For example, formatKeys("mod+shift+k") returns [["⇧", "⌘", "K"]] on a Mac and [["Ctrl", "Shift", "K"]] elsewhere. Use matchesKeys when a component handles a shortcut itself inside a protected context.
The dispatcher ignores events already prevented, IME composition, and keys such as bare modifiers. Bindings run on repeated keydowns only with repeat: true. A binding without an eligible handler consumes nothing.
What it does not do
No keyup handling or storage. Matching uses the character produced, with a physical-key fallback when Shift or Option changes it to a symbol; that fallback is disabled for AltGr. It does not infer keyboard layouts.
This page is docs/use-hotkeys.md at v1.4.13.