Skip to content

Instantly share code, notes, and snippets.

@Toyz
Last active August 15, 2026 18:59
Show Gist options
  • Select an option

  • Save Toyz/75e74a64b3df3b642899c5da3b4c0245 to your computer and use it in GitHub Desktop.

Select an option

Save Toyz/75e74a64b3df3b642899c5da3b4c0245 to your computer and use it in GitHub Desktop.
Quite literally useKeybinds in react that doesn't suck
// ─────────────────────────────────────────────────────────────────────────────
// useKeybinds — a small, self-contained React keyboard-shortcut hook.
// Platform-stable chords, ⌘/Ctrl auto-swap, vim-style sequences, typing-aware
// (won't fire while you're in an input/editor). Extracted from mininote.
//
// Quick start — global shortcuts:
// useKeybinds([
// { keys: "mod+k", run: () => openPalette() }, // ⌘K / Ctrl+K
// { keys: "mod+shift+p", run: () => openSwitcher() },
// { keys: "?", run: () => toggleCheatSheet() },// symbol implies shift
// { keys: "g h", run: () => go("/home") }, // vim-style sequence
// ]);
//
// Helpers:
// label("mod+shift+p") -> "⌘⇧P" (mac) / "Ctrl+Shift+P" — cheat-sheet chip
// captureChord(e) -> "mod+shift+p" | null — build a rebind UI
// chordIsSafe(keys) -> reject binds that hijack typing
// toCMKey("mod+b") -> "Mod-B" — feed a CodeMirror keymap
//
// The component-extensible registry at the BOTTOM (KeybindRegistryProvider /
// useRegisterKeybinds) is OPTIONAL: it lets a mounted component contribute scoped
// shortcuts into the same matcher/cheat-sheet. It needs a host to own the store and
// render the provider — with no provider, useRegisterKeybinds is a no-op. Don't need
// it? Delete from the "Component-extensible registry" divider down; the top half is
// fully standalone.
//
// Public gist — copy, adapt, ship. No attribution needed.
// ─────────────────────────────────────────────────────────────────────────────
import { createContext, useContext, useEffect, useRef } from "react";
// useKeybinds: mininote's own keyboard-shortcut hook. The ones in the wild
// re-subscribe every render, fire while you're typing, and fumble ⌘-vs-Ctrl —
// none of which is acceptable in an app whose whole point is a focused editor.
//
// Design:
// - ONE window listener, mounted once. Handlers live in a ref, so passing fresh
// closures every render never re-subscribes.
// - Typing-aware: a bind is ignored while focus is in an <input>/<textarea>/
// <select>/contenteditable (the CodeMirror editor is contenteditable), unless
// it sets whenTyping. So global nav keys never eat a keystroke mid-note.
// - `mod` means ⌘ on macOS, Ctrl elsewhere — declared once here.
// - Chords ("mod+k", "mod+shift+p", "/", "?") AND vim-style sequences ("g h")
// with a short timeout between steps.
export type KeyHandler = (e: KeyboardEvent) => void;
export interface Keybind {
/**
* A chord like "mod+k" / "mod+shift+p" / "/" / "?", or a space-separated
* sequence like "g h". `mod` = ⌘ (mac) or Ctrl. Symbol keys ("/", "?") imply
* their shift state, so you don't write "shift+/".
*/
keys: string;
run: KeyHandler;
/** Fire even while typing in an input/editor. Default false. */
whenTyping?: boolean;
/** preventDefault on match. Default true. */
preventDefault?: boolean;
}
export const isMac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.platform);
// canHover = a device with a real pointer (desktop). Keyboard-only affordances
// (the shortcut cheat-sheet) hide on touch where there's no keyboard.
export const canHover = typeof window !== "undefined" && window.matchMedia("(hover: hover)").matches;
// Platform modifier symbol for inline labels: "⌘" on mac, "Ctrl+" elsewhere.
export const MOD = isMac ? "⌘" : "Ctrl+";
const SEQ_TIMEOUT_MS = 700;
type Parsed =
| { type: "chord"; mods: Set<string>; key: string }
| { type: "seq"; steps: string[] };
function parse(keys: string): Parsed {
const s = keys.trim().toLowerCase();
if (/\s/.test(s)) return { type: "seq", steps: s.split(/\s+/) };
const toks = s.split("+");
const key = toks.pop() ?? "";
return { type: "chord", mods: new Set(toks), key };
}
// isTyping is true when focus is in an editable field — global binds yield to it.
function isTyping(target: EventTarget | null): boolean {
const el = target as HTMLElement | null;
if (!el || !el.tagName) return false;
const tag = el.tagName;
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return true;
return !!el.isContentEditable;
}
// Physical-key → [base, shifted] US symbol map. event.code is layout/platform STABLE,
// unlike event.key: macOS reports the UNSHIFTED symbol when ⌘ is held (⌘⇧/ → key="/" on
// mac but "?" on Windows), so a captured chord wouldn't match across platforms or survive
// cross-device sync. Deriving symbols from .code fixes both.
const CODE_SYM: Record<string, [string, string]> = {
Slash: ["/", "?"], Period: [".", ">"], Comma: [",", "<"], Semicolon: [";", ":"],
Quote: ["'", "\""], BracketLeft: ["[", "{"], BracketRight: ["]", "}"],
Backslash: ["\\", "|"], Minus: ["-", "_"], Equal: ["=", "+"], Backquote: ["`", "~"],
Digit1: ["1", "!"], Digit2: ["2", "@"], Digit3: ["3", "#"], Digit4: ["4", "$"],
Digit5: ["5", "%"], Digit6: ["6", "^"], Digit7: ["7", "&"], Digit8: ["8", "*"],
Digit9: ["9", "("], Digit0: ["0", ")"],
};
// The shifted-symbol chars (from CODE_SYM). label() shows ⇧ for a chord whose key already
// encodes shift (e.g. "?" = ⇧/) — capture omits a redundant shift token for symbols, so this
// keeps the DISPLAYED chord showing the shift the user pressed, consistently on every platform.
const SHIFTED_SYMS = new Set(Object.values(CODE_SYM).map(([, s]) => s));
// normKey: the canonical, platform-stable base-key token for a keydown. Symbols + shifted
// digits derive from event.code (so ⌘⇧/ is "?" everywhere); letters/space/named keys fall
// back to the produced key. Used by BOTH capture and match so they always agree.
function normKey(e: KeyboardEvent): string {
const sym = CODE_SYM[e.code];
if (sym) return e.shiftKey ? sym[1] : sym[0];
return e.key === " " ? "space" : e.key.toLowerCase();
}
function matchChord(p: Extract<Parsed, { type: "chord" }>, e: KeyboardEvent): boolean {
const mod = isMac ? e.metaKey : e.ctrlKey;
if (p.mods.has("mod") !== mod) return false;
if (p.mods.has("alt") !== e.altKey) return false;
const key = normKey(e);
// A symbol char already encodes its shift (e.g. "?" = ⇧/). For a symbol chord
// WITHOUT an explicit shift token (the built-in "?" / "/"), ignore shift. But a
// captured chord that DOES carry shift (e.g. "mod+shift+?") enforces it like any
// other — so the cheat-sheet can show ⇧ and rebinds round-trip exactly.
const isSymbol = p.key.length === 1 && !/[a-z0-9]/.test(p.key);
const ignoreShift = isSymbol && !p.mods.has("shift");
if (!ignoreShift && p.mods.has("shift") !== e.shiftKey) return false;
return key === p.key;
}
// getExtra (optional) supplies component-registered binds LIVE at keydown time (see the
// registry below) — resolved on each press so their `run` closures are always current, and
// so a mounted component's bind takes precedence over the base list (extras are matched first).
export function useKeybinds(binds: Keybind[], getExtra?: () => Keybind[]): void {
const ref = useRef(binds);
ref.current = binds;
const extraRef = useRef(getExtra);
extraRef.current = getExtra;
const seq = useRef<{ keys: string[]; at: number }>({ keys: [], at: 0 });
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const typing = isTyping(e.target);
const list = extraRef.current ? [...extraRef.current(), ...ref.current] : ref.current;
// 1. chords
for (const b of list) {
const p = parse(b.keys);
if (p.type !== "chord") continue;
if (typing && !b.whenTyping) continue;
if (matchChord(p, e)) {
if (b.preventDefault !== false) e.preventDefault();
b.run(e);
return;
}
}
// 2. sequences — only bare single keys (no modifiers), and not while typing
if (typing || e.metaKey || e.ctrlKey || e.altKey || e.key.length !== 1) return;
const now = Date.now();
if (now - seq.current.at > SEQ_TIMEOUT_MS) seq.current.keys = [];
seq.current.at = now;
seq.current.keys.push(e.key.toLowerCase());
if (seq.current.keys.length > 4) seq.current.keys.shift();
const buf = seq.current.keys.join(" ");
for (const b of list) {
const p = parse(b.keys);
if (p.type !== "seq") continue;
if (buf.endsWith(p.steps.join(" "))) {
if (b.preventDefault !== false) e.preventDefault();
seq.current.keys = [];
b.run(e);
return;
}
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, []);
}
// ---- Component-extensible registry -----------------------------------------------------
// A component contributes its OWN shortcuts (scoped to while it's mounted) via
// useRegisterKeybinds — flowing into the SAME matcher, cheat-sheet, and rebind/sync path as
// the app's built-ins, instead of a bespoke window listener. The host (App) owns the store +
// user overrides and supplies a `register` fn through this context.
// A registered bind = a runnable Keybind plus the metadata the cheat-sheet + rebinder need.
// `cat` is its display group; a module usually declares it ONCE via the `scope` arg of
// useRegisterKeybinds rather than repeating it per bind. `scoped` marks a component-scoped
// bind: it's only live while mounted, so a bare-key rebind ("j") can't hijack global typing →
// the rebinder relaxes its safety check, and the cheat-sheet groups it as module-owned.
export interface RegisteredBind extends Keybind {
id: string;
desc: string;
cat?: string; // defaults to the hook's `scope`
scoped?: boolean; // defaults true when a `scope` is given
}
export type RegisterKeybinds = (getBinds: () => RegisteredBind[]) => () => void;
const RegistryCtx = createContext<RegisterKeybinds | null>(null);
export const KeybindRegistryProvider = RegistryCtx.Provider;
// useRegisterKeybinds registers a component's shortcuts for as long as it's mounted. Pass a
// fresh array each render — it's read through a ref, so the binds' `run` closures stay current
// without re-registering. `scope` is the module's OWN category: it names the group in the
// cheat-sheet and marks the binds as component-scoped (module-owned). No-op outside a provider.
export function useRegisterKeybinds(binds: RegisteredBind[], scope?: string): void {
const register = useContext(RegistryCtx);
const norm = scope ? binds.map((b) => ({ ...b, cat: b.cat ?? scope, scoped: b.scoped ?? true })) : binds;
const ref = useRef(norm);
ref.current = norm;
useEffect(() => {
if (!register) return;
return register(() => ref.current);
}, [register]);
}
// captureChord turns a keydown into the canonical chord string the matcher uses
// ("mod+shift+p", "mod+e", "?"). Returns null for a modifier-only press (keep
// listening). Records every held modifier (incl. shift) so the chip shows the
// full chain. Used by the rebind UI.
export function captureChord(e: KeyboardEvent): string | null {
const k = e.key;
if (k === "Shift" || k === "Control" || k === "Alt" || k === "Meta") return null;
const key = normKey(e); // platform-stable (symbols from event.code → ⌘⇧/ = "?" everywhere)
const mods: string[] = [];
// Accept EITHER Ctrl or ⌘ as the platform mod and normalize to `mod` — so a Mac
// user pressing Ctrl (or a PC user pressing ⌘) still maps, and `mod` renders as
// the right glyph for their OS at runtime. This is the auto-swap.
if (e.metaKey || e.ctrlKey) mods.push("mod");
if (e.altKey) mods.push("alt");
// A symbol char already ENCODES its shift ("?" = ⇧/), so only record shift for
// non-symbol keys (letters/digits/named) — else "mod+shift+?" double-counts shift and
// mismatches the symbol-implies-shift matcher. Keeps capture ↔ match consistent.
const isSymbol = key.length === 1 && !/[a-z0-9]/.test(key);
if (e.shiftKey && !isSymbol) mods.push("shift");
return [...mods, key].join("+");
}
// chordIsSafe rejects a rebind that would hijack plain typing — a bare
// alphanumeric with no modifier (e.g. "p") fires on every keypress. Allow it only
// with a modifier, or if it's a symbol (?, /) which won't collide with nav typing.
export function chordIsSafe(keys: string): boolean {
const p = parse(keys);
if (p.type === "seq") return true;
if (p.mods.size > 0) return true;
return p.key.length === 1 && !/[a-z0-9]/.test(p.key); // symbol-only is fine
}
// toCMKey converts our chord ("mod+shift+b") to CodeMirror's keymap format
// ("Mod-Shift-B"). Sequences ("g h") aren't expressible as a CM key → null. Lets
// editor shortcuts live in the SAME registry (one source of truth) yet dispatch
// through CodeMirror so they stay editor-scoped + remappable.
export function toCMKey(keys: string): string | null {
if (/\s/.test(keys.trim())) return null;
const toks = keys.toLowerCase().split("+");
const key = toks.pop() ?? "";
const parts: string[] = [];
if (toks.includes("mod")) parts.push("Mod");
if (toks.includes("alt")) parts.push("Alt");
if (toks.includes("shift")) parts.push("Shift");
parts.push(key.length === 1 ? key.toUpperCase() : key);
return parts.join("-");
}
// Pretty names for named/whitespace keys, so a chip reads "←" / "Space" / "Esc" rather than
// "Arrowleft". Single-char keys pass through (upper-cased); anything unmapped title-cases.
const KEY_LABEL: Record<string, string> = {
arrowleft: "←", arrowright: "→", arrowup: "↑", arrowdown: "↓",
space: "Space", escape: "Esc", enter: "⏎", tab: "Tab", backspace: "⌫", delete: "Del",
};
// label formats a bind's keys for display in a help overlay ("⌘K", "G then H").
export function label(keys: string): string {
const p = parse(keys);
if (p.type === "seq") return p.steps.map((s) => KEY_LABEL[s] ?? s.toUpperCase()).join(" ");
const mod = isMac ? "⌘" : "Ctrl+";
const parts: string[] = [];
if (p.mods.has("mod")) parts.push(mod);
if (p.mods.has("alt")) parts.push(isMac ? "⌥" : "Alt+");
// Show ⇧ for an explicit shift token OR a shifted-symbol key ("?"=⇧/) — so the displayed
// chord shows the shift the user pressed CONSISTENTLY across platforms (capture omits the
// redundant shift token for symbols, but the user still expects to see ⇧).
if (p.mods.has("shift") || (p.key.length === 1 && SHIFTED_SYMS.has(p.key))) parts.push(isMac ? "⇧" : "Shift+");
parts.push(KEY_LABEL[p.key] ?? (p.key.length === 1 ? p.key.toUpperCase() : p.key[0].toUpperCase() + p.key.slice(1)));
return parts.join("").replace(/\+$/, "");
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment