Front-end Engineering Lab

Keyboard Shortcuts

Implement accessible custom keyboard shortcuts

Custom keyboard shortcuts improve power user productivity but must be implemented accessibly. Follow conventions, avoid conflicts, and provide discoverability.

Basic Implementation

// hooks/useKeyboardShortcut.ts
export function useKeyboardShortcut(
  key: string,
  callback: () => void,
  options: {
    ctrl?: boolean;
    shift?: boolean;
    alt?: boolean;
    meta?: boolean;
  } = {}
) {
  useEffect(() => {
    const handleKeyDown = (e: KeyboardEvent) => {
      const {
        ctrlKey = options.ctrl || false,
        shiftKey = options.shift || false,
        altKey = options.alt || false,
        metaKey = options.meta || false,
      } = e;

      if (
        e.key.toLowerCase() === key.toLowerCase() &&
        e.ctrlKey === ctrlKey &&
        e.shiftKey === shiftKey &&
        e.altKey === altKey &&
        e.metaKey === metaKey
      ) {
        e.preventDefault();
        callback();
      }
    };

    document.addEventListener('keydown', handleKeyDown);
    return () => document.removeEventListener('keydown', handleKeyDown);
  }, [key, callback, options]);
}

// Usage
export function Editor() {
  const [content, setContent] = useState('');

  useKeyboardShortcut('s', () => save(content), { ctrl: true });
  useKeyboardShortcut('b', () => bold(), { ctrl: true });
  useKeyboardShortcut('/', () => openSearch(), { ctrl: true });

  return <textarea value={content} onChange={(e) => setContent(e.target.value)} />;
}

Common Shortcuts

Standard Conventions

Cmd/Ctrl + S: Save
Cmd/Ctrl + C: Copy
Cmd/Ctrl + V: Paste
Cmd/Ctrl + Z: Undo
Cmd/Ctrl + Shift + Z: Redo
Cmd/Ctrl + F: Find
Cmd/Ctrl + K: Search/Command palette
Cmd/Ctrl + /: Toggle comment
Cmd/Ctrl + B: Bold
Cmd/Ctrl + I: Italic
Escape: Close modal/cancel

Application-Specific

export function AppWithShortcuts() {
  useKeyboardShortcut('n', createNew, { ctrl: true });
  useKeyboardShortcut('o', open, { ctrl: true });
  useKeyboardShortcut('p', print, { ctrl: true });
  useKeyboardShortcut('k', openCommandPalette, { ctrl: true });
  useKeyboardShortcut('/', openSearch, { ctrl: true });

  return <div>...</div>;
}

Shortcut Manager

// utils/shortcuts.ts
type ShortcutHandler = () => void;

interface Shortcut {
  key: string;
  ctrl?: boolean;
  shift?: boolean;
  alt?: boolean;
  meta?: boolean;
  description: string;
  handler: ShortcutHandler;
}

class ShortcutManager {
  private shortcuts: Map<string, Shortcut> = new Map();

  register(shortcut: Shortcut) {
    const id = this.getShortcutId(shortcut);
    this.shortcuts.set(id, shortcut);
  }

  unregister(key: string, modifiers: any) {
    const id = this.getShortcutId({ key, ...modifiers } as Shortcut);
    this.shortcuts.delete(id);
  }

  getShortcutId(shortcut: Partial<Shortcut>): string {
    const parts = [];
    if (shortcut.ctrl) parts.push('ctrl');
    if (shortcut.shift) parts.push('shift');
    if (shortcut.alt) parts.push('alt');
    if (shortcut.meta) parts.push('meta');
    parts.push(shortcut.key?.toLowerCase());
    return parts.join('+');
  }

  handleKeyDown = (e: KeyboardEvent) => {
    const id = this.getShortcutId({
      key: e.key,
      ctrl: e.ctrlKey,
      shift: e.shiftKey,
      alt: e.altKey,
      meta: e.metaKey,
    });

    const shortcut = this.shortcuts.get(id);
    
    if (shortcut) {
      // Don't trigger if typing in input
      if (
        e.target instanceof HTMLInputElement ||
        e.target instanceof HTMLTextAreaElement ||
        (e.target as HTMLElement).isContentEditable
      ) {
        return;
      }

      e.preventDefault();
      shortcut.handler();
    }
  };

  getAllShortcuts(): Shortcut[] {
    return Array.from(this.shortcuts.values());
  }
}

export const shortcutManager = new ShortcutManager();

// Initialize listener
if (typeof window !== 'undefined') {
  document.addEventListener('keydown', shortcutManager.handleKeyDown);
}

Shortcut Context Provider

// components/ShortcutProvider.tsx
const ShortcutContext = createContext<ShortcutManager | null>(null);

export function ShortcutProvider({ children }: Props) {
  const [manager] = useState(() => new ShortcutManager());

  useEffect(() => {
    const handler = (e: KeyboardEvent) => manager.handleKeyDown(e);
    document.addEventListener('keydown', handler);
    return () => document.removeEventListener('keydown', handler);
  }, [manager]);

  return (
    <ShortcutContext.Provider value={manager}>
      {children}
    </ShortcutContext.Provider>
  );
}

export function useShortcut(shortcut: Shortcut) {
  const manager = useContext(ShortcutContext);

  useEffect(() => {
    if (!manager) return;
    
    manager.register(shortcut);
    
    return () => {
      manager.unregister(shortcut.key, shortcut);
    };
  }, [manager, shortcut]);
}

Keyboard Shortcut Menu

// components/ShortcutMenu.tsx
export function ShortcutMenu() {
  const [isOpen, setIsOpen] = useState(false);
  const shortcuts = shortcutManager.getAllShortcuts();

  useKeyboardShortcut('?', () => setIsOpen(true), { shift: true });

  const formatShortcut = (shortcut: Shortcut) => {
    const keys = [];
    if (shortcut.meta) keys.push('⌘');
    if (shortcut.ctrl) keys.push('Ctrl');
    if (shortcut.shift) keys.push('Shift');
    if (shortcut.alt) keys.push('Alt');
    keys.push(shortcut.key.toUpperCase());
    return keys.join(' + ');
  };

  return (
    <>
      <button
        onClick={() => setIsOpen(true)}
        aria-label="View keyboard shortcuts"
        title="Keyboard shortcuts (Shift + ?)"
      >
        ⌨️
      </button>

      {isOpen && (
        <Modal onClose={() => setIsOpen(false)}>
          <h2>Keyboard Shortcuts</h2>
          <table>
            <thead>
              <tr>
                <th>Shortcut</th>
                <th>Action</th>
              </tr>
            </thead>
            <tbody>
              {shortcuts.map((shortcut) => (
                <tr key={shortcutManager.getShortcutId(shortcut)}>
                  <td>
                    <kbd>{formatShortcut(shortcut)}</kbd>
                  </td>
                  <td>{shortcut.description}</td>
                </tr>
              ))}
            </tbody>
          </table>
        </Modal>
      )}
    </>
  );
}

// CSS for kbd
kbd {
  background: #f4f4f4;
  border: 1px solid #ccc;
  border-radius: 4px;
  padding: 2px 6px;
  font-family: monospace;
  font-size: 0.9em;
}

Command Palette

// components/CommandPalette.tsx
export function CommandPalette() {
  const [isOpen, setIsOpen] = useState(false);
  const [query, setQuery] = useState('');
  const [selected, setSelected] = useState(0);

  useKeyboardShortcut('k', () => setIsOpen(true), { ctrl: true });

  const commands = [
    { id: 'new', label: 'New Document', shortcut: 'Ctrl+N', action: createNew },
    { id: 'save', label: 'Save', shortcut: 'Ctrl+S', action: save },
    { id: 'search', label: 'Search', shortcut: 'Ctrl+/', action: search },
    // ...more commands
  ];

  const filteredCommands = commands.filter(cmd =>
    cmd.label.toLowerCase().includes(query.toLowerCase())
  );

  const handleKeyDown = (e: React.KeyboardEvent) => {
    if (e.key === 'ArrowDown') {
      e.preventDefault();
      setSelected(prev => Math.min(prev + 1, filteredCommands.length - 1));
    } else if (e.key === 'ArrowUp') {
      e.preventDefault();
      setSelected(prev => Math.max(prev - 1, 0));
    } else if (e.key === 'Enter') {
      e.preventDefault();
      filteredCommands[selected]?.action();
      setIsOpen(false);
    }
  };

  if (!isOpen) return null;

  return (
    <Modal onClose={() => setIsOpen(false)}>
      <input
        type="search"
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        onKeyDown={handleKeyDown}
        placeholder="Type a command..."
        autoFocus
      />
      
      <ul role="listbox">
        {filteredCommands.map((cmd, index) => (
          <li
            key={cmd.id}
            role="option"
            aria-selected={index === selected}
            onClick={() => {
              cmd.action();
              setIsOpen(false);
            }}
          >
            <span>{cmd.label}</span>
            <kbd>{cmd.shortcut}</kbd>
          </li>
        ))}
      </ul>
    </Modal>
  );
}

Contextual Shortcuts

// Different shortcuts based on context
export function Editor() {
  const [mode, setMode] = useState<'edit' | 'view'>('view');

  // Edit mode shortcuts
  useShortcut({
    key: 's',
    ctrl: true,
    description: 'Save',
    handler: save,
  });

  // View mode shortcuts
  useKeyboardShortcut('e', () => setMode('edit'));
  useKeyboardShortcut('j', scrollDown);
  useKeyboardShortcut('k', scrollUp);

  return (
    <div data-mode={mode}>
      {mode === 'edit' ? <EditMode /> : <ViewMode />}
    </div>
  );
}

Accessibility Considerations

Screen Reader Announcements

export function withShortcutAnnouncement(Component: React.ComponentType) {
  return function WithAnnouncement(props: any) {
    const [announcement, setAnnouncement] = useState('');

    const announceShortcut = (action: string) => {
      setAnnouncement(`${action} executed`);
      setTimeout(() => setAnnouncement(''), 2000);
    };

    return (
      <>
        <Component {...props} announceShortcut={announceShortcut} />
        <div role="status" aria-live="polite" aria-atomic="true" className="sr-only">
          {announcement}
        </div>
      </>
    );
  };
}

Avoid Input Conflicts

function shouldIgnoreShortcut(e: KeyboardEvent): boolean {
  const target = e.target as HTMLElement;
  
  // Ignore in input fields
  if (
    target.tagName === 'INPUT' ||
    target.tagName === 'TEXTAREA' ||
    target.isContentEditable
  ) {
    return true;
  }
  
  // Ignore if modifier combination is unusual
  // (might be browser/OS shortcut)
  if (e.altKey && e.ctrlKey) return true;
  
  return false;
}

Discoverability

// Show shortcuts in tooltips
export function Button({ onClick, shortcut }: Props) {
  return (
    <button
      onClick={onClick}
      title={`Save (${shortcut})`}
      aria-label={`Save, keyboard shortcut: ${shortcut}`}
    >
      Save
    </button>
  );
}

// Show in menus
export function Menu() {
  return (
    <ul role="menu">
      <li role="menuitem">
        <span>New</span>
        <kbd>Ctrl+N</kbd>
      </li>
      <li role="menuitem">
        <span>Save</span>
        <kbd>Ctrl+S</kbd>
      </li>
    </ul>
  );
}

Testing

describe('Keyboard Shortcuts', () => {
  it('saves with Ctrl+S', () => {
    render(<Editor />);
    
    fireEvent.keyDown(document, {
      key: 's',
      ctrlKey: true,
    });
    
    expect(mockSave).toHaveBeenCalled();
  });

  it('ignores shortcuts in input fields', () => {
    render(<Editor />);
    
    const input = screen.getByRole('textbox');
    input.focus();
    
    fireEvent.keyDown(input, {
      key: 's',
      ctrlKey: true,
    });
    
    expect(mockSave).not.toHaveBeenCalled();
  });
});

Best Practices

  1. Follow conventions: Use standard shortcuts (Ctrl+S, Ctrl+C, etc.)
  2. Document shortcuts: Help menu, tooltips
  3. Visual indicators: Show shortcuts in UI
  4. Avoid conflicts: Don't override browser shortcuts
  5. Context-aware: Different shortcuts for different modes
  6. Announce actions: Screen reader feedback
  7. Escape always closes: Standard behavior
  8. Test thoroughly: All shortcuts, all contexts
  9. Allow customization: Power users love this
  10. Cross-platform: Cmd on Mac, Ctrl on Windows

Common Pitfalls

Overriding browser shortcuts: Breaks user expectations
Use unique combinations or standard conventions

No discoverability: Users don't know shortcuts exist
Show in tooltips, menus, help dialog

Ignoring inputs: Shortcuts trigger while typing
Check if target is input/textarea

No screen reader support: Silent shortcuts
Announce actions with live regions

Platform-specific only: Ctrl on Mac breaks expectations
Detect platform, use Cmd on Mac

Keyboard shortcuts boost productivity—implement them accessibly with clear documentation and smart defaults!

On this page