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>withshowModal()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">witharia-expanded,aria-controlspointing at the listbox,aria-autocomplete="list", andaria-activedescendantidentifying the highlighted option. Focus stays in the input throughout. - Listbox —
role="listbox"containingrole="option"elements, optionally inrole="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.
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 |
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.
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.
Related
Permalink to "Related"- Keyboard shortcut help dialogs — the other shortcut surface
- Native dialog versus custom traps — the palette’s container
- Multi-select combobox — the combobox pattern in depth