useKeyboardShortcut
A hook that listens for a global keyboard shortcut and invokes a callback.
Dependencies
Source Code
import { useEffect, useRef } from 'react';
import { useStableCallback } from '@/foundations/hooks/use-stable-callback';
export interface SingleKeyboardShortcut {
key: string;
meta?: boolean;
ctrl?: boolean;
shift?: boolean;
alt?: boolean;
/**
* Cross-platform modifier. When true, matches `meta` (⌘) on macOS and
* `ctrl` everywhere else. Overrides `meta` and `ctrl` when set.
*/
mod?: boolean;
}
export interface SequenceKeyboardShortcut {
/**
* Type these keys in order to fire the callback (e.g. `['g', 'i']` for
* "go to inbox"). Resets on a non-matching key, on any modifier, when
* focus is in an input, or after `sequenceTimeout` ms of inactivity.
*/
sequence: string[];
}
export type KeyboardShortcut =
| SingleKeyboardShortcut
| SequenceKeyboardShortcut;
export interface UseKeyboardShortcutOptions {
enabled?: boolean;
preventDefault?: boolean;
target?: EventTarget | null;
/**
* For sequence shortcuts only — how long to wait between keys before
* resetting. Defaults to 1000ms.
*/
sequenceTimeout?: number;
}
const DEFAULT_SEQUENCE_TIMEOUT = 1000;
const isMac = () =>
typeof navigator !== 'undefined' &&
/Mac|iPad|iPhone|iPod/.test(navigator.userAgent);
const isTypingTarget = (target: EventTarget | null) => {
if (!(target instanceof HTMLElement)) return false;
const tag = target.tagName;
return tag === 'INPUT' || tag === 'TEXTAREA' || target.isContentEditable;
};
const isSequence = (s: KeyboardShortcut): s is SequenceKeyboardShortcut =>
'sequence' in s;
const matches = (event: KeyboardEvent, shortcut: SingleKeyboardShortcut) => {
if (event.key.toLowerCase() !== shortcut.key.toLowerCase()) return false;
const expectMeta =
shortcut.mod !== undefined
? shortcut.mod
? isMac()
: false
: Boolean(shortcut.meta);
const expectCtrl =
shortcut.mod !== undefined
? shortcut.mod
? !isMac()
: false
: Boolean(shortcut.ctrl);
if (event.metaKey !== expectMeta) return false;
if (event.ctrlKey !== expectCtrl) return false;
if (event.shiftKey !== Boolean(shortcut.shift)) return false;
if (event.altKey !== Boolean(shortcut.alt)) return false;
return true;
};
/**
* Listen for a keyboard shortcut and invoke a callback when it fires. Use the
* `mod` field for cross-platform shortcuts: `mod: true` matches ⌘ on macOS and
* Ctrl elsewhere — the conventional pairing for app-level commands.
*
* Pass a `sequence` instead of `key` for Vim/Linear-style multi-key shortcuts
* like `g i` to "go to inbox".
*
* @example
* ```
* useKeyboardShortcut({ key: 'k', mod: true }, () => setOpen(true));
* useKeyboardShortcut({ sequence: ['g', 'i'] }, () => navigate('/inbox'));
* ```
*/
export const useKeyboardShortcut = (
shortcut: KeyboardShortcut,
callback: (event: KeyboardEvent) => void,
options: UseKeyboardShortcutOptions = {}
) => {
const {
enabled = true,
preventDefault = true,
target,
sequenceTimeout = DEFAULT_SEQUENCE_TIMEOUT,
} = options;
const shortcutRef = useRef(shortcut);
shortcutRef.current = shortcut;
const stableCallback = useStableCallback(callback);
useEffect(() => {
if (!enabled) return;
const element: EventTarget | null =
target ?? (typeof document !== 'undefined' ? document : null);
if (!element) return;
let position = 0;
let resetTimer: ReturnType<typeof setTimeout> | null = null;
const reset = () => {
position = 0;
if (resetTimer) {
clearTimeout(resetTimer);
resetTimer = null;
}
};
const handler = (event: Event) => {
const keyEvent = event as KeyboardEvent;
const current = shortcutRef.current;
if (isSequence(current)) {
// Sequences shouldn't fire while the user is typing or holding any
// modifier — almost always something else is intended.
if (isTypingTarget(keyEvent.target)) return;
if (keyEvent.metaKey || keyEvent.ctrlKey || keyEvent.altKey) {
reset();
return;
}
const expected = current.sequence[position];
if (!expected) {
// Empty sequence or sequence shrunk between renders — bail out.
reset();
return;
}
if (keyEvent.key.toLowerCase() !== expected.toLowerCase()) {
reset();
return;
}
position++;
if (resetTimer) clearTimeout(resetTimer);
if (position === current.sequence.length) {
if (preventDefault) keyEvent.preventDefault();
stableCallback(keyEvent);
reset();
} else {
resetTimer = setTimeout(reset, sequenceTimeout);
}
return;
}
if (!matches(keyEvent, current)) return;
// No-modifier shortcuts (`{ key: 'e' }`) would fire while the user is
// typing the same letter into an input. Skip in that case — modifier
// shortcuts (⌘K, /) are explicit enough to override anyway.
const hasModifier =
Boolean(current.mod) ||
Boolean(current.meta) ||
Boolean(current.ctrl) ||
Boolean(current.alt);
if (!hasModifier && isTypingTarget(keyEvent.target)) return;
if (preventDefault) keyEvent.preventDefault();
stableCallback(keyEvent);
};
element.addEventListener('keydown', handler);
return () => {
element.removeEventListener('keydown', handler);
if (resetTimer) clearTimeout(resetTimer);
};
}, [enabled, preventDefault, target, sequenceTimeout, stableCallback]);
}; Features
- Cross-platform —
mod: truematches ⌘ on macOS and Ctrl elsewhere - Sequences —
sequence: ['g', 'i']for Vim/Linear-style multi-key shortcuts - Stable callback — your callback can read the latest state without retriggering the listener
- Scoped — pass a
targetto scope the listener to an element instead of the whole document - Toggleable —
enabled: falsedisables matching without unmounting
API Reference
Single shortcut
A modifier-plus-key combination. Pass an object with key and optional modifier fields.
| Prop | Default | Type | Description |
|---|---|---|---|
key * | - | string | The key to match. Compared case-insensitively against `event.key`. |
mod | - | boolean | Cross-platform modifier. When true, matches `meta` on macOS and `ctrl` everywhere else. Overrides `meta` and `ctrl` when set. |
meta | - | boolean | Require the meta key (⌘ on macOS, Win on Windows). |
ctrl | - | boolean | Require the ctrl key. |
shift | - | boolean | Require the shift key. |
alt | - | boolean | Require the alt/option key. |
Sequence shortcut
A series of keys typed in order. Pass an object with a sequence array. Sequences reset on a non-matching key, on any modifier, when focus is in an input/textarea/contenteditable, or after sequenceTimeout ms of inactivity.
| Prop | Default | Type | Description |
|---|---|---|---|
sequence * | - | string[] | The keys to match in order, e.g. `['g', 'i']` for `g i`. |
Options
| Prop | Default | Type | Description |
|---|---|---|---|
enabled | true | boolean | Set to false to disable the listener without unmounting. |
preventDefault | true | boolean | Whether to call `event.preventDefault()` on a match. |
target | - | EventTarget | null | The element to listen on. Defaults to `document`. |
sequenceTimeout | 1000 | number | Sequences only — milliseconds to wait between keys before resetting. |
Examples
Cross-platform shortcut
Open a panel with ⌘K on macOS and Ctrl+K elsewhere. The hook handles the match — pair it with mod whenever you wire up an app-level command.
Press ⌘I (or CtrlI)
Pressed 0 times
import { useState } from 'react';
import { useKeyboardShortcut } from '@/foundations/hooks/use-keyboard-shortcut';
import { Kbd } from '@/components/kbd';
export default function UseKeyboardShortcutPreview() {
const [count, setCount] = useState(0);
useKeyboardShortcut({ key: 'i', mod: true }, () => setCount((c) => c + 1));
return (
<div className="flex flex-col items-center gap-3 text-foreground-secondary text-sm">
<p>
Press{' '}
<Kbd.Group>
<Kbd>⌘</Kbd>
<Kbd>I</Kbd>
</Kbd.Group>{' '}
(or{' '}
<Kbd.Group>
<Kbd>Ctrl</Kbd>
<Kbd>I</Kbd>
</Kbd.Group>
)
</p>
<p className="text-2xl text-foreground">Pressed {count} times</p>
</div>
);
} Showing the right modifier in the UI
mod resolves the shortcut at match time, but if you want to render the shortcut in the UI you need to detect the platform yourself. navigator is browser-only, so detect after hydration to stay SSR-safe — first paint shows the non-Mac version, then the Mac symbol swaps in.
Press CtrlS
Saved 0 times
import { useEffect, useState } from 'react';
import { useKeyboardShortcut } from '@/foundations/hooks/use-keyboard-shortcut';
import { Kbd } from '@/components/kbd';
// Detect Mac on the client only — `navigator` doesn't exist during SSR, so
// the first render assumes non-Mac and updates after hydration.
const useIsMac = () => {
const [isMac, setIsMac] = useState(false);
useEffect(() => {
setIsMac(/Mac|iPad|iPhone|iPod/.test(navigator.userAgent));
}, []);
return isMac;
};
export default function UseKeyboardShortcutPlatformPreview() {
const isMac = useIsMac();
const [count, setCount] = useState(0);
useKeyboardShortcut({ key: 's', mod: true }, () => setCount((c) => c + 1));
return (
<div className="flex flex-col items-center gap-3 text-foreground-secondary text-sm">
<p>
Press{' '}
<Kbd.Group>
<Kbd>{isMac ? '⌘' : 'Ctrl'}</Kbd>
<Kbd>S</Kbd>
</Kbd.Group>
</p>
<p className="text-2xl text-foreground">Saved {count} times</p>
</div>
);
} Multi-key sequence
g h, g i, g s — the Linear/Vim/GitHub pattern. Pair each sequence with its destination.
Press g then h, i, or s
Nowhere yet
import { useState } from 'react';
import { useKeyboardShortcut } from '@/foundations/hooks/use-keyboard-shortcut';
import { Kbd } from '@/components/kbd';
export default function UseKeyboardShortcutSequencePreview() {
const [where, setWhere] = useState<string | null>(null);
useKeyboardShortcut({ sequence: ['g', 'h'] }, () => setWhere('Home'));
useKeyboardShortcut({ sequence: ['g', 'i'] }, () => setWhere('Inbox'));
useKeyboardShortcut({ sequence: ['g', 's'] }, () => setWhere('Settings'));
return (
<div className="flex flex-col items-center gap-3 text-foreground-secondary text-sm">
<p>
Press <Kbd>g</Kbd> then <Kbd>h</Kbd>, <Kbd>i</Kbd>, or <Kbd>s</Kbd>
</p>
<p className="text-2xl text-foreground">
{where ? `Went to ${where}` : 'Nowhere yet'}
</p>
</div>
);
} Previous
useIntersectionObserver
Next
useMatchMedia