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#v2.0.0
npx shadcn@latest add tradecn/ui/use-hotkeys#v2.0.0
yarn dlx shadcn@latest add tradecn/ui/use-hotkeys#v2.0.0
bunx --bun shadcn@latest add tradecn/ui/use-hotkeys#v2.0.0
-
Copy the files into your project:
hooks/use-hotkeys.tsx import { createContext, useContext, useEffect, useInsertionEffect, useLayoutEffect, 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) const EMPTY_DECLARED: ReadonlySet<string> = new Set() const EMPTY_DECLARED_MAP: ReadonlyMap<HotkeyRegistry, ReadonlySet<string>> = new Map() const DeclaredContext = createContext<ReadonlyMap<HotkeyRegistry, ReadonlySet<string>>>(EMPTY_DECLARED_MAP) 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 // Visible during render, before any effect runs: a child that supplies a default for an id // the consumer declares here must see the declaration on its first pass, not an effect later. // Keyed by registry, so a nested provider with its own registry inherits nothing it doesn't own. const parentDeclared = useContext(DeclaredContext) const declared = useMemo(() => { if (!bindings?.length) return parentDeclared const next = new Map(parentDeclared) next.set(value, new Set([...(parentDeclared.get(value) ?? EMPTY_DECLARED), ...bindings.map((binding) => binding.id)])) return next }, [parentDeclared, bindings, value]) 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}><DeclaredContext.Provider value={declared}>{children}</DeclaredContext.Provider></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) } /** * Ids the surrounding providers declare through `bindings` for one registry — the nearest * provider's unless another is given — visible during render before any effect registers them. * Returns an empty set without a provider. */ export function useDeclaredHotkeyIds(registry?: HotkeyRegistry | null): ReadonlySet<string> { const map = useContext(DeclaredContext) const nearest = useContext(RegistryContext) const key = registry ?? nearest return (key && map.get(key)) || EMPTY_DECLARED } 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) // A commit no key caused, such as a push that revokes an action, runs every layout effect before the passive ones, // and a key dispatched in between must reach the handler that commit rendered, or none. So the handler is swapped // before any layout effect runs, and the binding detaches in the commit that disables or unmounts it. useInsertionEffect(() => { latest.current = handler }) useLayoutEffect(() => { 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) } /** * The nearest HotkeyScope's element getter, or null outside one. Hand it to a scoped * palette action's `within`, so the panel that answers is the one containing the * captured focus — the scope element itself included, since clicking a panel focuses it. */ export function useHotkeyScope(): (() => Element | null) | null { const scope = useContext(ScopeContext) return scope ? scope.element : null } 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 while focus is inside it: only scopes declared // inside it are active. Keys typed in the gap before a dialog takes focus land on the page, // where single-key bindings still run — the command palette's early capture shows the fix. 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 a readable one, else the binding's own. */ keys: string defaultKeys: string /** The keys exactly as the binding declared them, before normalization: `"mod+k"` stays `"mod+k"`. */ declaredKeys: string /** True while an override is stored for the id, one that fails to parse or matches the defaults included. */ 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 /** * Declare a component's built-in binding. It lists and dispatches like a registration until a * consumer registers the id — the registration shadows it, and unregistering brings it back — * and it stands while any declarer holds it. Returns the release. */ declareDefault(binding: HotkeyBinding): () => 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: "" } // The canonical form writes the + key as itself, so "ctrl++" and a bare "+" must read back. // Only the canonical shape qualifies: a malformed "ctrl+++" still fails instead of quietly // becoming a live binding from a corrupted stored override. let rest = part let plusKey = false if (rest === "+") { plusKey = true rest = "" } else if (rest.endsWith("++") && rest.length > 2 && rest.slice(0, -2).split("+").every(Boolean)) { plusKey = true rest = rest.slice(0, -2) } for (const token of rest ? rest.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 (plusKey) { if (step.key) throw new Error(`hotkeys: "${part}" names two keys; separate the steps of a chord with a space`) step.key = "+" } 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() // A timer runs at once for 0, NaN, Infinity, or anything past 2^31 - 1 ms, and a chord could never finish: those read as the default. const given = options.chordTimeoutMs const chordTimeoutMs = typeof given === "number" && given >= 1 && given <= 2_147_483_647 ? given : 1000 const recs = new Map<string, Rec>() // Component defaults: each declaration is held individually beside the live records, installed // only while no consumer registration shadows the id — the earliest held declaration is the // one in force. Tokens, not the bindings, carry identity: two declarers may pass one shared // module constant. `defaultIds` marks the ids whose live record is the default. const defaults = new Map<string, { binding: HotkeyBinding }[]>() const defaultIds = new Set<string>() 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 declaredSteps = parseKeys(binding.keys, platform) let steps = declaredSteps 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 = declaredSteps } } 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 { // 229 is a key an input method takes, which Safari sends before isComposing says so. if (event.defaultPrevented || event.isComposing || event.keyCode === 229 || 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 — though a // consumer registering over a default claims the id either way. if (previous && !handler && sameBinding(previous.binding, binding)) { defaultIds.delete(binding.id) return conflictsFor(binding.id) } // Built before any marker moves: a throw on malformed keys must leave ownership untouched. const next = build(binding, handler ?? previous?.handler) defaultIds.delete(binding.id) recs.set(binding.id, next) clearPending() emit() return conflictsFor(binding.id) }, unregister(id) { // A default is not removable here — only shadowed or released; `remap(id, "")` unbinds // its keys. Removing-and-reinstalling would wake subscribers with an unchanged list, and // a subscriber that unregisters whatever it sees listed would recurse forever. if (!recs.has(id) || defaultIds.has(id)) return // The earliest held default resurfaces when the consumer registration leaves — replaced // in place, since the record's position carries conflict resolution and list order. const held = defaults.get(id) if (held?.[0]) { recs.set(id, build(held[0].binding)) defaultIds.add(id) } else { recs.delete(id) } clearPending() emit() }, declareDefault(binding) { if (!binding.id) throw new Error("hotkeys: a binding needs an id") // Built before the declaration is held — shadowed or not — so malformed keys throw here // and never wait inside the held list to break a later resurface. const rec = build(binding) const installing = !recs.has(binding.id) const token = { binding } const held = defaults.get(binding.id) if (held) held.push(token) else defaults.set(binding.id, [token]) if (installing) { recs.set(binding.id, rec) defaultIds.add(binding.id) clearPending() emit() } return () => { const current = defaults.get(binding.id) if (!current) return const at = current.indexOf(token) if (at < 0) return current.splice(at, 1) if (current.length === 0) defaults.delete(binding.id) if (!defaultIds.has(binding.id)) return const next = current[0] if (next === undefined) { defaultIds.delete(binding.id) recs.delete(binding.id) clearPending() emit() } else if (!sameBinding(recs.get(binding.id)!.binding, next.binding)) { // A remaining declarer's own binding takes over from the released one's. recs.set(binding.id, build(next.binding)) 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) // Remapped means an override is stored, not that the resolved keys differ: a corrupt // override falls back to the default keys, and Reset must still be able to clear it. return { ...rec.binding, keys, defaultKeys, declaredKeys: rec.binding.keys, remapped: overrides.has(rec.binding.id) } })) }, 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 overrides the keys in force for that binding id, so the display and handler stay in sync while the declaration keeps its defaults. 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. The handler changes, and false or unmounting detaches it, in the commit that renders them, so a key in that commit never reaches a handler it replaced. Content a Suspense boundary hides again detaches until it shows. |
useHotkeyList() |
Live readonly HotkeyEntry[], including normalized keys, normalized defaultKeys, the declaration's own spelling as declaredKeys, and remapped while an override is stored for the id, one that fails to parse or matches the defaults included. |
usePendingChord() |
Normalized steps typed so far, or null, for a status-bar hint. |
useDeclaredHotkeyIds(registry?) |
Ids the surrounding providers declare through bindings for one registry — the nearest provider's unless given — visible during render before any effect registers them. |
useHotkeyScope() |
The nearest HotkeyScope's element getter, or null outside one — hand it to a scoped palette action's within. |
All hooks require a provider except useMaybeHotkeys, useDeclaredHotkeyIds, which returns an empty set, and useHotkeyScope, which reads only the surrounding scope.
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, and neither does a combobox-style select trigger built from a button. |
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. The wall holds while focus is inside the dialog: keys typed in the moment before it takes focus land on the page, where single-key bindings still run, so give destructive actions a modifier or an early capture like the command palette's. |
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. Stored overrides keep the pressed modifier, so mod is saved as the platform's own key and a profile synced across platforms carries the wrong one. On a Mac, Option combinations that type dead keys — alt+e, alt+i, alt+n, alt+u on the US layout — never arrive as hotkeys.
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, as does a key an input method holds that Safari sends before isComposing is set, with key code 229. 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 conflict. 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, remap(id, "ctrl+plus") stores the canonical "ctrl++", which parses back and prints as Ctrl + (⌃ + on a Mac). Overrides saved under v1 as ctrl++ were unreadable there and fell back to the default; after upgrading they read back and take effect, so reset one that was unintended — see Updating from v1.
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 repository's playground includes a capture field; HotkeyEditor provides a settings screen.
The rest
createHotkeyRegistry(options?) accepts chordTimeoutMs (milliseconds, default 1000; anything but a number from 1 to 2,147,483,647 reads as the default) 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[]. |
declareDefault(binding: HotkeyBinding) |
Declare a component's built-in binding; return the release. It lists and dispatches like a registration until a consumer register of the id shadows it, unregister brings it back, and it stands while any declarer holds it — the earliest declaration is the one in force when declarations differ, the next is promoted when it releases, and malformed keys throw at the declaring call even while the id is shadowed. |
unregister(id: string) |
Remove a registration. A default is not removable, only shadowed or released; unbind its keys with remap(id, ""). |
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 the keys an input method holds (key code 229), and keys such as bare modifiers. Bindings run on repeated keydowns only with repeat: true. A binding without an eligible handler consumes nothing.
Render hotkey UI on the client. A registry reads its platform when it is created, and overrides load in the browser, so on a server it has the server's platform and none of the user's remaps: key caps rendered there disagree with the client's first render for Mac users and for anyone with saved remaps.
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, so on a layout whose letters aren't Latin, such as Cyrillic or Greek, mod+k and single-letter bindings don't fire, and neither does a symbol that needs AltGr, such as [ on a German layout.
This page is docs/use-hotkeys.md at v2.0.0.