An Accessible Command Palette for Data Apps

Permalink to "An Accessible Command Palette for Data Apps"

A command palette is a searchable list of every action in an application, opened with a shortcut such as Ctrl+K: type “exp”, press Enter, and the table is exported. For keyboard users it is often the fastest route to any command, and for screen reader users it can be the most discoverable one — provided the palette itself follows the combobox pattern. Built as a styled <div> with a text field and a list of clickable rows, it announces nothing as the user types, and arrowing through results is silent.

This page builds the palette as a dialog containing a combobox and a listbox. It belongs to keyboard shortcuts & command palettes.

Spec reference

Permalink to "Spec reference"

Three ARIA patterns combine:

  • Dialog — the palette is modal while open. Using <dialog> with showModal() makes the page behind it inert and returns focus on close, as covered in native dialog versus custom focus traps.
  • Combobox (ARIA 1.2) — an <input role="combobox"> with aria-expanded, aria-controls pointing at the listbox, aria-autocomplete="list", and aria-activedescendant identifying the highlighted option. Focus stays in the input throughout.
  • Listbox — role="listbox" containing role="option" elements, optionally in role="group" sections with labels (Navigation, Table, Export).

Criteria: SC 4.1.2 Name, Role, Value (combobox state and active option), SC 4.1.3 Status Messages (result counts), SC 2.1.1 Keyboard, SC 2.4.3 Focus Order (focus after running a command). The opening shortcut uses a modifier, so SC 2.1.4 does not apply to it.

The three patterns inside a command palette Layers of a command palette: the modal dialog, the combobox input that keeps focus, the listbox of grouped command options, and a status region for result counts. The three patterns inside a command palettedialog (showModal)aria-label "Command palette"; page behind is inertinput role="combobox"focus stays here; aria-activedescendant points at the highlighted optionlistbox with groupsoptions such as "Export table as CSV", grouped under labelled headingsstatus region"7 commands" — updated as the user types, politely
Focus never leaves the input — the listbox is navigated through aria-activedescendant.

When to add a palette — and when not to

Permalink to "When to add a palette — and when not to"

Add one when an application has more commands than fit in visible menus, or when commands are scattered across views: export, switch dataset, toggle columns, jump to a record. It complements, never replaces, visible controls — every command in the palette must also be reachable somewhere in the interface for users who do not know the palette exists.

Do not make the palette the only way to perform an action. Discoverability is its weakness: users must know the shortcut. Advertise it in the header (“Search commands, Ctrl+K”) with a visible button that opens it.

The misapplication to name is moving DOM focus onto each result as the user arrows. Focus leaves the input, typing stops working, and screen readers announce each option as a new focused element with its full context — slower and more confusing than aria-activedescendant.

Annotated code example

Permalink to "Annotated code example"
<button type="button" id="open-palette" aria-keyshortcuts="Control+K">
  Search commands <kbd>Ctrl K</kbd>
</button>

<dialog id="palette" aria-label="Command palette">
  <!-- SC 4.1.2: combobox owns focus, state and the active option -->
  <input id="palette-input" role="combobox" aria-expanded="true"
         aria-controls="palette-list" aria-autocomplete="list"
         aria-activedescendant="" aria-describedby="palette-count"
         placeholder="Type a command" aria-label="Command">
  <div id="palette-list" role="listbox" aria-label="Commands">
    <div role="group" aria-labelledby="g-table">
      <div id="g-table" role="presentation" class="group-label">Table</div>
      <div role="option" id="cmd-export" data-run="exportCsv">Export table as CSV</div>
      <div role="option" id="cmd-cols" data-run="openColumns">Choose visible columns</div>
    </div>
  </div>
  <!-- SC 4.1.3: count, announced politely -->
  <p id="palette-count" role="status" class="visually-hidden"></p>
</dialog>
const dlg = document.getElementById('palette');
const input = document.getElementById('palette-input');

document.addEventListener('keydown', (e) => {
  if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') {
    e.preventDefault(); openPalette();
  }
});

function openPalette() {
  dlg.showModal();                      // inert background, focus return on close
  input.value = ''; render('');
  input.focus();
}

input.addEventListener('input', () => render(input.value));

function render(q) {
  const opts = [...dlg.querySelectorAll('[role="option"]')];
  let n = 0;
  opts.forEach((o) => {
    const hit = o.textContent.toLowerCase().includes(q.toLowerCase());
    o.hidden = !hit; if (hit) n++;
  });
  dlg.querySelectorAll('[role="group"]').forEach((g) => {
    g.hidden = !g.querySelector('[role="option"]:not([hidden])');
  });
  setActive(opts.find((o) => !o.hidden));
  debounceStatus(`${n} command${n === 1 ? '' : 's'}`);   // one message per pause in typing
}

function setActive(opt) {
  dlg.querySelectorAll('[aria-selected="true"]').forEach((o) => o.removeAttribute('aria-selected'));
  if (!opt) { input.setAttribute('aria-activedescendant', ''); return; }
  opt.setAttribute('aria-selected', 'true');
  input.setAttribute('aria-activedescendant', opt.id);   // SC 4.1.2
  opt.scrollIntoView({ block: 'nearest' });
}

input.addEventListener('keydown', (e) => {
  const visible = [...dlg.querySelectorAll('[role="option"]:not([hidden])')];
  const i = visible.findIndex((o) => o.id === input.getAttribute('aria-activedescendant'));
  if (e.key === 'ArrowDown') { e.preventDefault(); setActive(visible[Math.min(i + 1, visible.length - 1)]); }
  if (e.key === 'ArrowUp')   { e.preventDefault(); setActive(visible[Math.max(i - 1, 0)]); }
  if (e.key === 'Enter' && visible[i]) {
    e.preventDefault();
    const run = commands[visible[i].dataset.run];
    dlg.close();                                          // focus returns to the opener…
    run();                                                // …then the command may move it on
  }
});

Running the command after closing the dialog lets the command decide where focus should go — an export command may leave focus where it was and announce “Export started”; “Choose visible columns” should open the column menu and focus it.

Keyboard & AT behaviour

Permalink to "Keyboard & AT behaviour"
Key / event Expected announcement AT-specific deviations
Ctrl+K “Command palette, dialog. Command, combo box, expanded” VoiceOver reads the dialog label after the field
Type “exp” Pause, then “2 commands” NVDA may echo typed characters first
Down Arrow “Export table as CSV, 1 of 2, Table” Group label read by NVDA and JAWS; VoiceOver may omit it
Enter Dialog closes; command result announced Focus lands where the command put it
Escape Dialog closes; focus back to where it was Clears the query on first press in some designs
From shortcut to result Timeline of a keyboard user opening the command palette, typing a query, hearing the count, choosing a command and hearing its result. From shortcut to resultCtrl+Kpalette opens, input focusedType "exp""2 commands" after a pauseDown Arrow"Export table as CSV"Enterpalette closesResult"Export started. 1,240 rows."one command run
Focus sits in the input the whole time the palette is open — arrows only move the highlighted option.

Integration context

Permalink to "Integration context"

A palette and a shortcut help dialog should share one command registry, so every command shows its shortcut in the palette (“Archive — E”) and the help dialog lists the same set; see keyboard shortcut help dialogs and aria-keyshortcuts for grid commands.

The combobox and listbox behaviour here is the same pattern as filter comboboxes; the multi-select variant with removable chips is in an accessible multi-select combobox for column filters.

Moving focus versus aria-activedescendant in a palette Comparison of moving DOM focus to each result as the user arrows against keeping focus in the input and pointing aria-activedescendant at the highlighted option. Moving focus versus aria-activedescendant in a palette✗ Focus moves to each optionTyping stops working until focus returnsEach option read as a newly focused elementEscape handling split across elementsScroll and focus fight each other✓ aria-activedescendantFocus stays in the inputOption read as the active descendantKeep typing at any time to refineOne keydown handler for everything
The input keeps focus, so typing and arrowing can be freely interleaved.

Gotchas

Permalink to "Gotchas"

Fuzzy matching and announcements. Results reorder as the user types. Always re-point aria-activedescendant at the first visible option after each render, or it references a hidden element and nothing is announced.

Recent commands. A “Recent” group at the top is useful; label it, so users know why those commands are first.

Shortcut conflicts. Ctrl+K focuses the browser’s search bar in some browsers when the page does not prevent it; Meta+K is common on macOS. Call preventDefault and accept both.

Design system notes

Permalink to "Design system notes"

Ship the palette as a platform component that consumes the command registry: each command has a label, group, keywords, optional shortcut and a run function that returns where focus should go. Products register commands; the palette handles combobox semantics, grouping, counts and focus return, so no product needs to rebuild the pattern.

Testing checklist

Permalink to "Testing checklist"

FAQ

Permalink to "FAQ"
What ARIA pattern should a command palette use?

A modal dialog containing a combobox input and a listbox of command options. Focus stays in the input, and aria-activedescendant identifies the highlighted option as the user arrows through results.

Should a command palette announce the number of results?

Yes, politely and once the user pauses typing, for example “7 commands”. Without it, screen reader users do not know whether their query matched anything.

Is Ctrl+K subject to SC 2.1.4?

No. Shortcuts that include a modifier key are outside SC 2.1.4. Still offer a visible button that opens the palette, because shortcuts are not discoverable on their own.

Where should focus go after running a command?

Wherever the command’s result is. Close the palette first so focus returns to its origin, then let the command move focus if it opens something or changes the view.

Permalink to "Related"

← Back to Keyboard Shortcuts & Command Palettes