Chrome Extension Keyboard Shortcuts: Commands API & Custom Hotkeys

47 min read

Chrome Extension Keyboard Shortcuts: Commands API & Custom Hotkeys

Keyboard shortcuts represent one of the most powerful features for transforming a good Chrome extension into an indispensable tool for power users. When implemented thoughtfully, shortcuts can dramatically reduce the friction between user intent and action, turning what would be multiple clicks into a single keystroke combination. This comprehensive guide covers everything you need to know about implementing keyboard shortcuts in Chrome extensions, from basic Commands API usage to advanced chord patterns and accessibility considerations.

Introduction: The Power of Keyboard Shortcuts

Keyboard shortcuts have become the hallmark of productivity-focused extensions. Consider popular extensions like Vimium, which provides Vim-style navigation for the web and has millions of users who swear by its keyboard-centric interface. Or LastPass and 1Password, whose users rely heavily on keyboard shortcuts to quickly access saved credentials without leaving the current page. These extensions demonstrate that well-implemented shortcuts can become a primary interaction model that users genuinely prefer over clicking.

The impact on user engagement is significant. Extensions with robust keyboard support often see higher retention rates because users develop muscle memory that makes the extension feel like a natural extension of their browser. When users can accomplish tasks faster with keyboard shortcuts, they associate that efficiency with your extension, creating a compelling reason to keep it installed. This is why many developers choose to make advanced shortcut features part of their premium offerings, as detailed in the extension monetization strategies guide.

The Commands API in Chrome provides a standardized way to register keyboard shortcuts that work consistently across different contexts within your extension. Whether you need shortcuts that work only when your extension is active or global shortcuts that work regardless of which application has focus, the API offers the flexibility to implement various shortcut modes while handling the complexity of cross-platform differences between Windows, macOS, and Linux.

The Commands API: Fundamentals

The Commands API centers around declaring keyboard shortcuts in your extension’s manifest.json file and then listening for those commands in your background service worker. This declarative approach allows Chrome to register your shortcuts with its internal shortcut management system, giving users a consistent experience when configuring or viewing shortcut bindings.

Declaring Commands in manifest.json

Commands are declared under the "commands" key in your manifest file. Each command requires a unique name and should include a description that helps users understand what the shortcut does. The suggested_key property defines the default keyboard binding, and you can specify different bindings for different platforms using the default, mac, linux, and windows properties.

{
  "manifest_version": 3,
  "name": "Productivity Booster",
  "version": "1.0",
  "permissions": ["activeTab", "storage", "sidePanel"],
  "commands": {
    "save-to-reading-list": {
      "suggested_key": {
        "default": "Ctrl+Shift+S",
        "mac": "Command+Shift+S"
      },
      "description": "Save current page to reading list"
    },
    "toggle-sidebar": {
      "suggested_key": {
        "default": "Ctrl+Shift+T",
        "mac": "Command+Shift+T"
      },
      "description": "Toggle extension sidebar"
    },
    "quick-note": {
      "suggested_key": {
        "default": "Ctrl+Shift+N",
        "mac": "Command+Shift+N"
      },
      "description": "Create a quick note on current page"
    },
    "_execute_action": {
      "suggested_key": {
        "default": "Ctrl+Shift+Y",
        "mac": "Command+Shift+Y"
      },
      "description": "Toggle extension popup"
    }
  }
}

The special _execute_action command deserves particular attention. While most commands trigger custom functionality in your service worker, this reserved command tells Chrome to open or toggle your extension’s popup (the action defined in your manifest). This is particularly useful for extensions that don’t always show a persistent popup but want to give users a quick way to access their extension’s interface.

TypeScript Manifest Declaration

When using TypeScript in your extension project, you can define type-safe manifest command declarations that provide autocomplete and compile-time checking. Here’s how to structure your commands declaration with proper typing:

// types/manifest.ts
interface CommandManifest {
  suggested_key: {
    default?: string;
    mac?: string;
    linux?: string;
    windows?: string;
  };
  description: string;
  global?: boolean;
}

interface CommandsManifest {
  [commandName: string]: CommandManifest;
}

interface ManifestV3 {
  manifest_version: 3;
  name: string;
  version: string;
  commands: CommandsManifest;
  // ... other manifest properties
}

const manifestCommands: CommandsManifest = {
  'save-to-reading-list': {
    suggested_key: {
      default: 'Ctrl+Shift+S',
      mac: 'Command+Shift+S',
    },
    description: 'Save current page to reading list',
  },
  'toggle-sidebar': {
    suggested_key: {
      default: 'Ctrl+Shift+T',
      mac: 'Command+Shift+T',
    },
    description: 'Toggle extension sidebar',
  },
  'quick-note': {
    suggested_key: {
      default: 'Ctrl+Shift+N',
      mac: 'Command+Shift+N',
    },
    description: 'Create a quick note on current page',
  },
};

Understanding Key Combination Syntax

Chrome’s Commands API uses a specific syntax for defining key combinations that balances expressiveness with predictability. The format follows a modifier-then-key pattern where modifiers are separated by plus signs, and the final component represents the key itself. Modifier keys include Ctrl, Shift, Alt, and Meta (where Meta maps to the Command key on macOS and the Windows key on Linux/Windows).

For cross-platform compatibility, you should always provide both a default binding (used on Windows and Linux) and a mac binding. Chrome automatically interprets these correctly, but the key combinations themselves often need adjustment because the Command key replaces Ctrl on macOS for many user workflows. The platform differences aren’t merely semantic—using Command on macOS provides a much better experience than requiring users to use Ctrl, which is often mapped to other functions in macOS applications.

Chrome enforces a maximum of four suggested key shortcuts per extension to prevent shortcut spam and encourage thoughtful shortcut design. Additionally, Chrome reserves certain key combinations that cannot be overridden by extensions, including Ctrl+T (new tab), Ctrl+W (close tab), Ctrl+N (new window), Ctrl+Shift+T (reopen closed tab), and several others that are fundamental to browser operation.

Handling Command Events

Once you’ve declared commands in your manifest, the next step is to handle them in your background service worker. The chrome.commands.onCommand event provides a straightforward way to respond when users trigger your shortcuts.

Basic Command Handling

The event listener receives the command name as a string, which you can use in a switch statement or object lookup to dispatch to the appropriate handler:

// background/command-handler.ts
chrome.commands.onCommand.addListener((command: string) => {
  console.log(`Command triggered: ${command}`);
  
  switch (command) {
    case 'save-to-reading-list':
      handleSaveToReadingList();
      break;
    case 'toggle-sidebar':
      handleToggleSidebar();
      break;
    case 'quick-note':
      handleQuickNote();
      break;
  }
});

async function handleSaveToReadingList(): Promise<void> {
  const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
  if (!tab.id || !tab.url) return;

  const readingList = await chrome.storage.local.get('readingList') as { readingList?: Array<{url: string, title: string, timestamp: number}> };
  const list = readingList.readingList || [];
  
  list.push({
    url: tab.url,
    title: tab.title || 'Untitled',
    timestamp: Date.now()
  });
  
  await chrome.storage.local.set({ readingList: list });
  
  chrome.action.setBadgeText({ text: '' });
  setTimeout(() => chrome.action.setBadgeText({ text: '' }), 2000);
}

async function handleToggleSidebar(): Promise<void> {
  const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
  if (!tab.id) return;

  // Use sidePanel API for MV3
  chrome.sidePanel.toggle({ tabId: tab.id });
}

Command Router Class

For more complex extensions with many commands, creating a structured CommandRouter class provides better organization and makes it easier to add new commands:

// background/command-router.ts
type CommandHandler = (context: CommandContext) => Promise<void> | void;

interface CommandContext {
  tab?: chrome.tabs.Tab;
  window?: chrome.windows.Window;
}

class CommandRouter {
  private handlers: Map<string, CommandHandler> = new Map();
  private defaultHandler?: CommandHandler;

  register(command: string, handler: CommandHandler): void {
    this.handlers.set(command, handler);
  }

  setDefault(handler: CommandHandler): void {
    this.defaultHandler = handler;
  }

  async dispatch(command: string): Promise<void> {
    const handler = this.handlers.get(command);
    
    if (!handler && this.defaultHandler) {
      await this.defaultHandler(command);
      return;
    }
    
    if (!handler) {
      console.warn(`No handler registered for command: ${command}`);
      return;
    }

    const context = await this.buildContext();
    await handler(context);
  }

  private async buildContext(): Promise<CommandContext> {
    const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
    const [window] = await chrome.windows.getCurrent();
    
    return { tab, window };
  }
}

const router = new CommandRouter();

// Register command handlers
router.register('save-to-reading-list', async (ctx) => {
  if (!ctx.tab?.id) return;
  // Handle saving to reading list
  console.log('Saving to reading list:', ctx.tab.url);
});

router.register('toggle-sidebar', async (ctx) => {
  if (!ctx.tab?.id) return;
  await chrome.sidePanel.toggle({ tabId: ctx.tab.id });
});

router.register('quick-note', async (ctx) => {
  if (!ctx.tab?.id) return;
  // Open a dialog or side panel for note-taking
  await chrome.sidePanel.open({ tabId: ctx.tab.id });
});

// Connect router to Chrome's command events
chrome.commands.onCommand.addListener((command) => {
  router.dispatch(command);
});

This router pattern makes it straightforward to organize command handling, especially when commands need access to different context information or when you want to add logging, error handling, or permission checks that apply to all commands.

Global vs Extension-Scoped Shortcuts

Understanding the distinction between extension-scoped and global shortcuts is crucial for designing the right keyboard experience for your extension. Each mode has specific use cases and implications for how users interact with your extension.

Extension-Scoped Shortcuts (Default Behavior)

By default, keyboard shortcuts registered through the Commands API are “extension-scoped,” meaning they only work when Chrome has focus and the user is interacting with Chrome in some way. This is the appropriate mode for most extensions because it avoids conflicts with other applications and respects the user’s focus context.

Extension-scoped shortcuts are ideal for actions that directly manipulate the current page or browser state. For example, a reading mode extension might use Alt+R to toggle reading mode on the current page, or a developer tool might use Ctrl+Shift+I to open its console. These shortcuts make sense only within the Chrome context, so extension-scoped behavior is the natural fit.

Global Shortcuts

Global shortcuts work even when Chrome is not the active application. This capability opens up powerful use cases but comes with important considerations. Global shortcuts are particularly valuable for clipboard managers, screenshot tools, system-wide quick launchers, and productivity utilities that need to capture input regardless of which application the user is working in.

To make a shortcut global, add "global": true to the command definition in your manifest:

{
  "commands": {
    "quick-capture": {
      "suggested_key": {
        "default": "Ctrl+Shift+Space",
        "mac": "Command+Shift+Space"
      },
      "description": "Quick capture from anywhere",
      "global": true
    }
  }
}

There are important platform restrictions to consider when using global shortcuts. On macOS, Chrome requires the “Accessibility” permission to register global shortcuts. Users must explicitly grant this permission in System Preferences > Security & Privacy > Privacy > Accessibility. This permission requirement exists for security reasons—global shortcuts are a powerful capability that could theoretically be misused, so Chrome requires explicit user consent.

On Linux and Windows, global shortcuts generally work without additional permissions, though some system configurations or security software might interfere. Your extension should gracefully handle cases where global shortcut registration fails and provide clear guidance to users about what went wrong.

Platform Differences Table

Feature Windows/Linux macOS
Default modifier Ctrl Command
Alternative modifier Alt Option
Global shortcuts Work without extra permissions Require Accessibility permission
Reserved shortcuts Ctrl+T, Ctrl+W, Ctrl+N, etc. Cmd+T, Cmd+W, Cmd+N, etc.
Maximum commands 4 suggested keys 4 suggested keys

User-Configurable Shortcuts

Allowing users to customize keyboard shortcuts is a powerful way to increase user satisfaction and accommodate different workflows. Chrome provides built-in support for shortcut customization, and your extension can integrate with this system to provide a seamless user experience.

Chrome’s Built-in Shortcut Configuration

Users can view and customize keyboard shortcuts for your extension by navigating to chrome://extensions/shortcuts or by clicking the keyboard icon in your extension’s entry in the Chrome Extensions管理页面. Chrome’s shortcut management interface provides a familiar place for users to configure bindings, and changes take effect immediately without requiring your extension to restart.

Reading Current Bindings

Your extension can read the current keyboard bindings using chrome.commands.getAll(), which returns an array of command objects containing the name, description, current shortcut, and whether the command is global:

// utils/shortcut-manager.ts
interface ShortcutInfo {
  name: string;
  description: string;
  shortcut: string;
  isGlobal: boolean;
}

class ShortcutManager {
  async getAllShortcuts(): Promise<ShortcutInfo[]> {
    return new Promise((resolve) => {
      chrome.commands.getAll((commands) => {
        const shortcuts: ShortcutInfo[] = commands.map((cmd) => ({
          name: cmd.name,
          description: cmd.description,
          shortcut: cmd.shortcut || 'Not configured',
          isGlobal: cmd.global || false,
        }));
        resolve(shortcuts);
      });
    });
  }

  async getShortcutForCommand(commandName: string): Promise<string | null> {
    const shortcuts = await this.getAllShortcuts();
    const command = shortcuts.find((s) => s.name === commandName);
    return command?.shortcut ?? null;
  }

  openShortcutSettings(): void {
    // Deep link to Chrome's shortcut settings
    chrome.tabs.create({ url: 'chrome://extensions/shortcuts' });
  }
}

export const shortcutManager = new ShortcutManager();

Shortcut Display Component

When building an options page for your extension, displaying current shortcut bindings helps users understand what shortcuts are available and how they’re configured:

// components/ShortcutDisplay.tsx
import React, { useEffect, useState } from 'react';
import { shortcutManager, ShortcutInfo } from '../utils/shortcut-manager';

interface ShortcutDisplayProps {
  onOpenSettings?: () => void;
}

export const ShortcutDisplay: React.FC<ShortcutDisplayProps> = ({ 
  onOpenSettings 
}) => {
  const [shortcuts, setShortcuts] = useState<ShortcutInfo[]>([]);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    loadShortcuts();
  }, []);

  const loadShortcuts = async () => {
    try {
      const allShortcuts = await shortcutManager.getAllShortcuts();
      setShortcuts(allShortcuts);
    } catch (error) {
      console.error('Failed to load shortcuts:', error);
    } finally {
      setLoading(false);
    }
  };

  const handleConfigure = () => {
    if (onOpenSettings) {
      onOpenSettings();
    } else {
      shortcutManager.openShortcutSettings();
    }
  };

  if (loading) {
    return <div className="shortcuts-loading">Loading shortcuts...</div>;
  }

  return (
    <div className="shortcut-display">
      <h3>Keyboard Shortcuts</h3>
      <ul className="shortcut-list">
        {shortcuts.map((shortcut) => (
          <li key={shortcut.name} className="shortcut-item">
            <span className="shortcut-description">{shortcut.description}</span>
            <kbd className="shortcut-key">
              {shortcut.shortcut}
              {shortcut.isGlobal && <span className="global-badge">Global</span>}
            </kbd>
          </li>
        ))}
      </ul>
      <button onClick={handleConfigure} className="configure-button">
        Configure Shortcuts
      </button>
      <p className="shortcut-hint">
        Click on a shortcut above or use the button to open Chrome's shortcut settings.
        Changes take effect immediately.
      </p>
    </div>
  );
};

This component displays all registered shortcuts with their current bindings and includes a button that deep-links to Chrome’s shortcut configuration page. Providing this integration makes your extension feel more polished and gives users clear guidance on how to customize their experience.

Content Script Keyboard Listeners

While the Commands API handles shortcuts at the extension level, sometimes you need keyboard shortcuts that work specifically within the context of a web page. Content scripts can listen for keyboard events directly, enabling in-page shortcut functionality that’s specific to particular websites or page types.

Building a Content Script Keyboard Manager

Content script keyboard listeners give you fine-grained control over how shortcuts behave on specific pages, but they require careful implementation to avoid conflicts with page functionality:

// content-scripts/keyboard-manager.ts
interface ShortcutDefinition {
  key: string;
  modifiers?: {
    ctrl?: boolean;
    shift?: boolean;
    alt?: boolean;
    meta?: boolean;
  };
  handler: (event: KeyboardEvent, context: PageContext) => void;
  description: string;
  preventDefault?: boolean;
}

interface PageContext {
  selectedText: string;
  activeElement: HTMLElement | null;
  pageUrl: string;
}

class KeyboardShortcutManager {
  private shortcuts: Map<string, ShortcutDefinition> = new Map();
  private enabled: boolean = true;

  constructor() {
    this.setupListeners();
  }

  register(name: string, definition: ShortcutDefinition): void {
    this.shortcuts.set(name, definition);
  }

  enable(): void {
    this.enabled = true;
  }

  disable(): void {
    this.enabled = false;
  }

  private setupListeners(): void {
    document.addEventListener('keydown', this.handleKeyDown.bind(this), true);
  }

  private handleKeyDown(event: KeyboardEvent): void {
    if (!this.enabled) return;
    
    // Don't intercept when user is typing in input fields
    const target = event.target as HTMLElement;
    if (this.isInputElement(target) && !this.isKeyboardShortcutCombo(event)) {
      return;
    }

    const context = this.buildContext();
    const matchingShortcut = this.findMatchingShortcut(event);

    if (matchingShortcut) {
      if (matchingShortcut.preventDefault !== false) {
        event.preventDefault();
        event.stopPropagation();
      }
      matchingShortcut.handler(event, context);
    }
  }

  private isInputElement(element: HTMLElement): boolean {
    const tagName = element.tagName.toLowerCase();
    return (
      tagName === 'input' ||
      tagName === 'textarea' ||
      element.isContentEditable
    );
  }

  private isKeyboardShortcutCombo(event: KeyboardEvent): boolean {
    return event.ctrlKey || event.metaKey || event.altKey;
  }

  private findMatchingShortcut(
    event: KeyboardEvent
  ): ShortcutDefinition | undefined {
    for (const shortcut of this.shortcuts.values()) {
      const keyMatch =
        event.key.toLowerCase() === shortcut.key.toLowerCase() ||
        event.code.toLowerCase() === shortcut.key.toLowerCase();
      
      if (!keyMatch) continue;

      const modifiers = shortcut.modifiers || {};
      const ctrlMatch = (modifiers.ctrl || false) === event.ctrlKey;
      const shiftMatch = (modifiers.shift || false) === event.shiftKey;
      const altMatch = (modifiers.alt || false) === event.altKey;
      const metaMatch = (modifiers.meta || false) === event.metaKey;

      if (ctrlMatch && shiftMatch && altMatch && metaMatch) {
        return shortcut;
      }
    }

    return undefined;
  }

  private buildContext(): PageContext {
    const selection = window.getSelection();
    return {
      selectedText: selection?.toString() || '',
      activeElement: document.activeElement,
      pageUrl: window.location.href,
    };
  }
}

export const keyboardManager = new KeyboardShortcutManager();

// Register Vim-style navigation shortcuts
keyboardManager.register('go-home', {
  key: 'g',
  modifiers: { shift: true },
  handler: () => {
    window.scrollTo(0, 0);
  },
  description: 'Go to top of page',
});

keyboardManager.register('go-bottom', {
  key: 'G',
  handler: () => {
    window.scrollTo(0, document.body.scrollHeight);
  },
  description: 'Go to bottom of page',
});

keyboardManager.register('scroll-up', {
  key: 'k',
  handler: () => {
    window.scrollBy(0, -100);
  },
  description: 'Scroll up',
});

keyboardManager.register('scroll-down', {
  key: 'j',
  handler: () => {
    window.scrollBy(0, 100);
  },
  description: 'Scroll down',
});

Conflict Detection for Content Scripts

One of the most important considerations when implementing content script keyboard listeners is detecting and handling conflicts with existing page shortcuts. The KeyboardShortcutManager above already includes logic to avoid intercepting normal typing, but you should also implement explicit conflict detection:

// content-scripts/conflict-detector.ts
class ShortcutConflictDetector {
  private knownConflicts: Map<string, string[]> = new Map([
    ['j', ['Disqus, Facebook - scroll to next comment']],
    ['k', ['Disqus, Facebook - scroll to previous comment']],
    ['/', ['Gmail, Reddit - focus search']],
    ['t', ['Gmail - new tab']],
  ]);

  detectConflict(key: string, pageUrl: string): string | null {
    const potentialConflicts = this.knownConflicts.get(key.toLowerCase());
    
    if (!potentialConflicts) return null;

    // Check if current page matches any known conflict sources
    const currentHost = new URL(pageUrl).hostname;
    
    for (const conflict of potentialConflicts) {
      if (this.hostnameMatches(conflict, currentHost)) {
        return conflict;
      }
    }

    return null;
  }

  private hostnameMatches(conflictSource: string, currentHost: string): boolean {
    const sourceDomain = conflictSource.toLowerCase().split(' ')[0];
    return currentHost.includes(sourceDomain) || 
           sourceDomain.includes(currentHost.split('.')[0]);
  }

  suggestAlternative(baseKey: string): string {
    const alternatives: Record<string, string[]> = {
      'j': ['n', 'ArrowDown'],
      'k': ['p', 'ArrowUp'],
      '/': ['s', 'f'],
    };
    return alternatives[baseKey]?.[0] || baseKey;
  }
}

export const conflictDetector = new ShortcutConflictDetector();

Shortcut Conflict Handling

Beyond content script conflicts, your extension needs to handle conflicts at the browser level. Chrome reserves certain shortcuts for core browser functionality, and other extensions may have registered shortcuts that conflict with yours.

Detecting and Handling Conflicts

// background/conflict-handler.ts
interface ShortcutConflict {
  command: string;
  shortcut: string;
  conflictingWith: string;
}

class ConflictHandler {
  private reservedShortcuts = new Set([
    'Ctrl+T', 'Ctrl+W', 'Ctrl+N', 'Ctrl+Shift+N',
    'Ctrl+Tab', 'Ctrl+Shift+Tab', 'Ctrl+PageUp', 'Ctrl+PageDown',
    'Ctrl+L', 'Ctrl+D', 'Ctrl+H', 'Ctrl+J', 'Ctrl+Shift+Delete',
    'F5', 'F11', 'Ctrl+F', 'Ctrl+G', 'Ctrl+Shift+G',
  ]);

  isReserved(shortcut: string): boolean {
    return this.reservedShortcuts.has(shortcut);
  }

  async detectConflicts(commands: string[]): Promise<ShortcutConflict[]> {
    const allCommands = await this.getAllExtensionCommands();
    const conflicts: ShortcutConflict[] = [];

    for (const command of commands) {
      const myShortcut = allCommands.find((c) => c.name === command);
      if (!myShortcut?.shortcut) continue;

      // Check against reserved shortcuts
      if (this.isReserved(myShortcut.shortcut)) {
        conflicts.push({
          command,
          shortcut: myShortcut.shortcut,
          conflictingWith: 'Chrome browser',
        });
      }

      // Check against other extensions (simplified - Chrome doesn't expose this directly)
      for (const other of allCommands) {
        if (other.name === command) continue;
        if (other.shortcut === myShortcut.shortcut) {
          conflicts.push({
            command,
            shortcut: myShortcut.shortcut,
            conflictingWith: `Extension: ${other.name}`,
          });
        }
      }
    }

    return conflicts;
  }

  private getAllExtensionCommands(): Promise<chrome.commands.Command[]> {
    return new Promise((resolve) => {
      chrome.commands.getAll(resolve);
    });
  }

  getFallbackShortcut(primary: string): string {
    const fallbacks: Record<string, string> = {
      'Ctrl+Shift+S': 'Alt+Shift+S',
      'Ctrl+Shift+N': 'Alt+Shift+N',
      'Ctrl+Shift+T': 'Alt+Shift+T',
    };
    return fallbacks[primary] || primary;
  }
}

export const conflictHandler = new ConflictHandler();

When your extension detects a conflict, it’s best to provide graceful degradation. Rather than failing silently, you can implement fallback behavior or clearly communicate to users that their chosen shortcut isn’t available and suggest alternatives.

Accessibility Considerations

Accessibility in keyboard shortcut implementation goes beyond simply providing shortcuts—it involves ensuring all users can discover, configure, and use shortcuts effectively, including those who rely on assistive technologies.

Screen Reader Compatibility

When implementing shortcuts, you must ensure that any visual feedback or state changes are properly announced to screen readers. This means using ARIA live regions for dynamic content updates and ensuring that shortcut-triggered actions are communicated appropriately:

// components/AccessibleShortcutHelp.tsx
import React, { useState, useEffect } from 'react';

interface ShortcutItem {
  key: string;
  description: string;
  category: string;
}

interface AccessibleShortcutHelpProps {
  shortcuts: ShortcutItem[];
}

export const AccessibleShortcutHelp: React.FC<AccessibleShortcutHelpProps> = ({
  shortcuts,
}) => {
  const [isOpen, setIsOpen] = useState(false);
  const [announcement, setAnnouncement] = useState('');

  useEffect(() => {
    const handleKeyDown = (event: KeyboardEvent) => {
      // Press ? to open help
      if (event.key === '?' || (event.key === '/' && event.shiftKey)) {
        event.preventDefault();
        setIsOpen((prev) => !prev);
        setAnnouncement(isOpen ? 'Help closed' : 'Shortcut help opened');
      }
      
      // Escape to close
      if (event.key === 'Escape' && isOpen) {
        setIsOpen(false);
        setAnnouncement('Help closed');
      }
    };

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

  // Group shortcuts by category
  const groupedShortcuts = shortcuts.reduce((acc, shortcut) => {
    if (!acc[shortcut.category]) {
      acc[shortcut.category] = [];
    }
    acc[shortcut.category].push(shortcut);
    return acc;
  }, {} as Record<string, ShortcutItem[]>);

  return (
    <>
      {/* Live region for screen reader announcements */}
      <div
        role="status"
        aria-live="polite"
        aria-atomic="true"
        className="sr-only"
      >
        {announcement}
      </div>

      {isOpen && (
        <div
          className="shortcut-help-overlay"
          role="dialog"
          aria-labelledby="shortcut-help-title"
          aria-modal="true"
          onKeyDown={(e) => e.stopPropagation()}
        >
          <div className="shortcut-help-content">
            <h2 id="shortcut-help-title">Keyboard Shortcuts</h2>
            
            {Object.entries(groupedShortcuts).map(([category, items]) => (
              <div key={category} className="shortcut-category">
                <h3>{category}</h3>
                <ul>
                  {items.map((shortcut) => (
                    <li key={shortcut.key}>
                      <kbd>{shortcut.key}</kbd>
                      <span>{shortcut.description}</span>
                    </li>
                  ))}
                </ul>
              </div>
            ))}

            <p className="shortcut-help-footer">
              Press <kbd>Esc</kbd> to close this help dialog.
            </p>
          </div>
        </div>
      )}
    </>
  );
};

Providing Non-Keyboard Alternatives

Every action triggered by a keyboard shortcut should also be accessible through standard mouse or touch interactions. This principle ensures that users who cannot use keyboard shortcuts still have full access to your extension’s functionality. Document your shortcuts clearly in your extension’s UI, options page, and any help documentation.

Advanced Patterns: Chord-Based Shortcuts

For truly power-user extensions, consider implementing chord-based (sequential) shortcuts similar to Vim’s modal navigation. This pattern allows you to create rich keyboard interfaces without requiring users to memorize complex modifier combinations.

Chord Manager Implementation

// utils/chord-manager.ts
type ChordHandler = () => void | Promise<void>;

interface ChordSequence {
  sequence: string[];
  handler: ChordHandler;
  description: string;
  timeout: number;
}

class ChordManager {
  private sequences: Map<string, ChordSequence> = new Map();
  private currentSequence: string[] = [];
  private timeoutId: number | null = null;
  private isActive: boolean = false;

  register(
    name: string,
    sequence: string[],
    handler: ChordHandler,
    description: string,
    timeout: number = 1000
  ): void {
    this.sequences.set(name, {
      sequence,
      handler,
      description,
      timeout,
    });
  }

  handleKeyDown(event: KeyboardEvent): boolean {
    // Ignore if typing in input
    const target = event.target as HTMLElement;
    if (target.tagName === 'INPUT' || 
        target.tagName === 'TEXTAREA' || 
        target.isContentEditable) {
      return false;
    }

    const key = event.key.toLowerCase();

    // Start sequence with initial key
    if (this.currentSequence.length === 0) {
      this.startSequence(key, event);
      return true;
    }

    // Continue sequence
    this.currentSequence.push(key);
    this.checkSequence();

    return true;
  }

  private startSequence(key: string, event: KeyboardEvent): void {
    for (const [name, chord] of this.sequences) {
      if (chord.sequence[0]?.toLowerCase() === key) {
        event.preventDefault();
        this.currentSequence = [key];
        this.setTimeout(chord.timeout);
        return;
      }
    }
  }

  private checkSequence(): void {
    const currentSeqString = this.currentSequence.join('');

    for (const [name, chord] of this.sequences) {
      const chordString = chord.sequence.join('');
      
      if (currentSeqString === chordString) {
        this.executeAndReset(chord.handler);
        return;
      }

      // Check if current sequence is a prefix of any valid sequence
      if (!chordString.startsWith(currentSeqString)) {
        // Invalid sequence, reset
        this.reset();
        return;
      }
    }
  }

  private executeAndReset(handler: ChordHandler): void {
    if (this.timeoutId) {
      clearTimeout(this.timeoutId);
      this.timeoutId = null;
    }
    handler();
    this.currentSequence = [];
  }

  private reset(): void {
    if (this.timeoutId) {
      clearTimeout(this.timeoutId);
      this.timeoutId = null;
    }
    this.currentSequence = [];
  }

  private setTimeout(ms: number): void {
    this.reset();
    this.timeoutId = window.setTimeout(() => {
      this.reset();
    }, ms);
  }

  getRegisteredSequences(): Array<{ keys: string; description: string }> {
    return Array.from(this.sequences.values()).map((chord) => ({
      keys: chord.sequence.join(''),
      description: chord.description,
    }));
  }
}

export const chordManager = new ChordManager();

// Register Vim-style sequential shortcuts
chordManager.register(
  'go-home',
  ['g', 'h'],
  () => window.scrollTo(0, 0),
  'Go to top of page',
  1500
);

chordManager.register(
  'go-bottom',
  ['g', 'G'],
  () => window.scrollTo(0, document.body.scrollHeight),
  'Go to bottom of page',
  1500
);

chordManager.register(
  'open-settings',
  [',', 's'],
  () => {
    // Open settings panel
    console.log('Opening settings');
  },
  'Open settings',
  1500
);

This chord manager allows users to press a sequence of keys rather than holding multiple modifiers simultaneously. The timeout parameter ensures that sequences reset if the user takes too long between key presses, preventing accidental activations.

Complete Example: Productivity Extension

Putting together all the concepts from this guide, here’s a comprehensive productivity extension that demonstrates best practices for keyboard shortcuts:

Manifest Configuration

{
  "manifest_version": 3,
  "name": "Productivity Booster Pro",
  "version": "1.0",
  "description": "Enhance your browsing with powerful keyboard shortcuts",
  "permissions": ["activeTab", "storage", "sidePanel", "tabs"],
  "host_permissions": ["<all_urls>"],
  "background": {
    "service_worker": "background.js",
    "type": "module"
  },
  "action": {
    "default_title": "Open Productivity Booster"
  },
  "commands": {
    "save-to-reading-list": {
      "suggested_key": {
        "default": "Ctrl+Shift+S",
        "mac": "Command+Shift+S"
      },
      "description": "Save current page to reading list"
    },
    "quick-note": {
      "suggested_key": {
        "default": "Ctrl+Shift+N",
        "mac": "Command+Shift+N"
      },
      "description": "Create a quick note on current page"
    },
    "toggle-sidebar": {
      "suggested_key": {
        "default": "Ctrl+Shift+T",
        "mac": "Command+Shift+T"
      },
      "description": "Toggle extension sidebar"
    },
    "quick-capture": {
      "suggested_key": {
        "default": "Ctrl+Shift+Space",
        "mac": "Command+Shift+Space"
      },
      "description": "Quick capture from anywhere",
      "global": true
    },
    "_execute_action": {
      "suggested_key": {
        "default": "Ctrl+Shift+Y",
        "mac": "Command+Shift+Y"
      },
      "description": "Toggle extension popup"
    }
  },
  "content_scripts": [{
    "matches": ["<all_urls>"],
    "js": ["content-script.js"],
    "run_at": "document_idle"
  }]
}

Background Service Worker

// background.ts
import { CommandRouter } from './command-router';
import { conflictHandler } from './conflict-handler';

const router = new CommandRouter();

// Initialize command router
router.register('save-to-reading-list', async (ctx) => {
  if (!ctx.tab?.id || !ctx.tab.url) return;
  
  const { readingList = [] } = await chrome.storage.local.get('readingList');
  
  readingList.push({
    url: ctx.tab.url,
    title: ctx.tab.title || 'Untitled',
    timestamp: Date.now(),
    favIconUrl: ctx.tab.favIconUrl,
  });
  
  await chrome.storage.local.set({ readingList });
  
  // Visual feedback
  await chrome.action.setBadgeText({ text: '' });
  await chrome.action.setBadgeBackgroundColor({ color: '#4CAF50' });
  setTimeout(() => chrome.action.setBadgeText({ text: '' }), 2000);
  
  // Notify content script if sidebar is open
  chrome.tabs.sendMessage(ctx.tab.id, { action: 'saved' });
});

router.register('quick-note', async (ctx) => {
  if (!ctx.tab?.id) return;
  await chrome.sidePanel.open({ tabId: ctx.tab.id });
});

router.register('toggle-sidebar', async (ctx) => {
  if (!ctx.tab?.id) return;
  await chrome.sidePanel.toggle({ tabId: ctx.tab.id });
});

router.register('quick-capture', async (ctx) => {
  // Global capture - works even when Chrome isn't focused
  const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
  
  // Create a new tab with capture UI or open side panel
  if (tab?.id) {
    await chrome.sidePanel.open({ tabId: tab.id });
  }
});

// Register command listener
chrome.commands.onCommand.addListener((command) => {
  router.dispatch(command);
});

// Check for conflicts on startup
chrome.runtime.onInstalled.addListener(async () => {
  const commands = ['save-to-reading-list', 'quick-note', 'toggle-sidebar', 'quick-capture'];
  const conflicts = await conflictHandler.detectConflicts(commands);
  
  if (conflicts.length > 0) {
    console.warn('Shortcut conflicts detected:', conflicts);
    // Could notify user via notification or in options page
  }
});

Options Page Integration

// options/shortcuts-page.tsx
import React from 'react';
import { ShortcutDisplay } from '../components/ShortcutDisplay';
import { shortcutManager } from '../utils/shortcut-manager';

export const ShortcutsPage: React.FC = () => {
  const handleOpenSettings = () => {
    shortcutManager.openShortcutSettings();
  };

  return (
    <div className="options-container">
      <h1>Keyboard Shortcuts</h1>
      <p className="intro">
        Boost your productivity with keyboard shortcuts. Press the keys to 
        trigger actions quickly, or customize them to fit your workflow.
      </p>
      
      <ShortcutDisplay onOpenSettings={handleOpenSettings} />
      
      <div className="shortcut-tips">
        <h2>Tips</h2>
        <ul>
          <li>Shortcuts only work when Chrome is in focus unless marked as "Global"</li>
          <li>Press <kbd>?</kbd> on any page to see available in-page shortcuts</li>
          <li>Customize shortcuts by clicking the button below</li>
          <li>Global shortcuts require additional permissions on macOS</li>
        </ul>
      </div>
    </div>
  );
};

Conclusion

Implementing keyboard shortcuts in Chrome extensions requires understanding several interconnected systems: the Commands API for extension-level shortcuts, content script listeners for page-specific shortcuts, global shortcut registration for system-wide actions, and proper conflict handling to ensure reliable operation. The investment in building robust shortcut support pays dividends through improved user engagement and satisfaction.

Remember to prioritize accessibility throughout your implementation, providing non-keyboard alternatives for all actions and ensuring that screen readers can communicate shortcut-triggered state changes. For advanced use cases, chord-based sequential shortcuts offer a powerful way to provide rich keyboard interfaces without overwhelming users with modifier combinations.

For monetization considerations, keyboard shortcuts represent a compelling premium feature that can differentiate your extension in the marketplace. Many successful productivity extensions offer advanced shortcut customization or additional chord sequences as part of their paid tier, as discussed in the extension monetization strategies. By following the patterns and practices outlined in this guide, you’ll be well-equipped to implement keyboard shortcuts that your users will rely on as an essential part of their browsing workflow.


Built by Zovo - Open-source tools and guides for extension developers.

No previous article
No next article