Accessibility pattern · Interaction and motion

Keyboard shortcuts

A single letter as a shortcut is fast for some people and a trap for others: speech input and a stray key press set it off by accident. So here j, k and e start switched off, can be changed to other keys, only work while the message list has focus, and every one repeats a visible button.

WCAG criteria
6
Keyboard rules
7
Checked with
axe, keyboard and the inspector

Live demo

Try it

Use it with a mouse, a keyboard or a screen reader. The inspector beside it shows what the browser tells assistive technology as you go: focus, state changes and announcements.

Inbox

3 unread

  • Meera Iyer New Lunch on Friday? Are we still on for 1 pm at Koshy's? I can book a table. 10:24
  • Rohan Mehta New Site visit notes Photos from Whitefield are in the shared folder. Two snags to fix. 09:02
  • Ananya Rao Invoice 2041 Paid today by UPI. The receipt is attached. Yesterday
  • Kabir Shah New Tile samples The terracotta ones arrive on Monday. Yesterday
  • Priya Nair Weekend plans Trek to Skandagiri on Saturday, back by noon. Mon

Letters typed here never set off a shortcut. Ctrl+Enter sends.

Shortcut settings

Off by default, so typing and speech input never set them off. They work only while the message list has focus.

Change keys

Press one letter or digit. ? stays for help.

Keyboard shortcuts

Single-key shortcuts are off. Turn them on in Shortcut settings.

In the message list

j
Next message
k
Previous message
e
Archive
?
Show this list

Always on

↓ ↑
Move through the messages
Home End
First or last message
Ctrl Enter
Send a quick reply
Esc
Close this list

The demo works without JavaScript only as far as its HTML does; the inspector needs JavaScript.

Keyboard

Keys it answers to

Every action works without a pointer.

KeyWhat it does
Arrow Down or Arrow UpIn the message list, selects the next or previous message. Always on: arrow keys are not character keys.
Home or EndIn the message list, selects the first or last message.
J or KWith single-key shortcuts on and focus in the message list, selects the next or previous message.
EWith single-key shortcuts on and focus in the message list, archives the selected message.
?With single-key shortcuts on and focus in the message list, opens the list of shortcuts.
ControlEnterIn the quick reply, sends it. A modified shortcut, so it stays on and works inside the text field.
EscapeCloses the shortcuts dialog and returns focus to where it was.

Screen readers

What it announces

Written from the roles, names and states in the markup.

WhenExpected announcement
Focus reaches the message listMessages, list box. Meera Iyer New Lunch on Friday? Are we still on for 1 pm at Koshy's? I can book a table. 10:24, selected, 1 of 5
Single-key shortcuts are turned onSingle-key shortcuts, switch, on. Single-key shortcuts on
Focus reaches the Next button with shortcuts onNext, button, shortcut j
E archives the selected messageMessage archived
e is pressed in the Next message key fieldArchive already uses this key.
New keys are savedShortcut keys saved

These are expected announcements, not recordings. Wording and order differ between screen readers and browsers.

Code

Copy the code

The exact files this demo runs on. The styles are served with a prefix that keeps this site's own styles out of the demo; what you copy is the original.

The markup

<div class="ap-kbs" data-ap-keyboard-shortcuts>
  <div class="ap-kbs__layout">
  <section class="ap-kbs__app" aria-labelledby="kbs-inbox-name">
    <div class="ap-kbs__top">
      <h3 class="ap-kbs__name" id="kbs-inbox-name" tabindex="-1">Inbox</h3>
      <p class="ap-kbs__count"><span data-ap-unread translate="no">3</span> <span>unread</span></p>
      <button type="button" class="ap-btn ap-btn--ghost ap-kbs__help-btn" aria-haspopup="dialog" data-ap-help>
        <svg class="ap-btn__icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><rect x="2.5" y="6" width="19" height="12" rx="2"/><path d="M6.5 10h1m3 0h1m3 0h1m3 0h1M8 14h8"/></svg>
        Keyboard shortcuts
        <kbd class="ap-kbs__cap" aria-hidden="true" translate="no">?</kbd>
      </button>
    </div>

    <div class="ap-kbs__actions" role="group" aria-label="Message actions">
        <button type="button" class="ap-btn ap-kbs__act" data-ap-cmd="prev">
          <svg class="ap-btn__icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="m18 15-6-6-6 6"/></svg>
          Previous
          <kbd class="ap-kbs__cap" data-ap-keycap="prev" aria-hidden="true" translate="no">k</kbd>
        </button>
        <button type="button" class="ap-btn ap-kbs__act" data-ap-cmd="next">
          <svg class="ap-btn__icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="m6 9 6 6 6-6"/></svg>
          Next
          <kbd class="ap-kbs__cap" data-ap-keycap="next" aria-hidden="true" translate="no">j</kbd>
        </button>
        <button type="button" class="ap-btn ap-kbs__act" data-ap-cmd="archive">
          <svg class="ap-btn__icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><rect x="3" y="4" width="18" height="5" rx="1"/><path d="M5 9v10a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V9M10 13h4"/></svg>
          Archive
          <kbd class="ap-kbs__cap" data-ap-keycap="archive" aria-hidden="true" translate="no">e</kbd>
        </button>
    </div>

    <ul class="ap-kbs__list" role="listbox" aria-label="Messages" tabindex="0" aria-activedescendant="kbs-msg-1" data-ap-list>
      <li class="ap-kbs__msg" role="option" id="kbs-msg-1" aria-selected="true" data-unread>
        <span class="ap-kbs__avatar" aria-hidden="true" translate="no">MI</span>
        <span class="ap-kbs__msg-main">
          <span class="ap-kbs__msg-top"><span class="ap-kbs__from">Meera Iyer</span> <span class="ap-kbs__new">New</span></span>
          <span class="ap-kbs__subject">Lunch on Friday?</span>
          <span class="ap-kbs__snippet">Are we still on for 1 pm at Koshy's? I can book a table.</span>
        </span>
        <span class="ap-kbs__time">10:24</span>
      </li>
      <li class="ap-kbs__msg" role="option" id="kbs-msg-2" aria-selected="false" data-unread>
        <span class="ap-kbs__avatar" aria-hidden="true" translate="no">RM</span>
        <span class="ap-kbs__msg-main">
          <span class="ap-kbs__msg-top"><span class="ap-kbs__from">Rohan Mehta</span> <span class="ap-kbs__new">New</span></span>
          <span class="ap-kbs__subject">Site visit notes</span>
          <span class="ap-kbs__snippet">Photos from Whitefield are in the shared folder. Two snags to fix.</span>
        </span>
        <span class="ap-kbs__time">09:02</span>
      </li>
      <li class="ap-kbs__msg" role="option" id="kbs-msg-3" aria-selected="false">
        <span class="ap-kbs__avatar" aria-hidden="true" translate="no">AR</span>
        <span class="ap-kbs__msg-main">
          <span class="ap-kbs__msg-top"><span class="ap-kbs__from">Ananya Rao</span></span>
          <span class="ap-kbs__subject">Invoice 2041</span>
          <span class="ap-kbs__snippet">Paid today by UPI. The receipt is attached.</span>
        </span>
        <span class="ap-kbs__time">Yesterday</span>
      </li>
      <li class="ap-kbs__msg" role="option" id="kbs-msg-4" aria-selected="false" data-unread>
        <span class="ap-kbs__avatar" aria-hidden="true" translate="no">KS</span>
        <span class="ap-kbs__msg-main">
          <span class="ap-kbs__msg-top"><span class="ap-kbs__from">Kabir Shah</span> <span class="ap-kbs__new">New</span></span>
          <span class="ap-kbs__subject">Tile samples</span>
          <span class="ap-kbs__snippet">The terracotta ones arrive on Monday.</span>
        </span>
        <span class="ap-kbs__time">Yesterday</span>
      </li>
      <li class="ap-kbs__msg" role="option" id="kbs-msg-5" aria-selected="false">
        <span class="ap-kbs__avatar" aria-hidden="true" translate="no">PN</span>
        <span class="ap-kbs__msg-main">
          <span class="ap-kbs__msg-top"><span class="ap-kbs__from">Priya Nair</span></span>
          <span class="ap-kbs__subject">Weekend plans</span>
          <span class="ap-kbs__snippet">Trek to Skandagiri on Saturday, back by noon.</span>
        </span>
        <span class="ap-kbs__time">Mon</span>
      </li>
    </ul>
    <p class="ap-kbs__empty" data-ap-empty-inbox hidden>All done. Nothing is left in your inbox.</p>

    <form class="ap-kbs__reply" data-ap-reply>
      <label class="ap-label" for="kbs-reply">Quick reply</label>
      <textarea class="ap-input" id="kbs-reply" rows="2" aria-describedby="kbs-reply-hint"></textarea>
      <div class="ap-kbs__reply-row">
        <p class="ap-hint" id="kbs-reply-hint">Letters typed here never set off a shortcut. Ctrl+Enter sends.</p>
        <button type="submit" class="ap-btn ap-btn--primary" aria-keyshortcuts="Control+Enter Meta+Enter">Send</button>
      </div>
    </form>
    <p class="ap-kbs__status" role="status"></p>
  </section>

  <section class="ap-kbs__settings" aria-labelledby="kbs-settings-name">
    <h3 class="ap-kbs__set-name" id="kbs-settings-name">Shortcut settings</h3>
    <div class="ap-kbs__switch-row">
      <label class="ap-kbs__switch-label" for="kbs-single">Single-key shortcuts</label>
      <span class="ap-kbs__switch">
        <input class="ap-kbs__switch-input" type="checkbox" role="switch" id="kbs-single" aria-describedby="kbs-single-desc" data-ap-single />
        <span class="ap-kbs__track" aria-hidden="true"><span class="ap-kbs__thumb"></span></span>
      </span>
    </div>
    <p class="ap-kbs__desc" id="kbs-single-desc">Off by default, so typing and speech input never set them off. They work only while the message list has focus.</p>

    <details class="ap-kbs__remap">
      <summary class="ap-kbs__summary">Change keys</summary>
      <form class="ap-kbs__form" data-ap-keys novalidate>
        <p class="ap-hint" id="kbs-key-hint">Press one letter or digit. ? stays for help.</p>
          <div class="ap-field ap-kbs__keyfield">
            <label class="ap-label" for="kbs-key-next">Next message</label>
            <input class="ap-input ap-kbs__keyinput" id="kbs-key-next" data-ap-key="next" value="j" maxlength="1" autocomplete="off" autocapitalize="off" spellcheck="false" aria-describedby="kbs-key-hint" />
            <p class="ap-error ap-kbs__err" id="kbs-key-next-error"></p>
          </div>
          <div class="ap-field ap-kbs__keyfield">
            <label class="ap-label" for="kbs-key-prev">Previous message</label>
            <input class="ap-input ap-kbs__keyinput" id="kbs-key-prev" data-ap-key="prev" value="k" maxlength="1" autocomplete="off" autocapitalize="off" spellcheck="false" aria-describedby="kbs-key-hint" />
            <p class="ap-error ap-kbs__err" id="kbs-key-prev-error"></p>
          </div>
          <div class="ap-field ap-kbs__keyfield">
            <label class="ap-label" for="kbs-key-archive">Archive</label>
            <input class="ap-input ap-kbs__keyinput" id="kbs-key-archive" data-ap-key="archive" value="e" maxlength="1" autocomplete="off" autocapitalize="off" spellcheck="false" aria-describedby="kbs-key-hint" />
            <p class="ap-error ap-kbs__err" id="kbs-key-archive-error"></p>
          </div>
        <button type="submit" class="ap-btn">Save keys</button>
        <p class="ap-kbs__form-status" role="status"></p>
      </form>
    </details>
  </section>
  </div>

  <dialog class="ap-kbs__dialog" aria-labelledby="kbs-help-name">
    <div class="ap-kbs__dialog-top">
      <h2 class="ap-kbs__dialog-name" id="kbs-help-name">Keyboard shortcuts</h2>
      <button type="button" class="ap-kbs__x" aria-label="Close" data-ap-close-help>
        <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M6 6l12 12M18 6 6 18"/></svg>
      </button>
    </div>
    <p class="ap-kbs__state" data-ap-help-state>Single-key shortcuts are off. Turn them on in Shortcut settings.</p>
    <h3 class="ap-kbs__group">In the message list</h3>
    <dl class="ap-kbs__keys">
          <div class="ap-kbs__row"><dt><kbd data-ap-keycap="next" translate="no">j</kbd></dt><dd>Next message</dd></div>
          <div class="ap-kbs__row"><dt><kbd data-ap-keycap="prev" translate="no">k</kbd></dt><dd>Previous message</dd></div>
          <div class="ap-kbs__row"><dt><kbd data-ap-keycap="archive" translate="no">e</kbd></dt><dd>Archive</dd></div>
          <div class="ap-kbs__row"><dt><kbd translate="no">?</kbd></dt><dd>Show this list</dd></div>
    </dl>
    <h3 class="ap-kbs__group">Always on</h3>
    <dl class="ap-kbs__keys">
          <div class="ap-kbs__row"><dt><kbd translate="no">↓</kbd> <kbd translate="no">↑</kbd></dt><dd>Move through the messages</dd></div>
          <div class="ap-kbs__row"><dt><kbd translate="no">Home</kbd> <kbd translate="no">End</kbd></dt><dd>First or last message</dd></div>
          <div class="ap-kbs__row"><dt><kbd translate="no">Ctrl</kbd> <kbd translate="no">Enter</kbd></dt><dd>Send a quick reply</dd></div>
          <div class="ap-kbs__row"><dt><kbd translate="no">Esc</kbd></dt><dd>Close this list</dd></div>
    </dl>
  </dialog>
</div>

WCAG 2.2

What it meets

The success criteria this pattern takes care of, and how.

  • 2.1.4 Character Key Shortcuts Level A

    The single-key shortcuts can be turned off (they start off), remapped to other keys, and only fire while the message list has focus, which meets all three of the criterion's options.

  • 2.1.1 Keyboard Level A

    Every shortcut repeats a visible button, and the arrow keys move through the list, so nothing depends on knowing a shortcut.

  • 3.3.1 Error Identification Level A

    A key that is already taken, or is not a letter or digit, is named as an error next to its field and the field is marked invalid.

  • 3.3.2 Labels or Instructions Level A

    Each key field has a label and a hint saying what it accepts, and the switch says what it does before it is turned on.

  • 4.1.2 Name, Role, Value Level A

    aria-keyshortcuts on the buttons names each shortcut while it works and is removed when it does not; the list is a listbox with the selected message marked.

  • 4.1.3 Status Messages Level AA

    Archiving, sending, saving keys and turning shortcuts on or off are confirmed in status messages without moving focus.

Usage

When to use it

Use it

  • Apps people use for hours, like mail, chat, editors and dashboards, where frequent users want speed.
  • Commands that also exist as visible buttons, so the shortcut is a faster route, never the only one.

Use something else

  • Single-key shortcuts on by default across the whole page: speech input users say words, and every letter fires.
  • Keys the browser, the operating system or screen readers already use, like Ctrl+F, Ctrl+K or Insert.
  • Shortcuts as the only way to do something: no one discovers them, and some people cannot press them.

Common failures

How it usually goes wrong

  • Letters that fire anywhere on the page

    A global listener for j or e triggers while someone types, and every time speech input hears a word with that letter. Limit them to the component that has focus.

  • No way to turn them off

    If the only fix for a stray shortcut is to avoid that key, some people cannot use the page. Off by default, or a switch, is the minimum.

  • Shortcuts in text fields

    Typing "e" in a reply should type "e". Check where focus is, not just which key was pressed, and ignore editable targets.

  • Hidden shortcuts

    A shortcut that is not listed anywhere helps only the people who guessed it. A help dialog, keycaps on the buttons and aria-keyshortcuts make them findable.

  • Clashing with assistive technology

    Screen readers use single letters to move by headings and links. In a list with focus mode on they pass keys through; elsewhere your shortcut never arrives.

Notes

Building it

  • aria-keyshortcuts takes key names from the KeyboardEvent key values ("j", "Control+Enter", "Meta+Enter"); it only announces the shortcut, the script still has to handle it.
  • Check event.ctrlKey, metaKey and altKey before treating a key as a single-key shortcut, so browser and system shortcuts pass through untouched.
  • Shift+/ gives "?" on many layouts but not all. Matching event.key, not event.code, follows the character the person sees on their keyboard.
  • Keep remapped keys somewhere that lasts, like localStorage or the account's settings. The demo keeps them in memory, so Reset restores j, k and e.
  • A shortcut two parts of a page both want goes to whichever listens first. This site opens its guide on ? and search on Ctrl+K, so the list stops ? from reaching the page while it has focus.

Sources: Understanding WCAG 2.2: Character Key Shortcuts · WAI-ARIA 1.2: aria-keyshortcuts · WAI-ARIA Authoring Practices: Keyboard interface

Checked with axe in light and dark themes, at desktop and phone widths, and by keyboard. Not yet tested with every screen reader and browser pair. Report a correction