React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

keyboardshortcuthotkeycommand-paletteaccessibilitycustom-hook

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 (enabled option)
  • 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 both event.ctrlKey (Windows/Linux) and event.metaKey (Mac Cmd), so Ctrl+K and Cmd+K both 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.key is compared case-insensitively, so "k" matches both k and K (with Shift).
  • preventDefault: Defaults to 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.
  • enabled guard: When false, the effect skips registration entirely, avoiding unnecessary listeners.
  • Multi-shortcut variant: useKeyboardShortcuts registers a single listener for many shortcuts, breaking after the first match for efficiency.

Parameters & Return Values

ParameterTypeDefaultDescription
keystringThe event.key value (e.g., "k", "Escape", "ArrowDown")
callback(event: KeyboardEvent) => voidHandler to call when shortcut fires
options.ctrlbooleanfalseRequire Ctrl (or Cmd on Mac)
options.shiftbooleanfalseRequire Shift
options.altbooleanfalseRequire Alt (Option on Mac)
options.metabooleanfalseRequire Meta (Cmd on Mac)
options.preventDefaultbooleantrueCall event.preventDefault()
options.enabledbooleantrueWhether the shortcut is active
options.targetEventTargetdocumentCustom 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.key values are strings from the KeyboardEvent.key spec (e.g., "Escape", "ArrowUp", "a").
  • The options interface uses all optional properties with sensible defaults.
  • The callback receives the full KeyboardEvent for advanced inspection (e.g., event.repeat for held keys).

Gotchas

  • Input fields swallow shortcuts — Typing 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.
  • 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 ctrl option matches both ctrlKey and metaKey by default for cross-platform compatibility.
  • key vs codeevent.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.
  • Repeat events — Holding a key fires repeated keydown events. Fix: Check event.repeat and skip if you only want the first press.

Alternatives

PackageHook NameNotes
react-hotkeys-hookuseHotkeysMost popular, string-based shortcuts
ahooksuseKeyPressSimple key press detection
@uidotdev/usehooksuseKeyPressMinimal, single key
cmdkBuilt-inFull command palette component
kbarBuilt-inCommand 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)?
  • 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.
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.key reflects the character produced ("a", "k", "Escape").
  • event.code reflects the physical key position ("KeyA", "KeyK").
  • Non-QWERTY layouts may produce different characters for the same physical key.
  • This hook uses event.key for 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.