GODRICH
#css-animation#button#loader#transition#scroll#dashboard#ui#design-trends

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 in this search overlay ui design, 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. The same dashboard wrapping numbers in rings is SVG Progress Ring Widgets, and the hands-on feel over the graphs continues in Chart UI Design.

01Recent items on open

What you see in this command palette design

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.

Recent items on open
Open in a new tab

01 · Recent items on open — Measured area 2.486% · moving frames 13/23Original by GODRICH · SCSS and React ship in the zip · See the password

role=listboxrole=grouparia-labelledby

Where to look in the code

The open shortcut watches metaKey and ctrlKey together, because a Mac sends one and Windows sends the other.

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

What happens on screen

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.

Fuzzy match highlight
Open in a new tab

02 · Fuzzy match highlight — Measured area 4.095% · moving frames 12/23Original by GODRICH · SCSS and React ship in the zip · See the password

markaria-activedescendantopacity

How to read the code

Wrapping one character at a time in <mark> instead of one continuous run is what makes the skipping visible.

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

What moves

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.

Scroll that follows the cursor
Open in a new tab

03 · Scroll that follows the cursor — Measured area 16.5% · moving frames 20/23Original by GODRICH · SCSS and React ship in the zip · See the password

scrollIntoViewblock: nearesttranslateY

What to change

The Home and End keys land on the first and last row in one press.

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);
  // with block: 'nearest', a row already in view causes no scroll at all
  opts[at].scrollIntoView({ block: 'nearest' });
}

04Right-aligned shortcut badges

What you see

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.

Right-aligned shortcut badges
Open in a new tab

04 · Right-aligned shortcut badges — Measured area 15.966% · moving frames 19/23Original by GODRICH · SCSS and React ship in the zip · See the password

margin-left: autokbdtranslateY

Where to look in the code

Typing the printed combination actually runs that row, which is what keeps the cheat sheet from being a picture.

// the badge stays flush right no matter how long the name is — the auto margin eats all the leftover space
.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

What happens on screen

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.

Breadcrumb into a submenu
Open in a new tab

05 · Breadcrumb into a submenu — Measured area 2.706% · moving frames 9/23Original by GODRICH · SCSS and React ship in the zip · See the password

BackspacetranslateXaria-live

How to read the code

A visually hidden aria-live="polite" paragraph says which level is current.

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

What moves

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.

Preview pane on the right
Open in a new tab

06 · Preview pane on the right — Measured area 13.172% · moving frames 13/23Original by GODRICH · SCSS and React ship in the zip · See the password

grid-template-columnssteps(1, end)aria-describedby

What to change

The swap happens in a single frame instead of fading between the two.

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

What you see

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.

Empty state that still suggests
Open in a new tab

07 · Empty state that still suggests — Measured area 4.858% · moving frames 12/23Original by GODRICH · SCSS and React ship in the zip · See the password

hiddenrole=optionopacity

Where to look in the code

A notice and a choice are different objects here.

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);
  // echo the just-typed text into the suggestion row — "by that name" is what this state is worth
  document.querySelectorAll('.pal__q').forEach(function (s) { s.textContent = q; });
  at = -1;
}

08Spotlight-style center field

What happens on screen

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.

Spotlight-style center field
Open in a new tab

08 · Spotlight-style center field — Measured area 15.516% · moving frames 9/23Original by GODRICH · SCSS and React ship in the zip · See the password

steps(1, end)clip-path: inset()$ease-spring

How to read the code

Nothing below it shifts when it opens.

.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

What moves

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.

Bottom sheet palette
Open in a new tab

09 · Bottom sheet palette — Measured area 18.065% · moving frames 11/23Original by GODRICH · SCSS and React ship in the zip · See the password

translateYrole=dialog$r-card

What to change

The bottom padding adds env(safe-area-inset-bottom), so the last row sits clear of the home indicator on a notched screen.

.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. Boxes meant for copying install commands hit the same placement problem, and Terminal UI Design takes that panel on wholesale. 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 bar design css 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. The sheet that documents every palette shortcut is worked out, keycaps included, in Keyboard Shortcuts UI Design.

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.