Pattern: command bar
Who uses it: power users who know what they want — a dispatcher jumping to PO-88213, a controller running "post period" — without walking the menu. How often: dozens of times a day, which is why it is keyboard-first and why the shortcut must never be stolen from the browser. What "done" looks like: they typed a few characters and the screen they wanted is open, hands never leaving the keyboard. Not a replacement for navigation — it rewards people who already know the name of the thing. A first-time or occasional user needs the visible structure of app launch, and a user narrowing an unknown set needs the filter panel; a command bar answers neither.
The screen
This palette is dialog +
combobox +
kbd, composed. The framework ships
no command-bar component and does not need one: of the palette CSS this
site runs, 7 of 10 declarations already exist in .bo-dialog,
and the keyboard contract is combobox's in full.
Opened by a button here, deliberately: this site already binds ⌘K to its own docs search. That collision is the first thing to check in your own app — a command bar that fights the host for a shortcut loses, silently.
Anatomy
- Top-anchored dialog — dialog, in the top layer so it escapes the app shell's overflow. Three extra declarations anchor it near the top instead of centring it; that is the whole visual difference.
- Query input + result list —
combobox, which carries the
entire keyboard and ARIA contract:
aria-expanded,aria-activedescendantmoved by ↑/↓,aria-selectedon the active option, Esc and click-outside for free. - Keyboard hint strip — kbd, above the input. Render the shortcut per platform: ⌘K on Mac, Ctrl K everywhere else, which for a back-office framework is the majority.
- The shortcut binding — your code, not a behavior. Which key the app owns is a page-level decision.
Data contract
GET /command?q=pos -> 200
{
"groups": [
{ "label": "Records", "items": [
{ "id": "po-88213", "text": "PO-88213 — Umbrella Logistics", "href": "/po/88213" }
]},
{ "label": "Actions", "items": [
{ "id": "act-post", "text": "Post period 2026-07", "href": "/period/post" }
]}
]
}
# Nothing matched: 200 with an EMPTY groups array, never a 404.
# "No results" is a state of the screen, not an error of the request.
# The user may not run it: omit the item. Never return an action that
# 4xx's when chosen — see /concepts/permissions. Group results by kind — records and actions answer different questions and a flat list makes the user read every row to tell them apart. Two rules do the heavy lifting: no matches is a 200 with an empty list, never a 404; and an action the user may not run is omitted, not disabled — returning one that 4xx's when chosen teaches the user to distrust the bar.
The binding, and the failure rule
<!-- The shortcut is YOUR four lines, not a behavior: which key
the app owns is a page-level decision. -->
<script type="module">
import { initCombobox } from '@busy-office/ui/js';
initCombobox();
const dlg = document.getElementById('cmd');
addEventListener('keydown', (e) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
e.preventDefault();
dlg.open ? dlg.close() : dlg.showModal();
}
});
// Backdrop click closes — a native <dialog> does not do this for you.
dlg.addEventListener('click', (e) => { if (e.target === dlg) dlg.close(); });
</script> The non-obvious rule, and the one worth copying: if the result source cannot load, remove the trigger. A command bar that opens onto an empty box is worse than no command bar — the user types, nothing happens, and they cannot tell whether the search is broken or the record does not exist. This site does exactly that: if the search index fails to fetch, the button deletes itself.
The listbox is a popover — so nothing sits below it
Combobox renders its listbox as a [popover] in the top
layer, which is what stops it being clipped by a scrolling ancestor. The
consequence only shows up once results appear: the list is painted over
whatever follows the input in the dialog. This page shipped a hint strip
below the input and measured it covered — footer at
y 122–175, listbox at 126–284 — hidden at exactly the moment its
contents become useful.
Removing popover is not the fix: it
is load-bearing. Measured with the attribute stripped, the list still
renders, but aria-expanded stays false,
no aria-activedescendant is set and nothing is
aria-selected — the whole keyboard contract goes silent while
the screen still looks correct. So put keyboard hints, counts, and any
footer above the input, and keep everything below it
empty.
States
| State | What the screen shows |
|---|---|
| Empty query (just opened) | Recent records and the handful of actions this role runs daily — an empty palette wastes the trip. Never a blank box. |
| Typing, results found | Grouped by kind, most-likely group first. The active option carries aria-selected, so it is announced as the arrow keys move. |
| No matches | "No records or actions match xyz" inside the list region, plus what would help — most misses are a wrong prefix, so say which formats are searchable. |
| Slow source (> ~200ms) | A busy state on the list region, not a spinner over the input — the user is still typing and must never be blocked. Keep the previous results visible underneath. |
| Source unavailable | The trigger is removed at load. See the failure rule above. |
| Permission | Actions the user cannot run are absent from the response, so this state never reaches the screen. That is the point of filtering server-side. |
| Shortcut already taken | The host owns the key. Detect it at build time, not by racing — and fall back to a visible trigger, which every user can find and no user needs told about. |
| No JS | The trigger button has no popovertarget or other native affordance — its <dialog> is opened purely by a click listener in this page's script. With no JS, clicking the trigger does nothing at all: the palette is unreachable, not degraded, and the combobox filtering inside it never comes into play. |
Typing, committing, clearing
Open the palette above and try each: type
PO to search records, type post to reach an
action, press Enter to commit, and use the
✕ to clear without leaving.
| Moment | What the palette does |
|---|---|
| Keying in a command | Same input, same list. A palette that made the user choose "search mode" or "command mode" first would be asking them to know the answer before they typed it — the whole point is one box. Actions and records are separate GROUPS in the listbox, not separate modes. |
| Keying in a search | Filtering starts on the first character;
initCombobox() owns it. Nothing is committed until
Enter, so arrowing through results changes the active option, not
the value. |
| Enter — the value afterwards | The combobox commits the active option and leaves that text in the input selected, so the next keystroke replaces it rather than appending. In a palette the commit usually navigates, so decide what the box holds when the user comes back: this demo clears and closes, because a palette reopened onto last week's query is a palette you have to clear before you can use. |
| Clear | A visible ✕, shown only when there is something to clear. It empties the query and returns focus to the input — it does not close the palette, which is the difference between "I mistyped" and "I'm done". |
| Escape, and why it takes two | The listbox is a popover, so the first Escape is
the platform light-dismissing it; the second closes the dialog.
That layering is free and correct — but it surprises people, so say
it in the hint rather than fighting it. |
Markup
<dialog id="cmd" class="bo-dialog cmd-palette" aria-label="Command bar">
<!-- Hints ABOVE the input, not below: see "The listbox is a popover" -->
<div class="cmd-palette__hint">
<span><kbd class="bo-kbd">↑</kbd><kbd class="bo-kbd">↓</kbd> move</span>
<span><kbd class="bo-kbd">Enter</kbd> open</span>
<span><kbd class="bo-kbd">Esc</kbd> close</span>
</div>
<div class="bo-combobox">
<input class="bo-input" type="text" role="combobox" id="cmd-input"
aria-expanded="false" aria-controls="cmd-list" aria-autocomplete="list"
aria-label="Search records and actions"
autocomplete="off" placeholder="Search records and actions…">
<ul class="bo-combobox__listbox" id="cmd-list" role="listbox" popover>
<li class="bo-combobox__option" role="option" id="cmd-1"
data-value="PO-88213">PO-88213 — Umbrella Logistics</li>
<!-- …one per result… -->
</ul>
</div>
</dialog>
<!-- Three declarations are all that separates a palette from a dialog: -->
<style>
/* overflow MUST stay visible. With overflow:hidden the results listbox does
not render at all — measured, not guessed. The listbox is a [popover] and
this dialog is its containing block, so clipping the dialog clips the
results away and the palette silently becomes a search box that finds
nothing. See "The listbox is a popover" on this page. */
.cmd-palette { margin-block-start: 12vh; margin-inline: auto; overflow: visible; }
/* Padding, or the hint row sits flush in the corner of an 8px-rounded box
and reads as a separate floating bar rather than part of the palette. */
.cmd-palette__hint {
display: flex; gap: var(--bo-space-4);
font-size: var(--bo-font-size-xs);
padding: var(--bo-space-2) var(--bo-space-3);
border-block-end: var(--bo-border-width) solid var(--bo-color-border-default);
}
</style> Components used
complexity 1 of 4 — one input, one list; the composition is the whole trick.