INTERMEDIATEREACTHOOKS

useKeyboardShortcut Hook for React

A typed React hook for binding keyboard shortcuts with Cmd, Ctrl, Shift, and Alt modifier support. Cross-platform and accessible.

By Tested on React 19, modern browsers (macOS, Windows, Linux)

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.platform is deprecated but still the most reliable Mac check. The replacement (navigator.userAgentData) isn't supported in Safari yet. Use platform until it actually disappears.
  • Modifier checks are strict. If you bind Cmd+K and the user presses Cmd+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.
  • useCallback on 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.

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.

X (Twitter)LinkedIn