GODRICH

Command Palette UI Design — 9 That Really Open

Command palette UI design is the pattern behind every Cmd+K overlay: one floating field that searches commands, documents, and screens at once.

Auto-plays · click a tile to jump to its section · all nine in one zip

The nine are ordered by when a person meets them, not by popularity. What the palette shows before a single key is pressed comes first (01), then how it matches once typing starts (02). A list taller than its box has to let the scroll chase the cursor (03), and the right edge of each row carries the shortcut so next time nobody needs the palette at all (04). When one command is not the whole job it goes a level deeper (05), and when a name is not enough to choose by, a preview rides alongside (06). Number 07 is the missed search, and 08 and 09 are the same palette rebuilt for a different screen. All nine carry role="dialog" and role="listbox", and arrows, Enter, and Escape are wired for real.

01Recent items on open

The palette opens with nothing typed yet, and the commands you last used already stand under the Recent and Go to headings. Each cluster is wrapped in role="group" with aria-labelledby, so a screen reader announces the heading as the name of that run of rows. The open shortcut watches metaKey and ctrlKey together, because a Mac sends one and Windows sends the other.

role=listboxrole=grouparia-labelledby
document.addEventListener('keydown', function (e) {
  var k = e.key.toLowerCase();
  if ((e.metaKey || e.ctrlKey) && k === 'k') {
    live();
    pal.classList.toggle('is-shut');
    if (!pal.classList.contains('is-shut')) input.focus({ preventScroll: true });
    e.preventDefault();
    return;
  }
  if (pal.classList.contains('is-shut')) return;
  if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
    live(); mark(at + (e.key === 'ArrowDown' ? 1 : -1)); e.preventDefault();
  } else if (e.key === 'Enter' && opts[at]) {
    live();
    opts.forEach(function (o) { o.setAttribute('aria-selected', String(o === opts[at])); });
    opts[at].classList.add('is-run');
    e.preventDefault();
  } else if (e.key === 'Escape') {
    live(); pal.classList.add('is-shut');
  }
});

02Fuzzy match highlight

Skipping letters still finds the row as long as the order holds. The test is a subsequence rather than a substring, so each character of the query is located with indexOf(c, from) moving strictly forward, and a single miss drops the row. Wrapping one character at a time in <mark> instead of one continuous run is what makes the skipping visible.

markaria-activedescendantopacity
function hits(name, q) {
  var lower = name.toLowerCase(), need = q.toLowerCase();
  var idx = [], i = 0;
  for (var c = 0; c < need.length; c++) {
    var k = lower.indexOf(need[c], i);
    if (k < 0) return null;
    idx.push(k); i = k + 1;
  }
  return idx;
}
function paint(name, idx) {
  var html = '', last = 0;
  idx.forEach(function (k) {
    html += name.slice(last, k) + '<mark class="pal__m is-on">' + name[k] + '</mark>';
    last = k + 1;
  });
  return html + name.slice(last);
}

03Scroll that follows the cursor

Seven rows live in a box that shows four, and the list starts sliding only once the highlight reaches the bottom edge. The whole behavior is scrollIntoView with block: 'nearest', which does nothing at all when the row is already visible, so the view never jerks on the first three presses. The Home and End keys land on the first and last row in one press.

scrollIntoViewblock: nearesttranslateY
function move(i) {
  pal.classList.remove('is-demo');
  at = Math.max(0, Math.min(i, opts.length - 1));
  opts.forEach(function (o, k) { o.classList.toggle('is-at', k === at); });
  input.setAttribute('aria-activedescendant', opts[at].id);
  // block: 'nearest' 라서 이미 보이는 줄이면 스크롤이 아예 일어나지 않는다
  opts[at].scrollIntoView({ block: 'nearest' });
}

04Right-aligned shortcut badges

Whether the command name is one word or four, the keycaps line up at the right edge. The alignment comes from margin-left: auto on the badge cluster rather than a flex value on the label, so the leftover width is eaten in one piece. Typing the printed combination actually runs that row, which is what keeps the cheat sheet from being a picture.

margin-left: autokbdtranslateY
// 이름 길이와 무관하게 배지는 늘 오른쪽 끝 — 남는 자리를 auto 마진이 통째로 먹는다
.pal__keys { margin-left: auto; display: inline-flex; gap: $sp-1; }
.pal__kbd {
  box-sizing: border-box;
  min-width: 16px; height: 16px;
  display: inline-flex; align-items: center; justify-content: center;
  padding: 0 $sp-1;
  border-radius: $r-xs;
  background: rgba(255, 247, 230, .14);
  box-shadow: 0 1px 0 rgba(255, 247, 230, .28);
  font-family: inherit;
  font-size: 9px; font-weight: 800;
  color: rgba(255, 247, 230, .72);
}

05Breadcrumb into a submenu

Choosing the first row grows a pill in front of the field and swaps the list for that command's targets. The way back is not a back button but Backspace on an empty field, so the hand never leaves the input. A visually hidden aria-live="polite" paragraph says which level is current.

BackspacetranslateXaria-live
document.addEventListener('keydown', function (e) {
  var r = rows();
  if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
    pal.classList.remove('is-demo'); move(at + (e.key === 'ArrowDown' ? 1 : -1)); e.preventDefault();
  } else if (e.key === 'Enter' && r[at]) {
    if (!deep && at === 0) { deep = true; paint(); } else { r[at].setAttribute('aria-selected', 'true'); }
    e.preventDefault();
  } else if (e.key === 'Backspace' && deep && !input.value) {
    deep = false; paint(); input.focus({ preventScroll: true }); e.preventDefault();
  } else if (e.key === 'Escape') {
    deep = false; paint(); input.value = ''; input.focus({ preventScroll: true });
  }
});

06Preview pane on the right

Whatever row the cursor stands on appears in the right half at once, and every move replaces the pane's entire contents. One line of grid-template-columns: 1fr 104px splits the list from the preview, and aria-describedby on the field ties the two together for screen readers. The swap happens in a single frame instead of fading between the two.

grid-template-columnssteps(1, end)aria-describedby
function show(i) {
  pal.classList.remove('is-demo');
  at = Math.max(0, Math.min(i, opts.length - 1));
  opts.forEach(function (o, k) { o.classList.toggle('is-at', k === at); });
  pvs.forEach(function (p, k) { p.classList.toggle('is-on', k === at); });
  input.setAttribute('aria-activedescendant', opts[at].id);
}

07Empty state that still suggests

When nothing matches, the palette does not stop at a single notice line: two rows carrying the text you just typed rise in its place. Those rows also carry role="option", so arrows and Enter keep working on them. A notice and a choice are different objects here.

hiddenrole=optionopacity
function render(q) {
  pal.classList.remove('is-demo');
  var shown = 0;
  rows.forEach(function (o, i) {
    var hit = !q || names[i].indexOf(q) >= 0;
    o.hidden = !hit;
    if (hit) shown++;
  });
  empty.hidden = !(q && shown === 0);
  // 제안 줄에 방금 친 글자를 그대로 넣는다 — "그 이름으로" 가 이 상태의 값이다
  document.querySelectorAll('.pal__q').forEach(function (s) { s.textContent = q; });
  at = -1;
}

08Spotlight-style center field

At first only one large field floats there, and the results unfold downward the moment a character lands. The value being animated is neither height nor scaleY but the bottom inset of a clip-path, and the panel's 84px height is reserved even while it stays closed. Nothing below it shifts when it opens.

steps(1, end)clip-path: inset()$ease-spring
.sp__panel {
  position: relative;
  box-sizing: border-box;
  height: 84px;
  margin: $sp-2 0 0;
  clip-path: inset(0 0 100% 0 round 16px);
}
.sp.is-open .sp__panel { clip-path: inset(0 0 0 0 round 16px); }

09Bottom sheet palette

On a phone the centered modal gives way to a sheet that rises and sticks to the bottom edge. Only the top corners are rounded, and the grabber bar at the top is a real close button with an aria-label rather than a decoration. The bottom padding adds env(safe-area-inset-bottom), so the last row sits clear of the home indicator on a notched screen.

translateYrole=dialog$r-card
.ph__sheet {
  position: absolute;
  left: 0; right: 0; bottom: 0;
  box-sizing: border-box;
  padding: $sp-2 $sp-2 calc(#{$sp-3} + env(safe-area-inset-bottom));
  border-radius: $r-card $r-card 0 0;
  background: #fff;
  box-shadow: $shadow-raised;
  transform: translateY(100%);
}
.ph.is-open .ph__sheet { transform: translateY(0); }

Where it breaks — the trap

The longest fight in this set was not an animation but the single frame that gets cut out as the cover image. The renderer here slices a two-second loop into 24 stills and picks the one that differs most from the first as the poster. Number 06 was built so the right-hand pane switched while the cursor was traveling between rows, and that switch happened to be the largest change of all, so the frame chosen showed the cursor on row two and the pane on row three. Two things fixed it. The pane now changes only after the cursor has landed and stopped, and each preview got its own background tint so the swap is unmistakably the biggest change in the loop. A second problem surfaced right there: the last of the 24 stills sits at 95.8% of two seconds, so the return swap parked at 96% was never photographed at all. Only after pulling that return forward to 90% did one lap close cleanly.

The second trap is two layers of text in one field. Items 02, 07, and 08 lay a ghost string over the input to show what a person is typing, while the placeholder underneath stayed exactly where it was, so the two strings overlapped into shapes that are not words. That unreadable field went straight into a cover image before anyone looked. Turning ::placeholder transparent for the duration of the autoplay loop is the whole fix, but no gate caught it — only opening the capture did.

The third is phone width. Item 01 stacks one field, two headings, and four rows, which came out 186px tall, while a 320×200 screen leaves 174px once the stage padding is removed. Twelve pixels short meant the last row was clipped on a phone. Rows went from 24px to 22px, headings got a fixed 12px line-height, and only the card's vertical padding dropped from 8px to 4px, landing at 164px. Horizontal padding was left alone, so nothing about the wide-screen impression changed. The sources with all three corrections are inside the zip that opens with the archive password vswx5meq, all nine of them.

The fourth came from item 08. Folding the results panel with scaleY(0) seemed obvious, but shrinking toward the top edge squashes the text inside it as well. Half-folded frames held letters stretched flat, which read as a broken screen rather than a closing panel. clip-path: inset() paints the element first and only then cuts it away, so proportions survive; the price is that rounded corners have to be restated, which is what round 16px is doing there.

Accessibility (reduced-motion)

In all nine the focus stays parked on the input. Arrow keys never call focus() on a row; the current row is announced because aria-activedescendant on the field changes instead. The reason is that the field has to keep receiving characters, and the requirements for this arrangement are written up in the MDN combobox role reference. The overlay itself carries role="dialog" with aria-modal="true" so the box, not the page behind it, is announced as the current context.

Key What the nine do with it
⌘K · Ctrl+K Open and close the palette (01, 09). Reading metaKey and ctrlKey together covers Mac and Windows in one branch
↑ ↓ Move between rows. In 03 this also runs scrollIntoView({ block: 'nearest' })
Enter Run the standing row; aria-selected moves onto it
Escape Close, or clear the field. Focus returns through focus({ preventScroll: true })
Backspace Special only in 05 — on an empty field it deletes a level, not a character

Under prefers-reduced-motion: reduce only the autoplay loop stops; the current state is left untouched. The pill in 05 arrives in place instead of sliding, the panel in 08 appears already unfolded with no cut-away, and the sheet in 09 sits attached without traveling up. Which row is chosen is still spoken by aria-selected even with every movement switched off. The reason preventScroll is attached to every focus call is that these demos sit inside the article as iframes, and a bare focus() drags the reader's viewport down to the demo.

Parts that share a screen with this one are collected under the dashboard category, and parts that open on a press are under the click category.

FAQ

Why does a search field need role="dialog" at all?

Because the moment a floating box covers the page behind it, it stops being a field and becomes a screen. Without role="dialog" and aria-modal="true" a screen reader keeps offering the links and buttons underneath, and the person using it cannot tell where they are. The opposite also holds: an inline search box that never covers the page needs no dialog and works fine as a combobox alone.

Is subsequence matching always better than substring matching?

It is worse on short lists. Matching with gaps lets many more rows through, so with fewer than about ten options an irrelevant row often lands on top. To use it the way 02 does, score each result by how many gaps it needed and sort on that score; the version here is the minimum implementation, with the original order kept and no ranking.

Can all nine go on the same page together?

Only after checking the shortcuts. Items 01 and 09 both listen for ⌘K, so dropping the pair in as they are opens both on a single press. Inside the zip each demo is a self-contained file, so the safe move on a real page is to keep one listener for the shortcut and let that one open whichever palette the screen calls for.

Enter the archive password

The password is inside this article. You will find it as you read.