HotkeyEditor
List, search, and remap the shortcuts in a hotkey registry, with grouped bindings, reset controls, and conflict messages.
import { useState } from "react"
import { HotkeysProvider } from "@/hooks/use-hotkeys"
import { type HotkeyBinding, type HotkeyOverrides } from "@/lib/hotkeys"
import { HotkeyEditor } from "@/components/ui/hotkey-editor"
const BINDINGS: HotkeyBinding[] = [
{ id: "palette.open", keys: "mod+k", scope: "editing", description: "Open the command palette", group: "General" },
{ id: "go.blotter", keys: "g b", scope: "global", description: "Go to the blotter", group: "Go" },
{ id: "go.inquiries", keys: "g i", scope: "global", description: "Go to the inquiries", group: "Go" },
{ id: "rfq.send", keys: "mod+enter", scope: "editing", description: "Send the quote", group: "Inquiry" },
{ id: "book.cancel", keys: "x", scope: "panel:book", description: "Cancel the selected order" },
]
export default function HotkeyEditorDemo() {
const [exported, setExported] = useState<HotkeyOverrides | null>(null)
return (
<HotkeysProvider bindings={BINDINGS}>
<div className="w-xl max-w-full space-y-3 text-xs lining-nums tabular-nums">
<HotkeyEditor onExport={setExported} />
{exported !== null && (
<section aria-label="Exported overrides" className="space-y-1">
<p className="text-muted-foreground">Exported overrides</p>
<pre tabIndex={0} role="region" aria-label="Exported overrides JSON" className="max-h-36 overflow-auto rounded-md border border-border bg-card p-2 font-(family-name:--tradecn-font-mono) text-xs">{JSON.stringify(exported, null, 2)}</pre>
</section>
)}
</div>
</HotkeysProvider>
)
}
Installation
pnpm dlx shadcn@latest add tradecn/ui/hotkey-editor#v1.4.13
npx shadcn@latest add tradecn/ui/hotkey-editor#v1.4.13
yarn dlx shadcn@latest add tradecn/ui/hotkey-editor#v1.4.13
bunx --bun shadcn@latest add tradecn/ui/hotkey-editor#v1.4.13
-
Install the dependencies:
pnpm add cnnpm install cnyarn add cnbun add cn -
Add the shadcn components it composes:
pnpm dlx shadcn@latest add badge button input kbdnpx shadcn@latest add badge button input kbdyarn dlx shadcn@latest add badge button input kbdbunx --bun shadcn@latest add badge button input kbd -
Copy the files into your project:
components/ui/ hotkey-editor.tsx import { cn } from "cn" import { useMemo, useState, type KeyboardEvent } from "react" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Kbd, KbdGroup } from "@/components/ui/kbd" import { useHotkeyList, useHotkeys } from "@/hooks/use-hotkeys" import { formatKeys, keysFromEvent, type HotkeyConflict, type HotkeyEntry, type HotkeyOverrides } from "@/lib/hotkeys" // The settings screen for the hotkey registry: every binding, grouped, with its keys in force, and // three ways to change one. Press the new shortcut on a row, type a chord as text, or take it back to // its default. Conflicts are said under the row they touch, in words. The registry does the work and // tells the consumer's persistence through its own onChange; this only draws the list and asks. export interface HotkeyEditorLabels { title: string search: string change: string pressKeys: string edit: string unbound: string reset: string resetAll: string export: string import: string remapped: string keysFor: string notKeys: string empty: string duplicate: string prefix: string shadow: string cancelHint: string } export const DEFAULT_HOTKEY_EDITOR_LABELS: HotkeyEditorLabels = { title: "Keyboard shortcuts", search: "Find a shortcut", change: "Change", pressKeys: "Press the new shortcut", edit: "Type it", unbound: "unbound", reset: "Reset", resetAll: "Reset all", export: "Export", import: "Import", remapped: "changed", keysFor: "Keys for", notKeys: "Not a shortcut. Write keys joined by +, steps of a chord separated by a space: mod+k, g b.", empty: "No shortcut matches.", duplicate: "same keys as", prefix: "starts the chord of", shadow: "hides, while focus is in this panel,", cancelHint: "Escape cancels, Backspace unbinds", } export interface HotkeyEditorProps { /** Show the Export button; called with the overrides as the registry holds them. */ onExport?: (overrides: HotkeyOverrides) => void /** Show the Import button; you open your picker and call `registry.load`. */ onImport?: () => void /** Hide a binding from the list. */ hide?: (entry: HotkeyEntry) => boolean labels?: Partial<HotkeyEditorLabels> className?: string } /** The word for a scope on a row: "global", "editing", or a panel's kind. */ export function scopeWord(scope: string): string { return scope.startsWith("panel:") ? scope.slice("panel:".length) : scope } /** The group a binding is listed under: its own, else its scope. */ export function groupOf(entry: HotkeyEntry): string { return entry.group ?? scopeWord(entry.scope) } /** True when the query lands in the description, the group, the id, or the keys as shown. */ export function matchesQuery(entry: HotkeyEntry, query: string, platform: "mac" | "other"): boolean { const q = query.trim().toLowerCase() if (!q) return true const caps = entry.keys ? formatKeys(entry.keys, platform).flat().join(" ").toLowerCase() : "" return [entry.description, groupOf(entry), entry.id, entry.keys, caps].some((s) => s.toLowerCase().includes(q)) } function Keys({ keys, platform, unbound }: { keys: string; platform: "mac" | "other"; unbound: string }) { if (!keys) return <span className="text-muted-foreground">{unbound}</span> const steps = formatKeys(keys, platform) return ( <span className="inline-flex items-center gap-1"> {steps.map((caps, i) => ( <KbdGroup key={i}> {caps.map((cap) => ( <Kbd key={cap}>{cap}</Kbd> ))} </KbdGroup> ))} </span> ) } interface RowProps { entry: HotkeyEntry conflicts: HotkeyConflict[] byId: Map<string, HotkeyEntry> labels: HotkeyEditorLabels } function Row({ entry, conflicts, byId, labels }: RowProps) { const registry = useHotkeys() const [mode, setMode] = useState<"idle" | "capture" | "text">("idle") const [text, setText] = useState("") const [problem, setProblem] = useState<string | null>(null) function commit(keys: string) { try { registry.remap(entry.id, keys) setProblem(null) setMode("idle") } catch { setProblem(labels.notKeys) } } // The keydown is ours: preventDefault keeps the registry's own listener out of it. function capture(event: KeyboardEvent<HTMLButtonElement>) { event.preventDefault() event.stopPropagation() if (event.key === "Escape") return setMode("idle") if (event.key === "Backspace" || event.key === "Delete") return commit("") const keys = keysFromEvent(event.nativeEvent) if (keys) commit(keys) } function typed(event: KeyboardEvent<HTMLInputElement>) { if (event.key === "Enter") { event.preventDefault() commit(text) } else if (event.key === "Escape") { event.preventDefault() setMode("idle") setProblem(null) } } const other = (c: HotkeyConflict) => byId.get(c.ids[0] === entry.id ? c.ids[1] : c.ids[0]) return ( <div className="flex flex-col gap-1 border-b border-border py-1.5 last:border-b-0" data-hotkey-row={entry.id} data-remapped={entry.remapped || undefined}> <div className="flex flex-wrap items-center gap-2"> <span className="min-w-0 flex-1"> {entry.description} {entry.remapped && ( <Badge variant="outline" className="ml-2 h-4 px-1 text-xs"> {labels.remapped} </Badge> )} </span> {mode === "capture" ? ( <Button type="button" variant="outline" size="sm" className="h-6 px-2 text-xs" autoFocus aria-pressed data-hotkey-capture onKeyDown={capture} onBlur={() => setMode("idle")}> {labels.pressKeys} <span className="sr-only">, {labels.cancelHint}</span> </Button> ) : mode === "text" ? ( <Input autoFocus value={text} aria-label={`${labels.keysFor} ${entry.description}`} aria-invalid={problem ? true : undefined} spellCheck={false} autoComplete="off" className="h-6 w-36 px-1.5 font-(family-name:--tradecn-font-mono) text-xs md:text-xs" onChange={(event) => setText(event.target.value)} onKeyDown={typed} onBlur={() => setMode("idle")} /> ) : ( <span data-hotkey-keys={entry.keys}> <Keys keys={entry.keys} platform={registry.platform} unbound={labels.unbound} /> </span> )} <span className="flex items-center gap-1"> <Button type="button" variant="ghost" size="sm" className="h-6 px-1.5 text-xs" aria-label={`${labels.change}: ${entry.description}`} disabled={mode !== "idle"} onClick={() => setMode("capture")}> {labels.change} </Button> <Button type="button" variant="ghost" size="sm" className="h-6 px-1.5 text-xs" aria-label={`${labels.edit}: ${entry.description}`} disabled={mode !== "idle"} onClick={() => { setText(entry.keys) setProblem(null) setMode("text") }} > {labels.edit} </Button> {entry.remapped && ( <Button type="button" variant="ghost" size="sm" className="h-6 px-1.5 text-xs" aria-label={`${labels.reset}: ${entry.description}`} onClick={() => registry.reset(entry.id)}> {labels.reset} </Button> )} </span> </div> {problem && ( <p className="text-destructive" data-hotkey-problem> {problem} </p> )} {conflicts.length > 0 && ( <ul className="text-stale" data-hotkey-conflicts> {conflicts.map((c, i) => { const o = other(c) return ( <li key={i}> {c.kind === "duplicate" ? labels.duplicate : c.kind === "prefix" ? labels.prefix : labels.shadow} {o ? `"${o.description}"` : c.ids.join(", ")} </li> ) })} </ul> )} </div> ) } export function HotkeyEditor({ onExport, onImport, hide, labels: labelsProp, className }: HotkeyEditorProps) { const labels = { ...DEFAULT_HOTKEY_EDITOR_LABELS, ...labelsProp } const registry = useHotkeys() const entries = useHotkeyList() // The list's identity changes exactly when the registry emits, and the conflicts change with it. const conflicts = useMemo(() => { void entries return registry.conflicts() }, [registry, entries]) const [query, setQuery] = useState("") const shown = useMemo(() => entries.filter((e) => !hide?.(e) && matchesQuery(e, query, registry.platform)), [entries, hide, query, registry.platform]) // Named groups first, in order; then the groups a scope gave its name to. const groups = useMemo(() => { const map = new Map<string, HotkeyEntry[]>() const rank = (e: HotkeyEntry) => (e.group ? 0 : 1) for (const entry of [...shown].sort((a, b) => rank(a) - rank(b) || groupOf(a).localeCompare(groupOf(b)) || a.description.localeCompare(b.description))) { const g = groupOf(entry) map.set(g, [...(map.get(g) ?? []), entry]) } return [...map.entries()] }, [shown]) const byId = useMemo(() => new Map(entries.map((e) => [e.id, e])), [entries]) const remapped = entries.filter((e) => e.remapped).length return ( <div role="region" aria-label={labels.title} data-slot="tradecn-hotkey-editor" data-remapped={remapped} className={cn("flex flex-col gap-2 text-xs lining-nums tabular-nums", className)}> <div className="flex flex-wrap items-center gap-2"> <Input value={query} aria-label={labels.search} placeholder={labels.search} spellCheck={false} autoComplete="off" className="h-7 max-w-64 font-(family-name:--tradecn-font-mono) text-xs md:text-xs" onChange={(event) => setQuery(event.target.value)} /> <span className="ml-auto flex items-center gap-1"> {onImport && ( <Button type="button" variant="outline" size="sm" className="h-7 px-2 text-xs" onClick={onImport}> {labels.import} </Button> )} {onExport && ( <Button type="button" variant="outline" size="sm" className="h-7 px-2 text-xs" onClick={() => onExport(registry.overrides())}> {labels.export} </Button> )} <Button type="button" variant="outline" size="sm" className="h-7 px-2 text-xs" disabled={remapped === 0} onClick={() => registry.reset()}> {labels.resetAll} </Button> </span> </div> {groups.length === 0 && <p className="text-muted-foreground">{labels.empty}</p>} {groups.map(([group, list]) => ( <section key={group} aria-label={group} data-hotkey-group={group}> <h3 className="mb-0.5 text-xs font-semibold uppercase tracking-wide text-muted-foreground">{group}</h3> {list.map((entry) => ( <Row key={entry.id} entry={entry} conflicts={conflicts.filter((c) => c.ids.includes(entry.id))} byId={byId} labels={labels} /> ))} </section> ))} </div> ) }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, } } -
Add the tokens to your stylesheet:
:root { --stale: oklch(0.52 0.13 75); --tradecn-font-accessible: 'Atkinson Hyperlegible Next', 'Atkinson Hyperlegible', ui-sans-serif, system-ui, sans-serif; --tradecn-font-accessible-mono: 'Atkinson Hyperlegible Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; --tradecn-font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } .dark { --stale: oklch(0.8 0.15 80); --tradecn-font-accessible: 'Atkinson Hyperlegible Next', 'Atkinson Hyperlegible', ui-sans-serif, system-ui, sans-serif; --tradecn-font-accessible-mono: 'Atkinson Hyperlegible Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; --tradecn-font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } -
Append this to your stylesheet:
:root[data-accessibility="hyperlegible"], [data-accessibility="hyperlegible"] { --tradecn-font-sans: var(--tradecn-font-accessible); --tradecn-font-mono: var(--tradecn-font-accessible-mono); }
Usage
import { HotkeyEditor } from "@/components/ui/hotkey-editor"
import { HotkeysProvider } from "@/hooks/use-hotkeys"
import type { HotkeyBinding } from "@/lib/hotkeys"
const BINDINGS: HotkeyBinding[] = [
{ id: "palette.open", keys: "mod+k", scope: "editing", description: "Open the command palette", group: "General" },
{ id: "go.blotter", keys: "g b", scope: "global", description: "Go to the blotter", group: "Go" },
]
function ShortcutSettings() {
return (
<HotkeysProvider bindings={BINDINGS}>
<HotkeyEditor className="w-xl max-w-full" />
</HotkeysProvider>
)
}
Keep BINDINGS stable, as a module constant. Bindings declare the shortcuts; attach their handlers with useHotkey. Reuse the application's provider when it already owns the registry.
The preview remaps declarations without running application commands. Try typing g b for “Go to the inquiries” to see a conflict with “Go to the blotter,” then reset the row. Export shows a snapshot of the override map; export again after another edit to refresh it. Reloading restores the defaults.
Composition
For persistence, create a registry with createHotkeyRegistry from @/lib/hotkeys and pass it to the provider. During browser startup, read and validate your stored overrides, then call registry.load. Subscribe to registry.onChange to save remaps and resets, and call the returned unsubscribe when the owner is disposed. Imported overrides need a separate save because load does not call onChange.
To place the editor in a dialog, install shadcn dialog separately and give it a DialogTitle. The editor's installation supplies the inline controls; file downloads and storage belong to your application.
API Reference
Props
All HotkeyEditorProps are optional. The editor reads and updates the nearest HotkeysProvider registry; it has no controlled value or binding-list prop.
| Prop | Type | Default | Purpose |
|---|---|---|---|
onExport |
(overrides: HotkeyOverrides) => void |
Unset | Show Export and receive the current override map. |
onImport |
() => void |
Unset | Show Import and start your import flow. |
hide |
(entry: HotkeyEntry) => boolean |
Show all entries | Omit rows for which the callback returns true. |
labels |
Partial<HotkeyEditorLabels> |
DEFAULT_HOTKEY_EDITOR_LABELS |
Replace individual labels and messages. |
className |
string |
Unset | Classes merged onto the outer region. |
HotkeyOverrides is Record<string, string>, keyed by binding id. HotkeyEntry extends HotkeyBinding with normalized effective keys, normalized defaultKeys, and remapped when those strings differ. These types come from @/lib/hotkeys.
The list
useHotkeyList supplies every registered binding. Rows use the binding's group, falling back to its scope name: global, editing, or the name after panel:. Named groups sort before scope-derived groups, then by group and description. Each row shows its description, effective keys as key caps, a changed badge when remapped, and any conflicts.
Search trims the query and matches case-insensitive substrings of the description, group, id, normalized keys, or displayed key caps (ctrl finds Ctrl K on non-Mac platforms). hide and search only filter rows: hidden bindings stay registered, can appear in another row's conflict message, and remain included in export and Reset all.
Three ways to change a shortcut
| Control | Behavior |
|---|---|
Change |
Focus a capture button and record one key with its modifiers through keysFromEvent. Bare modifiers and other ignored keys leave capture open. Escape cancels; Backspace or Delete unbinds, even with modifiers held. |
Type it |
Edit the effective keys as text. Join modifiers and a key with + (mod+k); separate chord steps with spaces (g b). Enter commits, Escape cancels, and an empty field unbinds. Parse errors appear below the row without changing the binding. |
Reset |
Remove that row's override. Shown only when its effective keys differ from its defaults. |
Reset all |
Remove every override, including those for hidden or unregistered bindings. Disabled when no registered entry is remapped. |
Capture and text editing both end on blur without committing an unfinished edit. The capture button prevents default behavior and stops propagation for every keydown, keeping those events out of the registry's normal document listener. It records one step; use text for chords or for Escape, Backspace, and Delete bindings.
Changes go through registry.remap and registry.reset. Both notify registry.onChange with the full override map, so persistence belongs there. The editor only owns its search and editing state.
The plus key has a current limitation: capture can produce ctrl++, which remapping rejects. Typing ctrl+plus for a binding whose default is x stores ctrl++, then falls back to x without an editor error. Declaring or loading ctrl+plus also produces effective keys the editor cannot format. See remapping; neither entry method reliably supports plus-key shortcuts.
Conflicts
The editor reads registry.conflicts() whenever the registry wakes it and shows each conflict under both affected rows that are visible.
| Kind | Reported overlap | Default phrase |
|---|---|---|
duplicate |
Same keys in the same scope, or between global and editing. |
same keys as |
prefix |
One sequence starts another in the same scope, or between global and editing. |
starts the chord of |
shadow |
A panel and a global or editing binding share keys or a chord prefix. | hides, while focus is in this panel, |
Each phrase names the other binding. The same phrase appears on both rows, so prefix and shadow messages do not indicate which row is the shorter sequence or panel binding.
Reports compare declarations regardless of when(). Unbound bindings and pairs with different panel scope names are excluded, and so is a pair whose handlers are all fenced to elements that do not hold one another, the way two tickets on one desk share mod+enter; that pair is reported until both sides have such handlers. Conflicts do not block a remap or decide which shortcut runs; a shadow may be intentional. See the registry's conflict rules.
Export and import
Export calls onExport(registry.overrides()) with the stored override map, for your file or clipboard handler. Import invokes your onImport callback: open your picker, read the overrides, and call registry.load(overrides).
load replaces all overrides and refreshes the list, but does not call onChange. Persist imported overrides yourself if they should survive a reload. An override that fails to parse remains stored while the binding falls back to its defaults, so exported values can differ from the effective keys shown.
Labels
labels merges with the exported DEFAULT_HOTKEY_EDITOR_LABELS. It covers control text, placeholders, badges, empty and error messages, conflict phrases, and the capture hint; descriptions and group names come from your bindings.
The outer region's accessible name defaults to Keyboard shortcuts (title). Search uses Find a shortcut (search) as its label and placeholder. Change, Type it, and Reset buttons include the binding description in their accessible names; the text field is named Keys for (keysFor) that description and sets aria-invalid after a failed commit. The capture button includes the screen-reader hint Escape cancels, Backspace unbinds (cancelHint).
Helpers
These pure helpers are exported from @/components/ui/hotkey-editor.
| Helper | Result |
|---|---|
scopeWord(scope: string) |
Scope name with a leading panel: removed; other names stay unchanged. |
groupOf(entry: HotkeyEntry) |
entry.group ?? scopeWord(entry.scope). |
matchesQuery(entry: HotkeyEntry, query: string, platform: "mac" | "other") |
Whether the trimmed, case-insensitive query matches the fields described under The list. A blank query matches every entry. |
What it does not do
The editor requires a HotkeysProvider and declares no bindings or handlers. It does not persist overrides, read or write files, or resolve conflicts.
Tokens
The install adds the stale token, which the conflict lines draw from, if you do not have it.
Built on shadcn's badge, button, input, and kbd.
This page is docs/hotkey-editor.md at v1.4.13.