Back to Blog

React Keyboard Shortcuts: Global Hotkeys, Command Palette, and Focus Management

Implement keyboard shortcuts in React applications. Covers useKeyboardShortcut hook, global hotkey registration, command palette with fuzzy search, modifier key detection, shortcut conflict prevention, and accessibility considerations.

Viprasol Tech Team
May 9, 2027
12 min read

Power users judge your product by keyboard shortcuts. A well-implemented command palette and global hotkey system distinguishes a professional tool from a toy. The implementation challenge is handling modifier key detection correctly across platforms (⌘ on Mac, Ctrl on Windows), not firing shortcuts when the user is typing in an input, and preventing conflicts between shortcuts.

This guide covers building a keyboard shortcut system from scratch β€” a reusable hook, a command registry, and a command palette UI.

useKeyboardShortcut Hook

// hooks/use-keyboard-shortcut.ts
import { useEffect, useCallback, useRef } from "react";

export interface KeyboardShortcut {
  key:       string;          // e.g., "k", "Enter", "Escape", "/"
  meta?:     boolean;         // ⌘ (Mac) or Ctrl (Windows/Linux)
  ctrl?:     boolean;         // Ctrl explicitly
  shift?:    boolean;
  alt?:      boolean;
  // If true, fires even when an input/textarea is focused
  allowInInput?: boolean;
  // Element to attach listener to (default: window)
  target?:   HTMLElement | null;
}

type Handler = (event: KeyboardEvent) => void;

function matchesPlatformMeta(event: KeyboardEvent, shortcut: KeyboardShortcut): boolean {
  if (shortcut.meta) {
    // ⌘ on Mac, Ctrl on Windows/Linux
    const isMac = navigator.platform.toUpperCase().includes("MAC");
    return isMac ? event.metaKey : event.ctrlKey;
  }
  if (shortcut.ctrl) return event.ctrlKey;
  return true;
}

function isInputFocused(): boolean {
  const el = document.activeElement;
  if (!el) return false;
  const tag = el.tagName.toLowerCase();
  return (
    tag === "input" ||
    tag === "textarea" ||
    tag === "select" ||
    (el as HTMLElement).isContentEditable
  );
}

export function useKeyboardShortcut(
  shortcut: KeyboardShortcut,
  handler: Handler,
  enabled = true
): void {
  const handlerRef = useRef<Handler>(handler);
  handlerRef.current = handler;

  const handleKeyDown = useCallback(
    (event: Event) => {
      const e = event as KeyboardEvent;

      if (!enabled) return;

      // Skip if user is typing in an input
      if (!shortcut.allowInInput && isInputFocused()) return;

      // Check key match
      if (e.key.toLowerCase() !== shortcut.key.toLowerCase()) return;

      // Check modifiers
      if (!matchesPlatformMeta(e, shortcut)) return;
      if (shortcut.shift !== undefined && e.shiftKey !== shortcut.shift) return;
      if (shortcut.alt !== undefined && e.altKey !== shortcut.alt) return;

      // Don't fire if only meta/ctrl/shift/alt pressed (without a key)
      if (["Meta", "Control", "Shift", "Alt"].includes(e.key)) return;

      e.preventDefault();
      handlerRef.current(e);
    },
    [shortcut, enabled]
  );

  useEffect(() => {
    const target = shortcut.target ?? window;
    target.addEventListener("keydown", handleKeyDown);
    return () => target.removeEventListener("keydown", handleKeyDown);
  }, [handleKeyDown, shortcut.target]);
}

Command Registry

// lib/commands.ts β€” centralized command registry

export interface Command {
  id:          string;
  title:       string;
  description?: string;
  keywords?:   string[];   // Extra search terms
  icon?:       string;     // Emoji or icon name
  shortcut?:   string;     // Display hint: "⌘K", "G then D"
  section?:    string;     // Group label in palette
  action:      () => void | Promise<void>;
}

// Singleton registry
class CommandRegistry {
  private commands: Map<string, Command> = new Map();
  private listeners: Set<() => void> = new Set();

  register(command: Command): () => void {
    this.commands.set(command.id, command);
    this.notify();
    // Return unregister function
    return () => {
      this.commands.delete(command.id);
      this.notify();
    };
  }

  getAll(): Command[] {
    return Array.from(this.commands.values());
  }

  search(query: string): Command[] {
    if (!query.trim()) return this.getAll();
    const q = query.toLowerCase();
    return this.getAll().filter(
      (cmd) =>
        cmd.title.toLowerCase().includes(q) ||
        cmd.description?.toLowerCase().includes(q) ||
        cmd.keywords?.some((k) => k.toLowerCase().includes(q))
    );
  }

  subscribe(listener: () => void): () => void {
    this.listeners.add(listener);
    return () => this.listeners.delete(listener);
  }

  private notify() {
    this.listeners.forEach((l) => l());
  }
}

export const commandRegistry = new CommandRegistry();

🌐 Looking for a Dev Team That Actually Delivers?

Most agencies sell you a project manager and assign juniors. Viprasol is different β€” senior engineers only, direct Slack access, and a 5.0β˜… Upwork record across 100+ projects.

  • React, Next.js, Node.js, TypeScript β€” production-grade stack
  • Fixed-price contracts β€” no surprise invoices
  • Full source code ownership from day one
  • 90-day post-launch support included

Register Commands in Components

// hooks/use-register-commands.ts
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { commandRegistry, type Command } from "@/lib/commands";

export function useRegisterCommands(commands: Command[]) {
  useEffect(() => {
    const unregisterFns = commands.map((cmd) => commandRegistry.register(cmd));
    return () => unregisterFns.forEach((fn) => fn());
  }, []); // Register once on mount
}

// Usage in a page/layout component:
function DashboardLayout() {
  const router = useRouter();

  useRegisterCommands([
    {
      id:       "nav.dashboard",
      title:    "Go to Dashboard",
      icon:     "🏠",
      section:  "Navigation",
      shortcut: "G D",
      action:   () => router.push("/dashboard"),
    },
    {
      id:       "nav.settings",
      title:    "Go to Settings",
      icon:     "βš™οΈ",
      section:  "Navigation",
      shortcut: "G S",
      action:   () => router.push("/settings"),
    },
    {
      id:       "action.new-project",
      title:    "New Project",
      icon:     "βž•",
      section:  "Actions",
      shortcut: "⌘N",
      keywords: ["create", "add"],
      action:   () => router.push("/projects/new"),
    },
    {
      id:       "action.invite",
      title:    "Invite Team Member",
      icon:     "πŸ‘₯",
      section:  "Actions",
      keywords: ["add member", "team"],
      action:   () => router.push("/settings/members?invite=true"),
    },
  ]);

  return null; // Just registers β€” no UI
}

Command Palette Component

// components/command-palette/command-palette.tsx
"use client";

import { useState, useEffect, useRef, useCallback } from "react";
import { useKeyboardShortcut } from "@/hooks/use-keyboard-shortcut";
import { commandRegistry, type Command } from "@/lib/commands";
import { Search, CornerDownLeft } from "lucide-react";

function groupBySection(commands: Command[]): Map<string, Command[]> {
  const groups = new Map<string, Command[]>();
  for (const cmd of commands) {
    const section = cmd.section ?? "General";
    if (!groups.has(section)) groups.set(section, []);
    groups.get(section)!.push(cmd);
  }
  return groups;
}

export function CommandPalette() {
  const [open, setOpen]         = useState(false);
  const [query, setQuery]       = useState("");
  const [results, setResults]   = useState<Command[]>([]);
  const [selected, setSelected] = useState(0);
  const inputRef                = useRef<HTMLInputElement>(null);

  // Open with ⌘K
  useKeyboardShortcut(
    { key: "k", meta: true, allowInInput: true },
    () => { setOpen(true); setQuery(""); }
  );

  // Close with Escape
  useKeyboardShortcut(
    { key: "Escape", allowInInput: true },
    () => setOpen(false),
    open
  );

  // Update results when query changes
  useEffect(() => {
    if (!open) return;
    setResults(commandRegistry.search(query));
    setSelected(0);
  }, [query, open]);

  // Focus input when palette opens
  useEffect(() => {
    if (open) setTimeout(() => inputRef.current?.focus(), 10);
  }, [open]);

  const executeCommand = useCallback((cmd: Command) => {
    setOpen(false);
    setQuery("");
    void cmd.action();
  }, []);

  // Keyboard navigation within palette
  function handleKeyDown(e: React.KeyboardEvent) {
    if (e.key === "ArrowDown") {
      e.preventDefault();
      setSelected((s) => Math.min(s + 1, results.length - 1));
    } else if (e.key === "ArrowUp") {
      e.preventDefault();
      setSelected((s) => Math.max(s - 1, 0));
    } else if (e.key === "Enter" && results[selected]) {
      e.preventDefault();
      executeCommand(results[selected]);
    }
  }

  if (!open) return null;

  const grouped = groupBySection(results);
  let globalIndex = 0;

  return (
    <>
      {/* Backdrop */}
      <div
        className="fixed inset-0 bg-black/40 backdrop-blur-sm z-50"
        onClick={() => setOpen(false)}
      />

      {/* Palette */}
      <div
        className="fixed left-1/2 top-[20vh] -translate-x-1/2 z-50 w-full max-w-lg"
        role="dialog"
        aria-label="Command palette"
        aria-modal="true"
      >
        <div className="bg-white rounded-2xl shadow-2xl border border-gray-200 overflow-hidden">
          {/* Search input */}
          <div className="flex items-center gap-3 px-4 py-3 border-b border-gray-100">
            <Search className="w-4 h-4 text-gray-400 flex-shrink-0" />
            <input
              ref={inputRef}
              value={query}
              onChange={(e) => setQuery(e.target.value)}
              onKeyDown={handleKeyDown}
              placeholder="Search commands…"
              className="flex-1 text-sm text-gray-900 placeholder-gray-400 outline-none bg-transparent"
              role="combobox"
              aria-expanded="true"
              aria-autocomplete="list"
              aria-controls="command-list"
              aria-activedescendant={results[selected] ? `cmd-${results[selected].id}` : undefined}
            />
            <kbd className="text-xs text-gray-400 border border-gray-200 rounded px-1.5 py-0.5">
              esc
            </kbd>
          </div>

          {/* Results */}
          <div
            id="command-list"
            role="listbox"
            className="max-h-[400px] overflow-y-auto py-2"
          >
            {results.length === 0 ? (
              <p className="text-sm text-gray-400 text-center py-8">
                No commands found for "{query}"
              </p>
            ) : (
              Array.from(grouped.entries()).map(([section, cmds]) => (
                <div key={section}>
                  <p className="text-[11px] font-semibold text-gray-400 uppercase tracking-wider px-4 pt-3 pb-1">
                    {section}
                  </p>
                  {cmds.map((cmd) => {
                    const isSelected = globalIndex++ === selected;
                    return (
                      <div
                        key={cmd.id}
                        id={`cmd-${cmd.id}`}
                        role="option"
                        aria-selected={isSelected}
                        onClick={() => executeCommand(cmd)}
                        className={`
                          flex items-center gap-3 px-4 py-2.5 cursor-pointer transition-colors
                          ${isSelected ? "bg-blue-50" : "hover:bg-gray-50"}
                        `}
                      >
                        {cmd.icon && (
                          <span className="text-base w-5 text-center flex-shrink-0">
                            {cmd.icon}
                          </span>
                        )}
                        <div className="flex-1 min-w-0">
                          <p className={`text-sm font-medium ${isSelected ? "text-blue-700" : "text-gray-900"}`}>
                            {cmd.title}
                          </p>
                          {cmd.description && (
                            <p className="text-xs text-gray-400 truncate">{cmd.description}</p>
                          )}
                        </div>
                        {cmd.shortcut && (
                          <kbd className="text-xs text-gray-400 border border-gray-200 rounded px-1.5 py-0.5 flex-shrink-0">
                            {cmd.shortcut}
                          </kbd>
                        )}
                        {isSelected && (
                          <CornerDownLeft className="w-3.5 h-3.5 text-blue-400 flex-shrink-0" />
                        )}
                      </div>
                    );
                  })}
                </div>
              ))
            )}
          </div>

          {/* Footer */}
          <div className="border-t border-gray-100 px-4 py-2 flex items-center gap-4 text-xs text-gray-400">
            <span className="flex items-center gap-1">
              <kbd className="border border-gray-200 rounded px-1 py-0.5">↑↓</kbd> navigate
            </span>
            <span className="flex items-center gap-1">
              <kbd className="border border-gray-200 rounded px-1 py-0.5">↡</kbd> select
            </span>
            <span className="flex items-center gap-1">
              <kbd className="border border-gray-200 rounded px-1 py-0.5">esc</kbd> close
            </span>
          </div>
        </div>
      </div>
    </>
  );
}

πŸš€ Senior Engineers. No Junior Handoffs. Ever.

You get the senior developer, not a project manager who relays your requirements to someone you never meet. Every Viprasol project has a senior lead from kickoff to launch.

  • MVPs in 4–8 weeks, full platforms in 3–5 months
  • Lighthouse 90+ performance scores standard
  • Works across US, UK, AU timezones
  • Free 30-min architecture review, no commitment

Global Shortcut Hints Overlay

// components/shortcut-hint.tsx β€” show ⌘K hint in search bar
export function ShortcutHint({ shortcut }: { shortcut: string }) {
  const isMac =
    typeof navigator !== "undefined" &&
    navigator.platform.toUpperCase().includes("MAC");

  const display = shortcut
    .replace("meta", isMac ? "⌘" : "Ctrl")
    .replace("shift", "⇧")
    .replace("alt", isMac ? "βŒ₯" : "Alt");

  return (
    <kbd className="text-xs text-gray-400 border border-gray-200 rounded px-1.5 py-0.5 font-mono">
      {display}
    </kbd>
  );
}

Shortcut Documentation Page

// app/keyboard-shortcuts/page.tsx β€” discoverable shortcut reference
import { ONBOARDING_STEPS } from "@/lib/onboarding/steps";

const SHORTCUT_DOCS = [
  { section: "General",     key: "⌘K",            description: "Open command palette" },
  { section: "General",     key: "⌘/",            description: "Show keyboard shortcuts" },
  { section: "Navigation",  key: "G then D",       description: "Go to Dashboard" },
  { section: "Navigation",  key: "G then S",       description: "Go to Settings" },
  { section: "Actions",     key: "⌘N",             description: "New project" },
  { section: "Actions",     key: "⌘Enter",         description: "Submit form" },
  { section: "Editing",     key: "⌘Z",             description: "Undo" },
  { section: "Editing",     key: "βŒ˜β‡§Z",           description: "Redo" },
];

export default function KeyboardShortcutsPage() {
  const grouped = SHORTCUT_DOCS.reduce((acc, s) => {
    if (!acc[s.section]) acc[s.section] = [];
    acc[s.section].push(s);
    return acc;
  }, {} as Record<string, typeof SHORTCUT_DOCS>);

  return (
    <div className="max-w-2xl mx-auto py-10 px-4">
      <h1 className="text-2xl font-bold text-gray-900 mb-8">Keyboard shortcuts</h1>
      {Object.entries(grouped).map(([section, shortcuts]) => (
        <div key={section} className="mb-8">
          <h2 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">
            {section}
          </h2>
          <div className="bg-white border border-gray-200 rounded-xl divide-y divide-gray-100">
            {shortcuts.map((s) => (
              <div key={s.key} className="flex items-center justify-between px-4 py-3">
                <span className="text-sm text-gray-700">{s.description}</span>
                <kbd className="text-sm text-gray-500 border border-gray-200 rounded px-2 py-0.5 font-mono">
                  {s.key}
                </kbd>
              </div>
            ))}
          </div>
        </div>
      ))}
    </div>
  );
}

Cost and Timeline Estimates

ScopeTeamTimelineCost Range
useKeyboardShortcut hook (3–5 shortcuts)1 devHalf a day$150–300
Full command palette with registry1 dev2–3 days$600–1,200
+ Shortcut docs page + platform detection1 dev1 day$300–600

See Also


Working With Viprasol

Keyboard shortcuts separate tools from toys. Our team builds shortcut systems with cross-platform modifier key detection, a centralized command registry that components can extend, a command palette with fuzzy search and keyboard navigation, and a shortcut reference page β€” all with proper ARIA roles for screen reader accessibility.

What we deliver:

  • useKeyboardShortcut hook with input focus guard and platform-aware ⌘/Ctrl detection
  • CommandRegistry singleton with register/unregister and search
  • CommandPalette with ⌘K trigger, grouped results, arrow navigation, and ARIA
  • useRegisterCommands for page-level command registration
  • Keyboard shortcut documentation page with section grouping

Talk to our team about your productivity feature roadmap β†’

Or explore our web development services.

Share this article:

About the Author

V

Viprasol Tech Team

Custom Software Development Specialists

The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.

MT4/MT5 EA DevelopmentAI Agent SystemsSaaS DevelopmentAlgorithmic Trading

Need a Modern Web Application?

From landing pages to complex SaaS platforms β€” we build it all with Next.js and React.

Free consultation β€’ No commitment β€’ Response within 24 hours

Viprasol Β· Web Development

Need a custom web application built?

We build React and Next.js web applications with Lighthouse β‰₯90 scores, mobile-first design, and full source code ownership. Senior engineers only β€” from architecture through deployment.