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.
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
| Scope | Team | Timeline | Cost Range |
|---|---|---|---|
| useKeyboardShortcut hook (3β5 shortcuts) | 1 dev | Half a day | $150β300 |
| Full command palette with registry | 1 dev | 2β3 days | $600β1,200 |
| + Shortcut docs page + platform detection | 1 dev | 1 day | $300β600 |
See Also
- React Accessibility and ARIA Patterns
- React Design System with Radix UI
- React State Machines with XState
- Next.js App Router Caching Strategies
- SaaS Onboarding Flow
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:
useKeyboardShortcuthook with input focus guard and platform-aware β/Ctrl detectionCommandRegistrysingleton with register/unregister and searchCommandPalettewith βK trigger, grouped results, arrow navigation, and ARIAuseRegisterCommandsfor 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.
About the Author
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.
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
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.