ui
Keyboard shortcuts
A shortcut registry with scoping and a searchable `?` cheat sheet generated from what is actually registered.
npx shadcn@latest add @hextor/keyboard-shortcutsSource
"use client";
import * as React from "react";
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
/**
* The keyboard-shortcut layer: a registry components can plug into instead
* of every one of them wiring its own `document.addEventListener("keydown")`
* and re-solving the same three problems badly — not firing while someone is
* typing, not firing twice for one keypress, and not firing a background
* shortcut while a modal with its own binding for the same key is open.
*
* MVP scope, stated up front: a shortcut is one chord (optional modifiers +
* one key), evaluated on `keydown`. There is no chorded-sequence support
* (Gmail's "g" then "i") — that needs its own small state machine for the
* inter-key timeout, and it did not earn its place in a first pass where
* every one of the six primitives needed building.
*/
export interface ShortcutDefinition {
id: string;
/**
* Normalized combo string: modifiers (any of `mod`, `shift`, `alt`, in
* that order) joined with `+`, then the key. `mod` is Cmd on macOS/iOS and
* Ctrl everywhere else — the one piece of platform branching every app
* with a command palette needs, so it lives here instead of six times.
* Examples: `"mod+k"`, `"shift+?"`, `"g"`.
*/
combo: string;
/** What it does, in words a person searching the `?` overlay would type. */
description: string;
/** Groups the overlay's listing. Free text — callers own the taxonomy. */
group: string;
/** Registered and live, but left out of the `?` overlay's listing. */
hidden?: boolean;
}
interface RegisteredShortcut extends ShortcutDefinition {
scope: string;
handler: (event: KeyboardEvent) => void;
}
interface ShortcutContextValue {
registerShortcut: (shortcut: RegisteredShortcut) => () => void;
pushScope: (scope: string) => () => void;
shortcuts: ShortcutDefinition[];
}
const ShortcutContext = createContext<ShortcutContextValue | null>(null);
function isMac(): boolean {
if (typeof navigator === "undefined") return false;
return /Mac|iPhone|iPad|iPod/.test(navigator.platform || navigator.userAgent);
}
function isTextInput(el: Element | null): boolean {
if (!el) return false;
const tag = el.tagName;
if (tag === "INPUT" || tag === "TEXTAREA") return true;
return (el as HTMLElement).isContentEditable === true;
}
const MODIFIER_KEYS = new Set(["control", "meta", "shift", "alt"]);
function comboFromEvent(event: KeyboardEvent): string {
const parts: string[] = [];
const mod = isMac() ? event.metaKey : event.ctrlKey;
if (mod) parts.push("mod");
if (event.shiftKey) parts.push("shift");
if (event.altKey) parts.push("alt");
const key = event.key === " " ? "space" : event.key.toLowerCase();
if (!MODIFIER_KEYS.has(key)) parts.push(key);
return parts.join("+");
}
const DISPLAY_KEY: Record<string, string> = {
mod: "⌘",
shift: "⇧",
alt: "⌥",
arrowup: "↑",
arrowdown: "↓",
arrowleft: "←",
arrowright: "→",
space: "Space",
escape: "Esc",
enter: "↵",
backspace: "⌫",
};
/** Splits a combo string into the parts a `<kbd>` row should render, Mac-aware. */
export function formatShortcut(combo: string): string[] {
const mac = isMac();
return combo.split("+").map((part) => {
if (part === "mod") return mac ? "⌘" : "Ctrl";
if (part === "alt" && !mac) return "Alt";
return DISPLAY_KEY[part] ?? (part.length === 1 ? part.toUpperCase() : part);
});
}
export function ShortcutProvider({ children }: { children: React.ReactNode }) {
// The registry lives in real state, not a ref, because `shortcuts` below
// is read during render (the `?` overlay renders straight off it) — a
// ref mutated outside render and read inside it is exactly the tear a
// ref exists to avoid. `registryRef` is a same-tick mirror of that state,
// kept only so the keydown handler — which runs in an event callback, a
// context refs are meant for — always dispatches off the latest map
// without re-subscribing `addEventListener` on every register/unregister.
const [registry, setRegistry] = useState<Map<string, RegisteredShortcut>>(() => new Map());
const registryRef = useRef(registry);
useEffect(() => {
registryRef.current = registry;
}, [registry]);
// The scope stack never renders anything itself — it's read only inside
// the keydown handler and written only from other components' effects
// (`useShortcutScope`) — so a plain ref is the right tool here with no
// state mirror needed.
const scopeStackRef = useRef<string[]>(["global"]);
const registerShortcut = useCallback((shortcut: RegisteredShortcut) => {
setRegistry((prev) => {
const next = new Map(prev);
next.set(shortcut.id, shortcut);
return next;
});
return () => {
setRegistry((prev) => {
if (!prev.has(shortcut.id)) return prev;
const next = new Map(prev);
next.delete(shortcut.id);
return next;
});
};
}, []);
const pushScope = useCallback((scope: string) => {
scopeStackRef.current = [...scopeStackRef.current, scope];
return () => {
const idx = scopeStackRef.current.lastIndexOf(scope);
if (idx === -1) return;
scopeStackRef.current = [
...scopeStackRef.current.slice(0, idx),
...scopeStackRef.current.slice(idx + 1),
];
};
}, []);
useEffect(() => {
function onKeyDown(event: KeyboardEvent) {
// The hard rule from the brief: a shortcut never fires out from under
// someone who is typing. No opt-out — a registry item that could
// silently re-enable this per shortcut is one bad call away from
// hijacking every "s" keystroke in a text field somewhere.
if (isTextInput(document.activeElement)) return;
const combo = comboFromEvent(event);
const activeScope = scopeStackRef.current[scopeStackRef.current.length - 1] ?? "global";
for (const shortcut of registryRef.current.values()) {
if (shortcut.combo !== combo) continue;
// A shortcut only fires in the scope that is currently on top of the
// stack. Opening a modal that pushes its own scope shadows every
// `global`-scoped shortcut underneath it for exactly as long as the
// modal is open — that's the "does not fire the global one" rule.
if (shortcut.scope !== activeScope) continue;
event.preventDefault();
shortcut.handler(event);
return;
}
}
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, []);
const shortcuts = useMemo(
() =>
Array.from(registry.values()).map(
({ id, combo, description, group, hidden }): ShortcutDefinition => ({
id,
combo,
description,
group,
hidden,
}),
),
[registry],
);
const value = useMemo<ShortcutContextValue>(
() => ({ registerShortcut, pushScope, shortcuts }),
[registerShortcut, pushScope, shortcuts],
);
return <ShortcutContext.Provider value={value}>{children}</ShortcutContext.Provider>;
}
function useShortcutContext(hookName: string): ShortcutContextValue {
const ctx = useContext(ShortcutContext);
if (!ctx) throw new Error(`${hookName} must be used within a <ShortcutProvider>.`);
return ctx;
}
/**
* Registers a shortcut for as long as the calling component is mounted (and
* `enabled` is not `false`). `handler` may change every render without
* re-registering — it is read through a ref, so redefining an inline
* arrow function on every render (the normal case) never thrashes the
* registry or drops a keystroke between two re-renders.
*/
export function useRegisterShortcut(
definition: ShortcutDefinition,
handler: (event: KeyboardEvent) => void,
options?: { scope?: string; enabled?: boolean },
): void {
const ctx = useShortcutContext("useRegisterShortcut");
const handlerRef = useRef(handler);
useEffect(() => {
handlerRef.current = handler;
});
const scope = options?.scope ?? "global";
const enabled = options?.enabled ?? true;
useEffect(() => {
if (!enabled) return;
return ctx.registerShortcut({
...definition,
scope,
handler: (event) => handlerRef.current(event),
});
// `definition` is taken apart into primitives so a caller passing a
// fresh object literal every render (the normal case) doesn't
// re-register on every keystroke elsewhere in the app.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ctx, definition.id, definition.combo, definition.description, definition.group, definition.hidden, scope, enabled]);
}
/**
* Pushes `scope` onto the active-scope stack while `active` is true — the
* half of the scoping story a modal owns. Call it from the peek panel, the
* command palette, any dialog that has its own shortcuts and should shadow
* the app's global ones while it's open.
*/
export function useShortcutScope(scope: string, active = true): void {
const ctx = useShortcutContext("useShortcutScope");
useEffect(() => {
if (!active) return;
return ctx.pushScope(scope);
}, [ctx, scope, active]);
}
/** The live shortcut registry, for a `?` overlay (or any other listing) to render. */
export function useShortcuts(): ShortcutDefinition[] {
return useShortcutContext("useShortcuts").shortcuts;
}
Docs
The overlay lists the live registry, not a hand-kept document. A cheat sheet maintained separately from the bindings is wrong within a month.
Two things are enforced rather than offered: nothing fires while a text field has focus, and a scoped shortcut inside an open modal wins over the global one binding the same key. Both are the failures that make people stop using shortcuts at all.
Dependencies
lucide-reactdialoginput