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. Most hand-rolled versions still get one crucial part wrong.
A command palette is really five small things wired together: a trigger, an overlay, an input that filters, a keyboard model, and an activation contract. Below is each one, with the code Farvist actually ships — scss/components/_command.scss and the .command block of assets/farvist.js — and the sharp edges we hit building it.
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. Bind it on the document, not an element, so it works no matter where focus is.
The guard matters more than it looks. Farvist only claims the shortcut when a palette is actually present in the DOM, and only after that check does it call preventDefault() — so a page that includes farvist.js but has no palette leaves the browser's own ⌘K alone:
if ((e.metaKey || e.ctrlKey) && (e.key === 'k' || e.key === 'K')
&& document.querySelector('dialog.command')) {
e.preventDefault();
openCommand();
return;
}
Two details in there. Testing both 'k' and 'K' covers Caps Lock and Shift, which change e.key but not the user's intent. And the metaKey || ctrlKey pair deliberately accepts either modifier on every platform rather than sniffing the OS — Ctrl+K on a Mac opens it too.
That last decision has a cost we accept knowingly: the handler doesn't exempt text fields, and on macOS Ctrl K is the readline "kill to end of line" binding. On a page with a palette, pressing it inside a textarea opens the palette instead of deleting the rest of the line. Exempting <textarea> and contenteditable targets would fix that and break "open the palette from anywhere". If your product is an editor, add the exemption.
The overlay: what the native dialog element gives you, and what it doesn't
Resist the urge to hand-build a modal. Farvist's palette is a <dialog class="command"> opened with showModal(), and that one call buys four things that are genuinely hard to get right by hand: the element is promoted to the top layer (so no z-index arms race with sticky headers), everything behind it becomes inert (focus can't tab out, clicks don't reach the page), Esc closes it with no listener of your own, and ::backdrop gives you a real pseudo-element to style:
.command::backdrop {
background-color: rgba(6, 9, 18, 0.6);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
}
Three things it does not give you. First, closing on a backdrop click. That's not a <dialog> behaviour — you have to add it, and the trick is that a click on the backdrop reports the dialog itself as its target, so a hit test against the dialog's own box tells the two apart:
if (t.matches('dialog.modal, dialog.command')) {
var r = t.getBoundingClientRect();
if (e.clientX < r.left || e.clientX > r.right ||
e.clientY < r.top || e.clientY > r.bottom) t.close();
}
Note what that approximates: getBoundingClientRect() is a rectangle, and the panel has a 1.125rem border radius, so the four rounded corners test as "inside" — a click two pixels into a corner leaves the palette open. The dead zone is a few pixels square; the alternative, point-in-rounded-rect maths, is not worth the bytes.
Second, animation. [open] flips display, so you get one shot at an entrance animation and none at an exit; a close is instantaneous. Farvist animates in only, and drops even that under prefers-reduced-motion.
Third — and this is the one that bit us in production — <dialog> does not really position itself.
The centering bug we shipped, and the fix in 1.7.1
A modal <dialog> looks like it centers itself. It doesn't. It's centered by dialog { margin: auto } in the user-agent stylesheet — an ordinary declaration in the lowest-priority origin, which any author rule beats. From 1.5.0 to 1.7.0 our palette set margin-top: 12vh and margin-bottom: auto for the vertical offset and left the horizontal centering to that UA rule.
Any universal margin reset beats the UA and takes the centering with it — including our own. Farvist's reset is @layer reset { * { margin: 0 } }, and because .command never declared a left or right margin, there was nothing in @layer components to win those values back. Rendered against the shipped 1.7.0 stylesheet at 1280 px wide, the docs page's own palette markup puts the panel's left edge at 3 px instead of 323 px — flat against the side of the window. That is what farvist.com served, on the page documenting the component, for four releases.
On a host page it degrades further, which is how we finally caught it. The AI kit emits everything in @layer, and un-layered author CSS beats every layer, so a host's * { margin: 0 } overrides the margin-top: 12vh as well: farvist-ai.css 1.7.0 under that reset renders the palette at top: -6px, clipped off the top of the screen. We found that one testing against real host stylesheets for 1.7.1 — the full-build case had been sitting in plain sight the whole time.
The fix is to stop borrowing the UA's centering and own it:
.command {
width: calc(100% - 1.5rem);
max-width: 40rem;
position: fixed;
top: 12vh; /* near the top, not dead-centre — the ⌘K convention */
left: 50%;
margin: 0;
translate: -50% 0;
}
The interesting line is the last one. The obvious way to pull an element back by half its own width is transform: translateX(-50%) — and that would have silently broken the open animation, because the keyframes already own transform:
@keyframes fv-command-in {
from { opacity: 0; transform: translateY(-0.5rem) scale(0.99); }
to { opacity: 1; transform: none; }
}
A keyframe that sets transform replaces the property outright. The centering offset would vanish for the duration of the animation and the palette would fly in from half a panel-width to the right — measured mid-animation, the panel sits 320 px off-centre with translateX(-50%) and dead centre with translate. That's because translate is an independent transform property, not part of the transform shorthand: the browser composes translate, rotate, scale and then transform into a single matrix, so the offset holds while the keyframes do their thing. Whenever a layout offset and an animation both want to move the same element, split them across translate and transform.
Honest footnote: 1.7.1 fixed the palette and not .modal, which declared no position and no margin of its own and had exactly the same bug — opened against the shipped 1.7.1 stylesheet at 1280 px, the docs modal rendered at left: 5px, top: 18px. It had been live as long as the palette's version was. 1.7.2 gives it the same treatment, centered on both axes: position: fixed; top: 50%; left: 50%; margin: 0; translate: -50% -50%. Those were the only two — <dialog>, <hr> and [popover] are the only elements the UA sheet gives auto margins, and nothing else in the framework leaned on them. If you are shipping a <dialog> today, position it explicitly and don't inherit centering you didn't declare.
The input and the filter
Opening through Farvist.command() or ⌘K clears the query, focuses the input explicitly rather than relying on <dialog>'s default focus behaviour, and re-runs the filter so the list starts complete:
function openCommand(sel) {
var dialog = sel ? document.querySelector(sel) : document.querySelector('dialog.command');
if (!dialog || !dialog.showModal) return;
dialog.showModal();
var input = dialog.querySelector('.command-input');
if (input) { input.value = ''; input.focus(); }
cmdFilter(dialog);
}
The filter itself is deliberately dumb, and matches on more than the visible label. People search for "colors" and expect the "Theming" item, so each row can carry hidden synonyms in data-fv-keywords:
var q = (dialog.querySelector('.command-input').value || '').trim().toLowerCase();
qsa('.command-item', dialog).forEach(function (item) {
var hay = (item.textContent + ' ' + (item.getAttribute('data-fv-keywords') || '')).toLowerCase();
item.hidden = !!(q && hay.indexOf(q) === -1);
if (!item.hidden) anyVisible = true;
});
Using the hidden attribute rather than a class is a small win: hidden elements leave the accessibility tree, so the listbox a screen reader sees contains only the matches, without any extra bookkeeping. Two more passes follow. Group headings hide themselves when every item beneath them is filtered out — the loop walks forward from each .command-group until it meets the next one and shows the heading only if it finds a visible item. Then the empty state toggles, and the first surviving row becomes active so Enter always does something sensible.
The keyboard model — and the part everyone gets wrong
Here's the mistake in most 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.
This is why roving tabindex is the wrong pattern here, even though it's the right one for tabs, toolbars and menubars. Roving tabindex works by moving focus and shuffling tabindex="0" / tabindex="-1" between siblings — fine when the widget is the only thing the user is operating, useless when they need to keep typing into a different element while they navigate.
The correct pattern is a combobox controlling a listbox, the same one native autocomplete uses. Focus never leaves the input. 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. Farvist wires the static half of that in enhance(), so the markup an author writes stays plain HTML:
input.setAttribute('role', 'combobox');
input.setAttribute('aria-expanded', 'true');
input.setAttribute('aria-autocomplete', 'list');
input.setAttribute('autocomplete', 'off');
input.setAttribute('aria-controls', list.id);
list.setAttribute('role', 'listbox');
qsa('.command-item', dialog).forEach(function (item) {
item.setAttribute('role', 'option');
if (!item.hasAttribute('aria-selected')) item.setAttribute('aria-selected', 'false');
});
qsa('.command-group', dialog).forEach(function (g) { g.setAttribute('role', 'presentation'); });
aria-autocomplete="list" tells assistive tech that typing narrows a list rather than completing the text inline. Native autocomplete="off" is unrelated and equally necessary — without it, the browser's own form-fill dropdown can render over your results.
The moving half is one function, and it is the whole trick:
function cmdSetActive(dialog, item) {
qsa('.command-item.is-active', dialog).forEach(function (el) {
el.classList.remove('is-active'); el.setAttribute('aria-selected', 'false');
});
var input = dialog.querySelector('.command-input');
if (item) {
item.classList.add('is-active');
item.setAttribute('aria-selected', 'true');
if (!item.id) item.id = 'fvcmd-' + (++cmdId);
if (input) input.setAttribute('aria-activedescendant', item.id);
item.scrollIntoView({ block: 'nearest' });
} else if (input) {
input.removeAttribute('aria-activedescendant');
}
}
No .focus() anywhere. Four things move together: the .is-active class for sighted users, aria-selected for the accessibility tree, aria-activedescendant on the input for the virtual cursor, and the scroll position. The ids are generated lazily from a counter — an author writing markup by hand should never have to invent unique ids just so the ARIA has something to point at.
The rules that follow. Options are <li role="option">, not links or buttons — ARIA treats an option's children as presentational, so a real <a> or <button> nested inside one is flattened away before assistive tech ever sees it. And in this pattern the option is never focused, so .command-item gets cursor: pointer and no tabindex, and activation is your JS reading the active item. The highlight is driven by the class, never by :hover alone, so the CSS pairs them explicitly:
.command-item.is-active,
.command-item:hover { background-color: var(--fv-glass-bg-strong); }
.command-item { scroll-margin: 0.5rem; }
That scroll-margin exists because of scrollIntoView({ block: 'nearest' }): without it, arrowing to the row at the edge of the scroll container parks it flush against the list's padding, which reads as clipped. Half a rem of breathing room is the whole fix.
The keyboard contract, in full
Every behaviour below is implemented in the palette's keydown handler or comes free from <dialog>. "Visible items" means the ones surviving the current filter — hidden rows are excluded from the list before any index maths happens, so arrowing never lands on a row you can't see.
| Key | Behaviour | Source |
|---|---|---|
| ⌘K / Ctrl K | Opens the first palette on the page; clears the query and refocuses the input | farvist.js |
| ↓ | Next visible item, wrapping from the last back to the first | farvist.js |
| ↑ | Previous visible item, wrapping from the first to the last | farvist.js |
| Home | First visible item | farvist.js |
| End | Last visible item | farvist.js |
| Enter | Activates the active item; no-op when nothing is active | farvist.js |
| Any character | Re-filters, re-hides empty groups, makes the first match active | farvist.js |
| Esc | Closes, and focus returns to whatever had it before | <dialog> |
| Tab | Not intercepted — the dialog's focus trap keeps it inside the palette | <dialog> |
Arrow wrapping is a plain modulo, which is the behaviour people expect from every palette they've used. One subtlety: when the filter matches nothing, the handler bails before any of the arrow or Enter branches run, so nothing calls preventDefault() and those keys keep their default behaviour rather than firing against an empty list.
Worth noting that Home and End work but aren't advertised. The .command-hint footer lists only ↑↓ navigate, ↵ select, esc close. Three hints is a footer; listing every binding turns it into a manual, and the people who reach for Home try it without being told.
Activation
Enter (or a click) activates the highlighted item. The flexible design is to give each item one value and interpret it: a URL or #anchor navigates; anything else is a named action your app handles. Farvist puts that value in data-fv-command and branches on its first character:
function runCommand(item) {
var dialog = item.closest('dialog.command');
var val = item.getAttribute('data-fv-command');
if (dialog) dialog.close();
if (val && (val.charAt(0) === '/' || val.charAt(0) === '#' || /^https?:/.test(val))) {
window.location.href = val;
} else if (dialog) {
dialog.dispatchEvent(new CustomEvent('fv:command', { bubbles: true, detail: { value: val, item: item } }));
}
}
Anything that isn't a link becomes an event you handle:
palette.addEventListener('fv:command', (e) => {
if (e.detail.value === 'skin-synthwave') Farvist.theme('synthwave');
});
Three deliberate choices here. The dialog closes before the action runs, so a handler that opens another dialog or moves focus isn't fighting a modal that's still in the top layer. The event bubbles, so you can listen on document and never hold a reference to the palette. And detail carries the element as well as the value, so a handler can read any extra data attributes you hung on the row. That split — navigation for free, custom actions via an event — keeps the component generic without forcing a framework on you.
What this doesn't solve
The filter is indexOf. It is substring matching, not fuzzy matching, and the query is one contiguous string — so "dark brand" does not match an item whose keywords are "colors dark skins brand", because the words aren't adjacent. Splitting the query on whitespace and requiring every token to appear is about four more lines and would be a strict improvement; proper fuzzy ranking (the cmdk / Fuse.js class of thing) is a different component with a different weight budget. If your users expect to type "gh" and land on GitHub, you want a scorer, not this.
Related: the haystack is item.textContent, which is everything rendered in the row with no separator between child elements. The shortcut hint in .command-kbd is searchable whether you meant it to be or not, and a label that ends immediately before a hint concatenates with it — a row rendering GitHub plus a ↗ hint produces the string github↗, so a query spanning that boundary won't match. Keep queries short and lean on data-fv-keywords.
There is no ranking at all. Results appear in DOM order, so the first item you authored is always the default Enter target for an empty query. There is no recency, no frecency, no "you ran this yesterday" boost — the things that make Raycast and Linear feel psychic. Those need persistent state; Farvist's palette is stateless markup.
There is no virtualization. Every keystroke walks every .command-item in the dialog, and every item stays in the DOM; .command-list is just max-height: min(24rem, 50vh) with overflow-y: auto. For the six-item palette in our docs this is free. For a few hundred it's fine. For a palette over every file in a repo, you want a windowed list and a worker, and you should not start from this component.
Nor is it async. The item set is static markup, so a palette that queries a server as you type needs you to inject rows and re-run Farvist.enhance() to apply the roles — enhance() runs once on load and is exported for exactly this.
Two accessibility gaps we know about. Filtering doesn't announce anything: there's no live region, so a screen reader user who types four characters that eliminate every result hears silence rather than the empty state. And aria-expanded is set to "true" once and never updated, which is honest while the listbox is on screen and slightly wrong when the filter has emptied it. Both are on the list.
One trade-off we chose rather than deferred: .command-group headings get role="presentation". A listbox's children have to be options or groups, and a bare heading <li> is neither, so the simplest valid fix is to remove it from the accessibility tree. The cost is that the visual grouping doesn't reach screen reader users, who get one flat list of options. The more correct structure — nested role="group" containers with aria-label — means restructuring the markup an author writes, and we judged the flat list the better default. If your groups carry real meaning, that's the version to build.
Putting it together
Four decisions carry most of the weight: claim ⌘K only when you have something to open, lean on <dialog> for the top layer and the focus trap but position it yourself, keep focus in the input and steer with aria-activedescendant, and treat items as data you act on rather than links you focus.
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.