Somewhere around 2020 the ⌘K menu went from a Superhuman party trick to table stakes. Linear, GitHub, VS Code, Raycast, Notion, Vercel — every serious tool now answers ⌘K with a searchable list of everything you might want to do. It's become the keyboard user's front door. Which means it's worth understanding what actually makes one good, because most hand-rolled versions get one crucial part wrong.
A command palette is really five small things wired together. Here's each, and where the sharp edges are.
The trigger and the shortcut
Two ways in, always: a visible button (so people who don't know the shortcut can discover it) and the global ⌘K / Ctrl K binding. The shortcut has one gotcha — browsers bind ⌘K/Ctrl+K to the address bar, so you must preventDefault() when you handle it, and you should only grab it when a palette actually exists on the page. Bind it on the document, not an element, so it works no matter where focus is.
document.addEventListener('keydown', (e) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault();
openPalette();
}
});
The overlay
Resist the urge to hand-build a modal. The native <dialog> element gives you three things a command palette needs, for free: it traps focus, it closes on Esc, and its ::backdrop dims the page. Open it with dialog.showModal() and you've skipped the most bug-prone part of the whole component. Farvist's palette is exactly this — a <dialog class="command"> — so the overlay behavior is the platform's, not yours to maintain.
The input and the filter
When the palette opens, focus goes straight to the input and the list resets. As the user types, you filter the items. The subtlety worth adding: match on more than the visible label. People search for "colors" and expect the "Theming" item; a good palette lets each item carry hidden keywords so synonyms hit. Filter, then make the first surviving item active so Enter always does something sensible, and show an empty state when nothing matches.
The keyboard model — and the part everyone gets wrong
Here's the mistake in nine out of ten hand-built palettes: as you press ↓, they move real DOM focus onto each list item. It feels right and it's wrong. The moment focus leaves the input, the user can no longer type to keep filtering — they have to tab back. The whole appeal of ⌘K (type and arrow at the same time) is broken.
The correct pattern is a combobox controlling a listbox, and it's the same one native autocomplete uses. Focus never leaves the input. Instead, you track a "virtual" cursor with aria-activedescendant, which points the input at the id of the currently highlighted option. Arrow keys move that pointer and a CSS class; focus stays put; typing keeps working.
<input role="combobox" aria-expanded="true"
aria-controls="cmd-list"
aria-activedescendant="cmd-item-3">
<ul id="cmd-list" role="listbox">
<li id="cmd-item-3" role="option" aria-selected="true">…</li>
</ul>
Two rules that follow from this. The options are <li role="option"> — not links or buttons. An option is not supposed to contain interactive elements, and it shouldn't be focusable itself; activation is your JS reading the active item. And aria-selected tracks the highlight so a screen reader announces "Theming, 2 of 6" as the user arrows through, without ever stealing focus from where they're typing.
Activation
Enter (or a click) activates the highlighted item. The flexible design is to give each item a value and interpret it: a URL or #anchor navigates; anything else is a named action your app handles. Farvist does this with a data-fv-command attribute — a link value navigates, and any other value fires an fv:command event you listen for:
palette.addEventListener('fv:command', (e) => {
if (e.detail.value === 'toggle-theme') Farvist.theme('dark');
});
That split — navigation for free, custom actions via an event — keeps the component generic without forcing a framework on you.
Putting it together
Good command palettes feel instant and never trap you. That comes down to: open on ⌘K without hijacking the browser, lean on <dialog> for the overlay, keep focus in the input and steer with aria-activedescendant, and treat items as data you act on rather than links you focus. Get the keyboard model right and the rest is styling.
If you'd rather not build it, Farvist ships the whole thing as a .command component — try it in the docs (press ⌘K), or read the bigger idea behind a framework designed for the keyboard-and-AI era.