useKeyboardShortcut Hook for React
A typed React hook for binding keyboard shortcuts with Cmd, Ctrl, Shift, and Alt modifier support. Cross-platform and accessible.
useKeyboardShortcut is a React hook that binds a keyboard shortcut (with optional modifiers) to a callback. It handles cross-platform Cmd/Ctrl mapping and skips firing while the user is typing in an input. Drop it in a Server Component layout and you've got app-wide shortcuts.
Tested on React 19, modern browsers (macOS, Windows, Linux).
When to Use This
- Cmd+K to open a command palette
- Esc to close a modal
- Cmd+S to save inside an editor
- ? to open a help dialog
- Slash (/) to focus a search input
Don't use this when you need a full shortcut library with chord support (use react-hotkeys-hook or kbar) or when the shortcut should only work inside a specific component (use a regular onKeyDown handler).
Code
import { useEffect, useCallback } from 'react';
interface ShortcutOptions {
key: string;
mod?: boolean; // Cmd on Mac, Ctrl elsewhere
shift?: boolean;
alt?: boolean;
/** Allow the shortcut to fire even when focused inside an input/textarea */
allowInInputs?: boolean;
}
export function useKeyboardShortcut(
options: ShortcutOptions,
handler: (event: KeyboardEvent) => void
): void {
const stableHandler = useCallback(handler, [handler]);
useEffect(() => {
const isMac = typeof navigator !== 'undefined' &&
/mac|iphone|ipad|ipod/i.test(navigator.platform);
function listener(event: KeyboardEvent) {
// Skip if user is typing in a form field, unless explicitly allowed
if (!options.allowInInputs) {
const target = event.target as HTMLElement | null;
const tag = target?.tagName?.toLowerCase();
if (tag === 'input' || tag === 'textarea' || target?.isContentEditable) {
return;
}
}
// Match the key (case-insensitive)
if (event.key.toLowerCase() !== options.key.toLowerCase()) {
return;
}
// Match modifiers
const wantsMod = options.mod === true;
const hasMod = isMac ? event.metaKey : event.ctrlKey;
if (wantsMod !== hasMod) return;
if ((options.shift === true) !== event.shiftKey) return;
if ((options.alt === true) !== event.altKey) return;
event.preventDefault();
stableHandler(event);
}
window.addEventListener('keydown', listener);
return () => window.removeEventListener('keydown', listener);
}, [
options.key,
options.mod,
options.shift,
options.alt,
options.allowInInputs,
stableHandler,
]);
}The platform check uses navigator.platform (still reliable for Mac detection) to map mod to either metaKey or ctrlKey. The input-focus check prevents the shortcut from hijacking keystrokes while the user is typing.
Usage
A Cmd+K command palette trigger and an Esc-to-close modal:
'use client';
import { useState } from 'react';
import { useKeyboardShortcut } from '@/hooks/useKeyboardShortcut';
export function CommandPalette() {
const [open, setOpen] = useState(false);
// Cmd+K (Mac) / Ctrl+K (everywhere else) opens the palette
useKeyboardShortcut({ key: 'k', mod: true }, () => setOpen((prev) => !prev));
// Esc closes it — allow even when focused in an input inside the palette
useKeyboardShortcut(
{ key: 'Escape', allowInInputs: true },
() => setOpen(false)
);
if (!open) return null;
return (
<div className="fixed inset-0 bg-black/50 flex items-start justify-center pt-20">
<div className="bg-white rounded-lg p-4 w-96">
<input autoFocus placeholder="Type a command..." />
</div>
</div>
);
}The Cmd+K hook fires only when no input is focused (so typing 'k' in a form doesn't trigger). The Escape hook uses allowInInputs so users can dismiss the modal while focused in its search input.
Things to Know
event.preventDefault()is essential for browser shortcuts. Without it, Cmd+S triggers your handler AND the browser's "Save Page As" dialog.navigator.platformis deprecated but still the most reliable Mac check. The replacement (navigator.userAgentData) isn't supported in Safari yet. Useplatformuntil it actually disappears.- Modifier checks are strict. If you bind
Cmd+Kand the user pressesCmd+Shift+K, the shift mismatch means your handler doesn't fire. That's correct behavior — but make sure to document it for users. - Multiple components binding the same shortcut all fire. There's no built-in priority. If two components want Cmd+K, both run. Coordinate them in a parent or use a context to gate.
useCallbackon the handler is critical. Without it, the effect re-runs on every render and the listener is constantly removed/added. Memoize.- The hook listens on
window, not the document. Some shortcuts (Cmd+W) can't be intercepted at all because the browser handles them before your listener.
Related Snippets & Reading
- useClickOutside Hook — pairs for "click outside or press Esc to close"
- useCopyToClipboard Hook — bind Cmd+C to a copy button
- MDN KeyboardEvent docs — full event reference
Frequently Asked Questions
How do you handle Cmd on Mac and Ctrl on Windows in the same shortcut?
Check both event.metaKey (Cmd on Mac) and event.ctrlKey (Ctrl on Windows/Linux) and treat either as 'mod'. Most apps use Cmd on Mac and Ctrl elsewhere for the same shortcut, and the hook should abstract that.
Should keyboard shortcuts work inside form inputs?
Usually no — the user is typing, not invoking shortcuts. The hook should detect when an input or textarea is focused and skip the handler for letter keys. Modifier-based shortcuts like Cmd+S can still fire because they don't conflict with typing.