useKeyboardShortcut — Register keyboard shortcuts with modifier key support
Recipe
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.
Working Example
"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:
- Ctrl+K / Cmd+K opens a command palette (cross-platform modifier handling)
- Escape closes the palette only when it is open (
enabledoption) - Ctrl+S prevents the browser save dialog and triggers custom save logic
- Ctrl+Shift+Z demonstrates compound modifiers for redo
Deep Dive
How It Works
- Cross-platform modifiers: When
ctrl: true, the hook matches bothevent.ctrlKey(Windows/Linux) andevent.metaKey(Mac Cmd), soCtrl+KandCmd+Kboth work. - Callback ref pattern: The callback is stored in a ref so the event listener does not need to re-subscribe when the callback changes.
- Key normalization:
event.keyis compared case-insensitively, so"k"matches bothkandK(with Shift). - preventDefault: Defaults to
trueto stop browser defaults (e.g., Ctrl+S opening Save dialog). Set tofalsefor keys like Escape where you want the default behavior to proceed. - enabled guard: When
false, the effect skips registration entirely, avoiding unnecessary listeners. - Multi-shortcut variant:
useKeyboardShortcutsregisters a single listener for many shortcuts, breaking after the first match for efficiency.
Parameters & Return Values
| 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 |
Variations
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,
});TypeScript Notes
event.keyvalues are strings from theKeyboardEvent.keyspec (e.g.,"Escape","ArrowUp","a").- The options interface uses all optional properties with sensible defaults.
- The callback receives the full
KeyboardEventfor advanced inspection (e.g.,event.repeatfor held keys).
Gotchas
- Input fields swallow shortcuts — Typing
kin a text input firesCtrl+Kif Ctrl is held. Fix: Checkevent.targetand skip if it is an input, textarea, or contenteditable element. - Browser reserved shortcuts — Some shortcuts like Ctrl+W (close tab) or Ctrl+T (new tab) cannot be overridden. Fix: Choose shortcuts that browsers do not reserve. Ctrl+K is generally safe.
- Mac vs Windows — Mac users expect Cmd, not Ctrl. Fix: The
ctrloption matches bothctrlKeyandmetaKeyby default for cross-platform compatibility. - key vs code —
event.keyreflects the character ("a"), whileevent.codereflects the physical key ("KeyA"). Non-QWERTY layouts may differ. Fix: Useevent.keyfor character shortcuts,event.codefor position-based shortcuts. - Repeat events — Holding a key fires repeated
keydownevents. Fix: Checkevent.repeatand skip if you only want the first press.
Alternatives
| 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 |
FAQs
How does the hook handle cross-platform Ctrl vs Cmd differences?
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.
Why does preventDefault default to true?
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.
What is the difference between useKeyboardShortcut and useKeyboardShortcuts (plural)?
useKeyboardShortcutregisters a singlekeydownlistener for one shortcut.useKeyboardShortcutsregisters a singlekeydownlistener that checks multiple shortcuts, breaking after the first match. It is more efficient for many shortcuts.
How does the enabled option work?
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).
Gotcha: My Ctrl+K shortcut fires when I type "k" in a text input with Ctrl held. How do I prevent this?
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 });Gotcha: Holding a key fires the shortcut repeatedly. How do I make it fire only once?
Check event.repeat inside the callback:
useKeyboardShortcut("s", (e) => {
if (e.repeat) return;
save();
}, { ctrl: true });Can I override browser-reserved shortcuts like Ctrl+W or Ctrl+T?
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.
What is the difference between event.key and event.code?
event.keyreflects the character produced ("a", "k", "Escape").event.codereflects the physical key position ("KeyA", "KeyK").- Non-QWERTY layouts may produce different characters for the same physical key.
- This hook uses
event.keyfor character-based shortcuts.
How are the key and modifier options typed in TypeScript?
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.
How would I implement a key sequence (chord) like GitHub's g then h?
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.
Related
- useEventListener — the underlying listener pattern
- useClickOutside — dismiss UI with clicks
- useToggle — manage open/close state for palettes