Search across all documentation pages
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
import { useEffect, useRef, useCallback } from "react";
interface ShortcutOptions {
/** Require Ctrl (or Cmd on Mac). Default: false */
ctrl?: boolean;
/** Require Shift. Default: false */
shift?: boolean;
/** Require Alt (Option on Mac). Default: false */
alt?: boolean;
/** Require Meta (Cmd on Mac, Win on Windows). Default: false */
meta?: boolean;
/** Call event.preventDefault(). Default: true */
preventDefault?: boolean;
/** Only fire when this is true. Default: true */
enabled?: boolean;
/** Target element. Default: document */
target?: EventTarget | null;
}
function useKeyboardShortcut(
key: string,
callback: (event: KeyboardEvent) => void,
options: ShortcutOptions = {}
): void {
const {
ctrl = false,
shift = false,
alt = false,
meta = false,
preventDefault = true,
enabled = true,
target,
} = options;
const callbackRef = useRef(callback);
useEffect(() => {
callbackRef.current = callback;
}, [callback]);
useEffect(() => {
if (!enabled) return;
const eventTarget = target ?? document;
const handler = (e: Event) => {
const event = e as KeyboardEvent;
// Normalize the key comparison (case-insensitive)
if (event.key.toLowerCase() !== key.toLowerCase()) return;
// Check modifier keys
// Use metaKey OR ctrlKey for cross-platform Cmd/Ctrl
const ctrlMatch = ctrl
? event.ctrlKey || event.metaKey
: !event.ctrlKey && !event.metaKey;
// If ctrl option is set, skip individual meta check
const metaMatch = ctrl ? true : meta ? event.metaKey : !event.metaKey;
const shiftMatch = shift ? event.shiftKey : !event.shiftKey;
const altMatch = alt ? event.altKey : !event.altKey;
if (!ctrlMatch || !shiftMatch || !altMatch || (!ctrl && !metaMatch)) {
return;
}
if (preventDefault) {
event.preventDefault();
}
callbackRef.current(event);
};
eventTarget.addEventListener("keydown", handler);
return () => eventTarget.removeEventListener("keydown", handler);
}, [key, ctrl, shift, alt, meta, preventDefault, enabled, target]);
}
/**
* useKeyboardShortcuts
* Register multiple shortcuts at once.
*/
function useKeyboardShortcuts(
shortcuts: Array<{
key: string;
callback: (event: KeyboardEvent) => void;
options?: ShortcutOptions;
}>
): void {
const shortcutsRef = useRef(shortcuts);
useEffect(() => {
shortcutsRef.current = shortcuts;
}, [shortcuts]);
useEffect(() => {
const handler = (e: KeyboardEvent) => {
for (const shortcut of shortcutsRef.current) {
const opts = shortcut.options ?? {};
const {
ctrl = false,
shift = false,
alt = false,
meta = false,
preventDefault = true,
enabled = true,
} = opts;
if (!enabled) continue;
if (e.key.toLowerCase() !== shortcut.key.toLowerCase()) continue;
const ctrlMatch = ctrl
? e.ctrlKey || e.metaKey
: !e.ctrlKey && !e.metaKey;
const metaMatch = ctrl ? true : meta ? e.metaKey : !e.metaKey;
const shiftMatch = shift ? e.shiftKey : !e.shiftKey;
const altMatch = alt ? e.altKey : !e.altKey;
if (!ctrlMatch || !shiftMatch || !altMatch || (!ctrl && !metaMatch)) {
continue;
}
if (preventDefault) e.preventDefault();
shortcut.callback(e);
break; // Only fire the first matching shortcut
}
};
document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler);
}, []);
}When to reach for this: You want to add keyboard shortcuts like Ctrl+K for a command palette, Ctrl+S to save, Escape to close a modal, or arrow keys for navigation.
"use client";
import { useState } from "react";
function CommandPalette() {
const [isOpen, setIsOpen] = useState(false);
const [query, setQuery] = useState("");
// Ctrl+K or Cmd+K opens the palette
useKeyboardShortcut("k", () => setIsOpen(true), { ctrl: true });
// Escape closes it
useKeyboardShortcut("Escape", () => setIsOpen(false), {
enabled: isOpen,
preventDefault: false,
});
if (!isOpen) return null;
return (
<div
style={{
position: "fixed",
inset: 0,
background: "rgba(0,0,0,0.5)",
display: "flex",
alignItems: "flex-start",
justifyContent: "center",
paddingTop: 100,
zIndex: 1000,
}}
>
<div
style={{
background: "#fff",
borderRadius: 12,
padding: 16,
width: 500,
maxWidth: "90vw",
boxShadow: "0 16px 48px rgba(0,0,0,0.2)",
}}
>
<input
autoFocus
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Type a command..."
style={{
width: "100%",
padding: 12,
fontSize: 16,
border: "1px solid #e0e0e0",
borderRadius: 8,
outline: "none",
}}
/>
<div style={{ marginTop: 8, color: "#666", fontSize: 14 }}>
Press Escape to close
</div>
</div>
</div>
);
}
function EditorWithShortcuts() {
const [content, setContent] = useState("Hello, world!");
const [saved, setSaved] = useState(false);
// Ctrl+S to save
useKeyboardShortcut("s", () => {
console.log("Saving:", content);
setSaved(true);
setTimeout(() => setSaved(false), 2000);
}, { ctrl: true });
// Ctrl+Shift+Z to redo
useKeyboardShortcut("z", () => {
console.log("Redo");
}, { ctrl: true, shift: true });
return (
<div>
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
style={{ width: "100%", height: 200 }}
/>
{saved && <span style={{ color: "green" }}>Saved!</span>}
</div>
);
}What this demonstrates:
enabled option)ctrl: true, the hook matches both event.ctrlKey (Windows/Linux) and event.metaKey (Mac Cmd), so Ctrl+K and Cmd+K both work.event.key is compared case-insensitively, so "k" matches both k and K (with Shift).true to stop browser defaults (e.g., Ctrl+S opening Save dialog). Set to false for keys like Escape where you want the default behavior to proceed.false, the effect skips registration entirely, avoiding unnecessary listeners.useKeyboardShortcuts registers a single listener for many shortcuts, breaking after the first match for efficiency.| Parameter | Type | Default | Description |
|---|---|---|---|
key | string | - | The event.key value (e.g., "k", "Escape", "ArrowDown") |
callback | (event: KeyboardEvent) => void | - | Handler to call when shortcut fires |
options.ctrl | boolean | false | Require Ctrl (or Cmd on Mac) |
options.shift | boolean | false | Require Shift |
options.alt | boolean | false | Require Alt (Option on Mac) |
options.meta | boolean | false | Require Meta (Cmd on Mac) |
options.preventDefault | boolean | true | Call event.preventDefault() |
options.enabled | boolean | true | Whether the shortcut is active |
options.target | EventTarget | document | Custom event target |
Key sequence (chord): Detect multi-key sequences like g then h for GitHub-style navigation:
function useKeySequence(keys: string[], callback: () => void, timeout = 1000) {
const indexRef = useRef(0);
const timerRef = useRef<ReturnType<typeof setTimeout>>();
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key.toLowerCase() === keys[indexRef.current].toLowerCase()) {
indexRef.current++;
clearTimeout(timerRef.current);
if (indexRef.current === keys.length) {
callback();
indexRef.current = 0;
} else {
timerRef.current = setTimeout(() => {
indexRef.current = 0;
}, timeout);
}
} else {
indexRef.current = 0;
}
};
document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler);
}, [keys, callback, timeout]);
}Scoped to element: Pass a ref as the target to only listen for shortcuts when a specific element has focus:
const inputRef = useRef<HTMLInputElement>(null);
useKeyboardShortcut("Enter", handleSubmit, {
target: inputRef.current,
preventDefault: false,
});event.key values are strings from the KeyboardEvent.key spec (e.g., "Escape", "ArrowUp", "a").KeyboardEvent for advanced inspection (e.g., event.repeat for held keys).k in a text input fires Ctrl+K if Ctrl is held. Fix: Check event.target and skip if it is an input, textarea, or contenteditable element.ctrl option matches both ctrlKey and metaKey by default for cross-platform compatibility.event.key reflects the character ("a"), while event.code reflects the physical key ("KeyA"). Non-QWERTY layouts may differ. Fix: Use event.key for character shortcuts, event.code for position-based shortcuts.keydown events. Fix: Check event.repeat and skip if you only want the first press.
| Package | Hook Name | Notes |
|---|---|---|
react-hotkeys-hook | useHotkeys | Most popular, string-based shortcuts |
ahooks | useKeyPress | Simple key press detection |
@uidotdev/usehooks | useKeyPress | Minimal, single key |
cmdk | Built-in | Full command palette component |
kbar | Built-in | Command palette with shortcut handling |
When ctrl: true is set, the hook matches both event.ctrlKey (Windows/Linux) and event.metaKey (Mac Cmd). This means Ctrl+K on Windows and Cmd+K on Mac both trigger the same shortcut.
Most keyboard shortcuts override browser defaults (e.g., Ctrl+S triggers Save dialog). Setting preventDefault: true stops the browser's default action. Set it to false for keys like Escape where you want the default behavior to proceed.
useKeyboardShortcut registers a single keydown listener for one shortcut.useKeyboardShortcuts registers a single keydown listener that checks multiple shortcuts, breaking after the first match. It is more efficient for many shortcuts.When enabled is false, the effect skips registration entirely. No event listener is attached to the document. This is useful for shortcuts that should only be active in certain states (e.g., Escape only when a modal is open).
Check event.target inside the callback and skip if it is an input, textarea, or contenteditable element:
useKeyboardShortcut("k", (e) => {
const tag = (e.target as HTMLElement).tagName;
if (tag === "INPUT" || tag === "TEXTAREA") return;
setIsOpen(true);
}, { ctrl: true });Check event.repeat inside the callback:
useKeyboardShortcut("s", (e) => {
if (e.repeat) return;
save();
}, { ctrl: true });No. Browsers reserve certain shortcuts (close tab, new tab) and they cannot be intercepted by JavaScript. Choose shortcuts that browsers do not reserve. Ctrl+K is generally safe.
event.key reflects the character produced ("a", "k", "Escape").event.code reflects the physical key position ("KeyA", "KeyK").event.key for character-based shortcuts.The key parameter is a plain string matching KeyboardEvent.key values. The ShortcutOptions interface uses all optional boolean properties (ctrl, shift, alt, meta) with defaults destructured in the function body.
Use the useKeySequence variation from the Variations section. It tracks the current position in the key array via a ref and resets after a timeout if the sequence is not completed.
Reviewed by Chris St. John·Last updated Jul 19, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥