Skip to main content
Back to Blog
Tutorials
3 min read
November 7, 2024

How to Implement Keyboard Shortcuts in Your React App

Add keyboard shortcuts to your React app for power users. Global shortcuts, context-aware bindings, and a shortcut help dialog.

Ryel Banfield

Founder & Lead Developer

Keyboard shortcuts make power users more productive. Here is how to add them properly.

Step 1: Custom Hook for Key Bindings

// hooks/useKeyboardShortcut.ts
"use client";

import { useEffect, useCallback } from "react";

interface ShortcutOptions {
  ctrl?: boolean;
  meta?: boolean;
  shift?: boolean;
  alt?: boolean;
}

export function useKeyboardShortcut(
  key: string,
  callback: () => void,
  options: ShortcutOptions = {}
) {
  const handleKeyDown = useCallback(
    (e: KeyboardEvent) => {
      // Don't trigger in input fields
      if (
        e.target instanceof HTMLInputElement ||
        e.target instanceof HTMLTextAreaElement ||
        (e.target instanceof HTMLElement && e.target.isContentEditable)
      ) {
        return;
      }

      const modifierMatch =
        (options.ctrl ? e.ctrlKey : !e.ctrlKey || options.meta) &&
        (options.meta ? e.metaKey : !e.metaKey || options.ctrl) &&
        (options.shift ? e.shiftKey : !e.shiftKey) &&
        (options.alt ? e.altKey : !e.altKey);

      // Support both Ctrl and Cmd
      const ctrlOrMeta = options.ctrl || options.meta;
      const ctrlMetaMatch = ctrlOrMeta
        ? e.ctrlKey || e.metaKey
        : !e.ctrlKey && !e.metaKey;

      if (e.key.toLowerCase() === key.toLowerCase() && ctrlMetaMatch) {
        e.preventDefault();
        callback();
      }
    },
    [key, callback, options]
  );

  useEffect(() => {
    document.addEventListener("keydown", handleKeyDown);
    return () => document.removeEventListener("keydown", handleKeyDown);
  }, [handleKeyDown]);
}

Step 2: Basic Usage

"use client";

import { useKeyboardShortcut } from "@/hooks/useKeyboardShortcut";
import { useRouter } from "next/navigation";

export function AppShortcuts() {
  const router = useRouter();

  // Cmd+K: Open command palette
  useKeyboardShortcut("k", () => openCommandPalette(), { meta: true });

  // Cmd+/: Toggle shortcuts help
  useKeyboardShortcut("/", () => openShortcutsDialog(), { meta: true });

  // G then H: Go home (vim-style)
  useKeyboardShortcut("h", () => router.push("/"));

  return null; // Invisible component that adds shortcuts
}

Step 3: Shortcut Registry

// lib/shortcuts.ts
export interface Shortcut {
  key: string;
  label: string;
  description: string;
  category: string;
  modifiers?: ("Ctrl" | "Cmd" | "Shift" | "Alt")[];
}

export const shortcuts: Shortcut[] = [
  {
    key: "k",
    label: "K",
    description: "Open command palette",
    category: "General",
    modifiers: ["Cmd"],
  },
  {
    key: "/",
    label: "/",
    description: "Show keyboard shortcuts",
    category: "General",
    modifiers: ["Cmd"],
  },
  {
    key: "b",
    label: "B",
    description: "Toggle sidebar",
    category: "Navigation",
    modifiers: ["Cmd"],
  },
  {
    key: "n",
    label: "N",
    description: "New item",
    category: "Actions",
    modifiers: ["Cmd"],
  },
  {
    key: "s",
    label: "S",
    description: "Save changes",
    category: "Actions",
    modifiers: ["Cmd"],
  },
  {
    key: "Escape",
    label: "Esc",
    description: "Close dialog / Cancel",
    category: "General",
  },
];

Step 4: Shortcuts Help Dialog

"use client";

import { useState } from "react";
import { useKeyboardShortcut } from "@/hooks/useKeyboardShortcut";
import { shortcuts } from "@/lib/shortcuts";

export function ShortcutsDialog() {
  const [open, setOpen] = useState(false);

  useKeyboardShortcut("/", () => setOpen(true), { meta: true });
  useKeyboardShortcut("Escape", () => setOpen(false));

  if (!open) return null;

  const categories = [...new Set(shortcuts.map((s) => s.category))];

  return (
    <div className="fixed inset-0 z-50 flex items-center justify-center">
      <div
        className="absolute inset-0 bg-black/50"
        onClick={() => setOpen(false)}
      />
      <div className="relative w-full max-w-lg rounded-xl border bg-white p-6 shadow-2xl dark:border-gray-700 dark:bg-gray-900">
        <h2 className="text-lg font-bold">Keyboard Shortcuts</h2>

        <div className="mt-4 space-y-6">
          {categories.map((category) => (
            <div key={category}>
              <h3 className="mb-2 text-xs font-semibold uppercase tracking-wider text-gray-500">
                {category}
              </h3>
              <div className="space-y-2">
                {shortcuts
                  .filter((s) => s.category === category)
                  .map((shortcut) => (
                    <div
                      key={shortcut.key}
                      className="flex items-center justify-between"
                    >
                      <span className="text-sm text-gray-600 dark:text-gray-400">
                        {shortcut.description}
                      </span>
                      <div className="flex gap-1">
                        {shortcut.modifiers?.map((mod) => (
                          <kbd
                            key={mod}
                            className="rounded border bg-gray-100 px-1.5 py-0.5 text-xs dark:border-gray-600 dark:bg-gray-800"
                          >
                            {mod}
                          </kbd>
                        ))}
                        <kbd className="rounded border bg-gray-100 px-1.5 py-0.5 text-xs dark:border-gray-600 dark:bg-gray-800">
                          {shortcut.label}
                        </kbd>
                      </div>
                    </div>
                  ))}
              </div>
            </div>
          ))}
        </div>
      </div>
    </div>
  );
}

Step 5: Sequence Shortcuts (Vim-Style)

// hooks/useSequenceShortcut.ts
"use client";

import { useEffect, useRef, useCallback } from "react";

export function useSequenceShortcut(
  sequence: string[],
  callback: () => void,
  timeout = 1000
) {
  const buffer = useRef<string[]>([]);
  const timer = useRef<NodeJS.Timeout>();

  const handleKeyDown = useCallback(
    (e: KeyboardEvent) => {
      if (
        e.target instanceof HTMLInputElement ||
        e.target instanceof HTMLTextAreaElement
      ) {
        return;
      }

      buffer.current.push(e.key.toLowerCase());

      // Reset timer
      if (timer.current) clearTimeout(timer.current);
      timer.current = setTimeout(() => {
        buffer.current = [];
      }, timeout);

      // Check if sequence matches
      const lastN = buffer.current.slice(-sequence.length);
      if (
        lastN.length === sequence.length &&
        lastN.every((k, i) => k === sequence[i].toLowerCase())
      ) {
        buffer.current = [];
        callback();
      }
    },
    [sequence, callback, timeout]
  );

  useEffect(() => {
    document.addEventListener("keydown", handleKeyDown);
    return () => document.removeEventListener("keydown", handleKeyDown);
  }, [handleKeyDown]);
}

// Usage: Press "g" then "h" to go home
useSequenceShortcut(["g", "h"], () => router.push("/"));
useSequenceShortcut(["g", "s"], () => router.push("/settings"));
useSequenceShortcut(["g", "p"], () => router.push("/projects"));

Step 6: Keyboard Shortcut Tooltip

export function ShortcutHint({ keys }: { keys: string[] }) {
  return (
    <div className="hidden items-center gap-0.5 sm:flex">
      {keys.map((key, i) => (
        <kbd
          key={i}
          className="rounded border bg-gray-50 px-1 py-0.5 text-[10px] text-gray-400 dark:border-gray-700 dark:bg-gray-800"
        >
          {key}
        </kbd>
      ))}
    </div>
  );
}

// Usage in a button
<button className="flex items-center gap-2">
  <span>Search</span>
  <ShortcutHint keys={["⌘", "K"]} />
</button>

Need Power-User Features?

We build web applications with keyboard shortcuts, command palettes, and productivity-focused UX. Contact us to discuss your project.

keyboard shortcutsaccessibilityReactkeybindingstutorial

Ready to Start Your Project?

RCB Software builds world-class websites and applications for businesses worldwide.

Get in Touch

Related Articles