Customizable Keyboard Shortcuts

Permalink to "Customizable Keyboard Shortcuts"

Customizable shortcuts solve two problems at once: users whose assistive technology claims a key can move the app’s shortcut elsewhere, and SC 2.1.4 is satisfied for any single-character shortcut that can be remapped or turned off. The settings screen itself must be accessible. The tricky part is capturing a new key combination: the capture field must accept keys like Tab and Escape without becoming a keyboard trap, and screen reader users need to hear what was recorded.

This guide belongs to keyboard shortcuts and command palettes.

Spec reference

Permalink to "Spec reference"
  • SC 2.1.4 Character Key Shortcuts (A) — single-key shortcuts can be turned off or remapped to include modifiers.
  • SC 2.1.2 No Keyboard Trap (A) — the capture field must be escapable.
  • SC 4.1.3 (AA) — the recorded combination and conflicts announced.
  • aria-keyshortcuts — updated to the user’s mapping.
Remapping a shortcut Flow: the user activates Change for Export; capture mode starts and is announced; the user presses Ctrl+Alt+E; the app checks for conflicts; the new keys are saved and announced; aria-keyshortcuts updates. Remapping a shortcutChange button"Change shortcut for Export"Capture mode"Press new keys. Escape to cancel."Key pressCtrl+Alt+EConflict checknoneSaved"Export: Ctrl+Alt+E" announced
Capture mode is explicit and time-limited.

When to use — and when not to

Permalink to "When to use — and when not to"

Offer customization in any app with more than a handful of shortcuts, and always when you use single-key shortcuts.

Do not build capture fields that swallow every key with no escape. The misapplication: a “press keys” input that captures Tab and Escape too, so keyboard users can never leave it.

Trapping capture versus explicit capture mode Comparison: always-on capture fields trap Tab and Escape; an explicit capture mode started by a button and ended by Escape or a completed combination never traps. Trapping capture versus explicit capture mode✗ Input that captures all keysTab recorded, cannot leaveSilent recordingNo conflict warnings✓ Capture mode started by buttonEscape cancelsRecorded keys announcedConflicts explained

Annotated code example

Permalink to "Annotated code example"
<table>
  <caption>Keyboard shortcuts</caption>
  <thead><tr><th scope="col">Action</th><th scope="col">Keys</th><th scope="col">Change</th><th scope="col">Enabled</th></tr></thead>
  <tbody>
    <tr><th scope="row">Export</th><td id="k-export">Ctrl+Shift+E</td>
      <td><button type="button" aria-describedby="k-export">Change shortcut for Export</button></td>
      <td><input type="checkbox" checked aria-label="Export shortcut enabled"></td></tr>
  </tbody>
</table>
<button type="button">Reset all shortcuts</button>
<p role="status" id="kb-status"></p>
function startCapture(action, btn) {
  status.textContent = `Press new keys for ${action}. Escape to cancel.`;
  btn.textContent = 'Listening…';
  const onKey = e => {
    e.preventDefault();
    if (e.key === 'Escape') return end('Cancelled.');                     // never a trap
    if (['Shift', 'Control', 'Alt', 'Meta'].includes(e.key)) return;      // wait for a real key
    const combo = toCombo(e);                                             // e.g. "Control+Alt+E"
    const clash = findConflict(combo);
    if (clash) return end(`${pretty(combo)} is used by ${clash}. Choose another.`);
    save(action, combo); end(`${action}: ${pretty(combo)}.`);
  };
  const end = msg => { removeEventListener('keydown', onKey, true); btn.textContent = `Change shortcut for ${action}`; status.textContent = msg; btn.focus(); };
  addEventListener('keydown', onKey, true);
}

Keyboard & AT behaviour

Permalink to "Keyboard & AT behaviour"
Step Focus Announcement
Activate Change Button (now “Listening…”) “Press new keys for Export. Escape to cancel.”
Press Ctrl+Alt+E Button “Export: Ctrl+Alt+E.”
Press a conflicting combo Button “Ctrl+F is used by the browser. Choose another.”
Escape Button “Cancelled.”
Toggle Enabled Checkbox “Export shortcut enabled, not checked”
Handling a conflict during capture Sequence: the user starts capture, presses Ctrl+F, the app finds a browser conflict and announces it, capture ends, the user starts again and presses Ctrl+Alt+F, which is saved. Handling a conflict during captureUserSettingsCheckerStatusChange shortcut for FilterCtrl+Fconflict? yes, browser Find"Ctrl+F is used by thebrowser"Change, then Ctrl+Alt+F"Filter: Ctrl+Alt+F"
Conflicts are explained, not silently rejected.

Step-by-step implementation

Permalink to "Step-by-step implementation"
  1. Store a shortcut map. Keep action IDs mapped to key combinations and enabled flags, with defaults alongside.
  2. Render the settings table. One row per action, with current keys, a Change button and an Enabled checkbox.
  3. Capture explicitly. Start capture with the button, end it with a non-modifier key or Escape, and announce both start and result.
  4. Check conflicts. Against the app’s other shortcuts, browser keys and a list of screen reader keys.
  5. Apply everywhere. Handlers read from the map; aria-keyshortcuts attributes and help text are generated from it.

Integration context

Permalink to "Integration context"

Reserved keys are listed in avoiding shortcut conflicts with screen readers. The criterion is covered in single-character shortcuts and SC 2.1.4. Help dialogs follow keyboard shortcut help dialogs. Command palettes, which can also show shortcuts, are in accessible command palette for data apps.

Gotchas

Permalink to "Gotchas"

Screen reader interception. Some combinations never reach the page because the screen reader takes them; if capture receives nothing, time out after a few seconds and explain.

Platform display. Show “⌘” on macOS and “Ctrl” elsewhere, but announce words (“Command”) rather than symbols.

Persistence. Store mappings per user, not per device, if users move between machines.

Edge cases

Permalink to "Edge cases"
  • Duplicate across scopes. The same keys can be used in different widgets if scopes do not overlap; the checker must know scopes.
  • Disabled defaults. If a user disables a shortcut, the help dialog should show it as off, not hide it.
  • Import and export. Power users appreciate exporting their map; keep the format readable.

Worked example

Permalink to "Worked example"

A data annotation tool had thirty single-letter shortcuts that conflicted with JAWS quick navigation. Rather than removing them, the team added a shortcut settings table with explicit capture, conflict checks against a JAWS and NVDA key list, per-shortcut toggles and a “Use modifier keys for all” preset. JAWS users enabled the preset in one step, and the app met 2.1.4.

Common mistakes

Permalink to "Common mistakes"
  • Capture fields that trap Tab and Escape.
  • Silent capture with no announcement.
  • No conflict checks.
  • Help text not updated after remapping.

Testing checklist

Permalink to "Testing checklist"

Testing it with a screen reader

Permalink to "Testing it with a screen reader"

With NVDA, navigate the settings table with table commands and confirm each row reads action, keys and controls. Activate a Change button and listen for the capture prompt. Press a new combination and listen for the confirmation; then press a known conflict and confirm the explanation. Press Escape during capture and check that focus returns to the button. Repeat with JAWS, noting any combinations it intercepts, and with VoiceOver.

Offering presets

Permalink to "Offering presets"

Not every user wants to remap thirty shortcuts one by one. Offer presets: “Default”, “Modifier keys only” (adds Ctrl+Shift to every single-key shortcut), and “Off”. A screen reader user can choose “Modifier keys only” once and keep every shortcut without conflicts. Presets also make support easier, because a user can describe their setup in two words. Show which preset is active, and switch the label to “Custom” as soon as a user changes any individual shortcut.

Validating mappings on load

Permalink to "Validating mappings on load"

Stored mappings can go stale. A new release may add a default shortcut that collides with one a user mapped months ago, or remove an action entirely. When the app loads, validate the stored map against current actions and defaults. Drop mappings for removed actions, and if a new default collides with a user’s custom mapping, keep the user’s choice, leave the new action unmapped and show a one-time notice listing what changed. Silent reassignment would break muscle memory, and users who rely on remapped keys because of their assistive technology are hit hardest.

Key points

Permalink to "Key points"
  • Start capture with a button and always let Escape cancel it.
  • Announce the recorded keys and any conflict in words.
  • Generate aria-keyshortcuts and help text from the stored map.

Design system notes

Permalink to "Design system notes"

Build customization into the shared shortcut service, so every feature’s shortcuts appear in the settings table automatically.

FAQ

Permalink to "FAQ"
Does remapping satisfy 2.1.4?

Yes, if users can remap single-key shortcuts to include modifiers, or turn them off.

Should capture use a text input?

A button-started mode is more robust than an input.

What if two actions share keys?

Prevent it within a scope and explain the conflict.

How do I announce key combinations?

In words: “Control plus Alt plus E”.

Should settings sync across devices?

Ideally, per user account.

Can I allow single keys in custom mappings?

Yes, but warn about screen reader conflicts.

Permalink to "Related"

← Back to Keyboard Shortcuts & Command Palettes