EmpireUI
Get Pro
← Blog7 min read#keyboard-shortcuts#react-hooks#hotkeys

Keyboard Shortcuts in React: Global Hotkeys, Help Modal

Add global keyboard shortcuts to any React app — hotkey listeners, a slash-command palette, and a help modal. No libraries required, just clean hooks.

Mechanical keyboard with colorful backlit keys on a dark desk

Why Keyboard Shortcuts Still Matter in 2026

Honestly, keyboard shortcuts are one of those things most devs skip and then regret six months after launch when power users start complaining in support tickets. Mouse-first UX is fine for onboarding, but the users who stick around — the ones paying you money every month — want to move fast without lifting their hands off the keys.

Think about the tools you actually enjoy using: Linear, Figma, VS Code. Every single one has a dense hotkey system. That's not a coincidence. It's the difference between a tool and a toy.

Adding keyboard shortcut support to a React app isn't hard, but doing it cleanly — without memory leaks, event listener collisions, or accessibility regressions — takes a bit of thought. This article walks through a production-ready pattern: a global hotkey hook, a slash-command palette, and a '?' help modal that lists every shortcut.

The useHotkey Hook: Global Event Listeners Done Right

The naïve approach is slapping a keydown listener on window inside a useEffect and calling it a day. That works until you have five components all doing the same thing and they start stomping on each other. You need a central registry.

Here's a useHotkey hook that registers shortcuts globally and cleans up properly. It handles modifier keys (Ctrl, Shift, Meta, Alt), ignores keypresses when the user is typing in an input, and accepts a priority flag so modal shortcuts can shadow page-level ones.

import { useEffect, useRef } from 'react';

type Modifier = 'ctrl' | 'shift' | 'meta' | 'alt';

interface HotkeyOptions {
  modifiers?: Modifier[];
  ignoreInputs?: boolean; // default true
  preventDefault?: boolean;
}

export function useHotkey(
  key: string,
  callback: (e: KeyboardEvent) => void,
  options: HotkeyOptions = {}
) {
  const { modifiers = [], ignoreInputs = true, preventDefault = true } = options;
  const callbackRef = useRef(callback);
  callbackRef.current = callback; // keep ref fresh, avoid stale closure

  useEffect(() => {
    const handler = (e: KeyboardEvent) => {
      if (ignoreInputs) {
        const tag = (e.target as HTMLElement).tagName;
        if (['INPUT', 'TEXTAREA', 'SELECT'].includes(tag)) return;
        if ((e.target as HTMLElement).isContentEditable) return;
      }

      const modMatch =
        modifiers.every(mod => {
          if (mod === 'ctrl') return e.ctrlKey;
          if (mod === 'shift') return e.shiftKey;
          if (mod === 'meta') return e.metaKey;
          if (mod === 'alt') return e.altKey;
          return false;
        }) &&
        // Ensure NO extra modifiers are held
        (modifiers.includes('ctrl') || !e.ctrlKey) &&
        (modifiers.includes('shift') || !e.shiftKey) &&
        (modifiers.includes('meta') || !e.metaKey) &&
        (modifiers.includes('alt') || !e.altKey);

      if (modMatch && e.key.toLowerCase() === key.toLowerCase()) {
        if (preventDefault) e.preventDefault();
        callbackRef.current(e);
      }
    };

    window.addEventListener('keydown', handler);
    return () => window.removeEventListener('keydown', handler);
  }, [key, modifiers.join(','), ignoreInputs, preventDefault]);
}

A few things worth pointing out. The callbackRef pattern means the callback always sees the latest closure values without causing the effect to re-run on every render. The modifier check also enforces that *only* the specified modifiers are held — so Ctrl+K won't fire if the user presses Ctrl+Shift+K. That prevents accidental clashes with browser shortcuts.

Building a Hotkey Registry for the Help Modal

A help modal is only useful if it automatically knows about every registered shortcut. Hardcoding the list in a separate file is a maintenance nightmare — the list gets stale within two sprints. Instead, build a context-based registry that components write to as they mount.

import { createContext, useContext, useRef, useCallback } from 'react';

interface ShortcutMeta {
  id: string;
  keys: string; // display string e.g. "Ctrl + K"
  description: string;
  group?: string;
}

interface HotkeyRegistry {
  register: (meta: ShortcutMeta) => () => void;
  getAll: () => ShortcutMeta[];
}

const HotkeyContext = createContext<HotkeyRegistry | null>(null);

export function HotkeyProvider({ children }: { children: React.ReactNode }) {
  const registry = useRef<Map<string, ShortcutMeta>>(new Map());

  const register = useCallback((meta: ShortcutMeta) => {
    registry.current.set(meta.id, meta);
    return () => registry.current.delete(meta.id);
  }, []);

  const getAll = useCallback(() =>
    Array.from(registry.current.values()), []);

  return (
    <HotkeyContext.Provider value={{ register, getAll }}>
      {children}
    </HotkeyContext.Provider>
  );
}

export function useHotkeyRegistry() {
  const ctx = useContext(HotkeyContext);
  if (!ctx) throw new Error('useHotkeyRegistry must be used inside HotkeyProvider');
  return ctx;
}

Now any component can call register() inside a useEffect and return the cleanup function directly. The help modal just calls getAll() when it opens. No prop drilling, no global mutable singletons outside React, and the registry stays in sync with whatever is actually mounted.

The Help Modal: Showing '?' to List All Shortcuts

The convention — originated by GitHub back in the day — is pressing ? to pop open a shortcut reference. It's a convention enough users know about that it's worth implementing even for internal tools.

The modal itself can be simple. You don't need animation libraries for this, though if you're already using something like animated tabs for your navigation, you can reuse the same transition primitives. A backdrop with rgba(0,0,0,0.6) and a centered card with backdrop-filter: blur(12px) and background: rgba(255,255,255,0.08) looks great on dark UIs without any extra dependencies.

Group the shortcuts by the group field from the registry. Something like 'Navigation', 'Editor', 'Global'. Three columns in the modal, 12px gap, monospace spans for the key labels with a border of 1px solid rgba(255,255,255,0.15) and border-radius: 4px. You can wire the close button to both Escape and clicking the backdrop — both feel natural.

Slash Command Palette: Ctrl+K Done Simply

The command palette pattern is everywhere now. Ctrl+K opens a floating search box, you type a command name, hit Enter. It's basically a filtered list of actions. You don't need a library like cmdk unless you want the fully-managed focus/accessibility stack — for many apps a 50-line component is enough.

The key UX pieces: input autofocuses when the palette opens (use a ref and call .focus() in a useEffect that runs when isOpen flips to true), results filter on every keystroke with a simple .filter() on the command list, and ArrowUp/ArrowDown cycle through results. Keep the list to 8px gap between items, 40px minimum height per row.

Is a third-party library ever worth it here? Sure, if your app is complex enough that you need grouped results, async search, or deep accessibility support. But for a dashboard or internal tool? Roll it yourself. You'll understand it, you can style it with your existing Tailwind setup (v4.0.2 or later makes this even cleaner with @utility blocks), and you won't add 40 kB to your bundle for something you could write in an afternoon.

If you want the command palette to feel cohesive with the rest of your UI, consider pairing it with animated buttons for the action items — the press-state feedback helps users feel the selection register before the action fires.

Accessibility Considerations You Can't Skip

Here's the thing: keyboard shortcuts can *break* accessibility if you're not careful. Screen reader users are already navigating by keyboard. If you intercept arrow keys or Enter globally, you'll trap them. The ignoreInputs flag in the hook above handles inputs and textareas, but you also need to be careful with custom interactive components that use role="listbox" or role="combobox".

The ARIA pattern for a dialog (role="dialog", aria-modal="true", aria-labelledby pointing to the title) is mandatory for the help modal and command palette. Focus should be trapped inside while they're open — Tab cycles through focusable elements inside the modal only. When they close, focus returns to whatever triggered them. That's the full pattern; skip any part of it and you'll get bug reports from assistive technology users.

Always expose the shortcut in the tooltip or button label too. A button that says 'Search' is fine; 'Search (Ctrl+K)' is better. You can render that with an aria-label or just include it in the visible text with a <kbd> element, which also gives you the semantic HTML hook to style key badges consistently across your app.

Putting It Together in a Real Component

Here's how the pieces connect in practice. You wrap your app (or the relevant subtree) with HotkeyProvider. Each feature registers its shortcuts on mount. The global ? shortcut opens the help modal, which reads from the registry. The Ctrl+K shortcut opens the command palette.

// App.tsx
import { HotkeyProvider } from './hotkey-registry';
import { HelpModal } from './HelpModal';
import { CommandPalette } from './CommandPalette';
import { useHotkey } from './useHotkey';
import { useState } from 'react';

function AppShell({ children }: { children: React.ReactNode }) {
  const [helpOpen, setHelpOpen] = useState(false);
  const [paletteOpen, setPaletteOpen] = useState(false);

  useHotkey('?', () => setHelpOpen(v => !v), { modifiers: ['shift'] });
  useHotkey('k', () => setPaletteOpen(v => !v), { modifiers: ['ctrl'] });
  useHotkey('Escape', () => {
    setHelpOpen(false);
    setPaletteOpen(false);
  }, { ignoreInputs: false });

  return (
    <>
      {children}
      {helpOpen && <HelpModal onClose={() => setHelpOpen(false)} />}
      {paletteOpen && <CommandPalette onClose={() => setPaletteOpen(false)} />}
    </>
  );
}

export default function App() {
  return (
    <HotkeyProvider>
      <AppShell>
        {/* your routes */}
      </AppShell>
    </HotkeyProvider>
  );
}

Notice that Escape uses ignoreInputs: false — you want Escape to close modals even when an input is focused. That's one of those small details that separates a polished implementation from one that frustrates users when the modal feels stuck.

Testing Your Shortcut System

Unit testing keyboard shortcuts is straightforward with React Testing Library. fireEvent.keyDown(window, { key: 'k', ctrlKey: true }) triggers the handler. Test that the right state updates happen, that the modal opens, that focus moves correctly.

For the accessibility layer, axe-core via jest-axe can catch missing ARIA roles and focus management issues automatically. Run it on the open modal state, not just the closed state — that's where most violations live. You can also test the help modal contents by asserting that registered shortcuts appear in the rendered list, which doubles as a smoke test that your registry is working.

What about integration tests? If your team uses Playwright, recording a Ctrl+K interaction and asserting the palette renders is a solid sanity check to include in your CI pipeline. It takes maybe 10 minutes to write and catches regressions that unit tests miss. Worth it. For layout consistency across your component library, cross-reference how you test similar interactive components like the bento grid layout — the patterns transfer directly.

FAQ

How do I prevent my global Ctrl+K shortcut from conflicting with the browser's address bar focus shortcut?

Call e.preventDefault() in your handler — that's what the preventDefault: true default in the hook does. Chrome and Firefox will respect it. On macOS, Cmd+K in Safari is trickier; some users have it bound at the OS level. There's no perfect answer, but Ctrl+K is widely accepted as safe on Windows/Linux and Cmd+K on macOS for in-app palettes.

Should I use a library like react-hotkeys-hook instead of rolling my own?

react-hotkeys-hook v4+ is solid and handles a lot of edge cases (combo keys, sequences, scope management). If your app has a genuinely complex shortcut graph with contexts and scopes, use it. For most apps — a palette and a help modal — the custom hook in this article is around 30 lines and gives you full control. Pick based on complexity, not habit.

How do I handle shortcuts on macOS vs Windows where Ctrl and Meta/Cmd differ?

Detect the OS with navigator.platform.includes('Mac') or the newer navigator.userAgentData.platform and map your modifier accordingly. A small utility that returns 'meta' on Mac and 'ctrl' elsewhere keeps your shortcut registrations clean. Display 'Cmd' vs 'Ctrl' in the help modal based on the same check.

Can I register keyboard shortcuts outside of React components, like in a utility module?

You can, but you lose the auto-cleanup that React's useEffect return function provides. If you add listeners in a plain module, you need a manual cleanup mechanism and you have to manage lifecycle yourself. Staying inside React components and hooks is almost always cleaner and less error-prone.

How do I handle focus trapping inside the help modal for keyboard accessibility?

Collect all focusable elements inside the modal on open (querySelectorAll('button, [href], input, [tabindex]:not([tabindex="-1"])')), then intercept Tab and Shift+Tab to cycle within that list. There are also small utilities like focus-trap or tabbable that handle the edge cases (disabled elements, hidden elements, shadow DOM) if you don't want to write it from scratch.

What's the right way to show keyboard shortcut badges in buttons or tooltips?

Use the HTML <kbd> element — it's semantically correct and gives you a styling hook. Style it with a monospace font, 1px border, and a subtle background like rgba(255,255,255,0.1) on dark themes. In tooltips, you can render it inline next to the action name. Screen readers will announce it as part of the accessible name if you include it in visible text.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

React UI Components Complete Reference: 60+ Patterns with CodeImage Gallery with Lightbox: Accessible Photo Viewer in ReactMarkdown Editor in React: Preview, Syntax Highlight, ShortcutsNeumorphism Icon Buttons: Soft UI Action Controls