Agents (llms.txt)
Octocat

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-platformmod: true matches ⌘ on macOS and Ctrl elsewhere
  • Sequencessequence: ['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 target to scope the listener to an element instead of the whole document
  • Toggleableenabled: false disables 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