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

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.