Skip to content

Command Palette

Keyboard-first search across every action and destination in the product. For the people who use it, it stops being a feature and becomes the interface.

Also called Quick Open, Spotlight, Omnibox, Launcher, Cmd+K — in this system all of them are Command Palette.

Live preview

Everything below is the real component. Change the controls, tab through it, and turn on Inspector Mode to read any value off the screen.

Playground
Esc

Recent

api-gatewayProject
Postmortem — 4021Document

Actions

Deploy to production⌘⇧D
Create branch⌘B
Invite teammate
Delete deployment

Go to

Settings⌘,
Deployment log

Preferences

Toggle theme
↑↓ navigate↵ run⌘K toggle

9 of 9 commands

The empty state does the teaching

Before a keystroke, the palette should already be useful: what you touched recently, then what people do most. A blank list is a wasted first impression.

Esc

Recent

api-gatewayProject
Postmortem — 4021Document

Actions

Deploy to production⌘⇧D
Create branch⌘B
Invite teammate
Delete deployment

Go to

Settings⌘,
Deployment log

Preferences

Toggle theme
↑↓ navigate↵ run⌘K toggle

9 of 9 commands

Fuzzy matching

Typing "dep" narrows to everything containing that subsequence. Matching must survive skipped letters — people type the shape of a word, not its spelling.

Esc

Actions

Deploy to production⌘⇧D
Delete deployment

Go to

Deployment log
↑↓ navigate↵ run⌘K toggle

3 of 9 commands

Shortcuts teach themselves

Showing the shortcut on the row a user just ran is how they learn they never needed the palette. That is the palette working correctly, not losing.

Deploy to production⌘⇧D
Create branch⌘B
Settings⌘,

No results

Name the query back and stop. Do not offer "did you mean" guesses — in a command palette a wrong guess one Enter away from running is worse than an honest dead end.

Esc

No commands match “zzz”.

↑↓ navigate↵ run⌘K toggle

0 of 9 commands

Interaction states

Every state a user can put this component into, rendered side by side. If a state is missing here, it is missing in production too.

Trigger
Deploy
Row idle
Deploy
Row active
Delete
Row danger
Settings⌘,
With shortcut
Actions
Section header
No commands match “zzz”.
Empty
api-gatewayProject
Result hint

Anatomy

Every part, every measurement, and the reason it is that number.

Esc

Recent

api-gatewayProject
Postmortem — 4021Document

Actions

Deploy to production⌘⇧D
Create branch⌘B
Invite teammate
Delete deployment

Go to

Settings⌘,
Deployment log

Preferences

Toggle theme
↑↓ navigate↵ run⌘K toggle

9 of 9 commands

One input, a grouped list, and a footer that teaches the keyboard model. Focus stays in the input for the entire interaction.

  1. Panel width36rem, capped to viewport

    Wide enough for a command plus a hint plus a shortcut without truncation, narrow enough that the list is scanned in one vertical column rather than swept across.

  2. Vertical position12vh from the top

    Not centred. The list grows downward, and a vertically centred panel jumps as results change — the single most disorienting thing a palette can do.

  3. Input height48px, body-lg

    Larger than a form field. It is the only input on screen and it is the thing the user is looking at when the panel appears.

  4. List heightmax 24rem, ~8 rows

    Fixed so the panel never changes height as results narrow. A panel that resizes on every keystroke makes the row under the pointer move.

  5. Row height32px, 10px padding

    Dense, because this list is scanned rather than acted on individually, and because eight visible rows is worth more than six comfortable ones.

  6. Active rowAccent tint, no border

    Only one row is ever active, and it moves with the arrow keys. A hover highlight must set the same active row, never draw a second one.

  7. Footer32px, caption

    The keyboard legend. It looks like decoration and it is the reason people learn the arrow-and-Enter model in the first session.

  8. BackdropScrim + 2px blur

    The page is paused, not gone. The blur is what keeps the palette feeling layered over the work rather than replacing it.

Design tokens used

Values are read live from the running stylesheet, so this table can never drift from the code. Click any value to copy it.

Color

TokenValueUsed for
--ds-surface-overlay—Panel surface
--ds-layer-scrim—Backdrop behind the panel
--ds-accent-subtle—Active row fill
--ds-accent-text—Active row icon
--ds-fg-secondary—Idle row labels
--ds-fg-muted—Section headers, hints, icons
--ds-danger-text—A destructive command
--ds-surface-inset—Footer legend strip

Spacing

TokenValueUsed for
panel topDistance from the viewport top

Radius

TokenValueUsed for
--radius-2xlPanel corners

Shadow

TokenValueUsed for
--shadow-e5—Panel elevation

Motion

TokenValueUsed for
--duration-normalScale-in entrance

Recommended sizes

Pick a size from this table. Do not invent a new one — a fourth height is how a design system starts dying.

SizeHeightPaddingRadiusLabel gapTypeMax widthTouch targetWhen to use
Panel——20px——36rem—Capped to the viewport with 16px of margin on small screens.
Input48px0 14px——15px——The only input on screen; sized to be looked at, not filled in carefully.
Listmax 24rem——————About eight rows. Fixed height so the panel never resizes as results narrow.
Row32px0 8px—10px13px—44px on coarse pointersDense. Two-line rows go to 44px and cut the visible count to five.
Section header24px———11px uppercase——Quiet. It is a divider with a name, not a destination.
Footer32px———12px——The keyboard legend. Drop it on touch, where there is no keyboard to legend.

Do

<input role="combobox"
  aria-activedescendant="cp-a1" />
Keep focus in the input the whole timeThe user must be able to keep typing while the highlight moves. Move DOM focus into the list and every keystroke after the first arrow press goes to the wrong element.
Recentapi-gatewayPostmortem — 4021
Open onto recents, not a blank listThe empty state is where users learn what the palette can do. Recents plus the top few actions gives them something to press before they know what to type.
dtpDeploy to production
Match on a subsequence, not a prefix"dtp" should find "Deploy to production". Prefix matching forces the user to remember how the command starts, which is the recall problem the palette exists to remove.
Deploy to production⌘⇧D
Show the shortcut next to the commandThe palette should teach itself out of a job for frequent actions. A user who learns ⌘⇧D from the row they just ran is a user you made faster.

Don't

Delete deployment
Do not run destructive commands on EnterThe palette is used at speed, with the highlight often one row from where the user thinks it is. Anything irreversible must open a confirmation instead of executing.
Do not let the panel resize as results narrowA panel that shrinks from eight rows to two moves everything under the pointer mid-click. Fix the list height and scroll inside it.
“Just press ⌘K and type export” — in a product with no Export button anywhere
Do not make the palette the only pathIt is an accelerator. A feature reachable only by typing its name is a feature nobody who did not already know about it will ever find.
No commands match “delet”.✗ Did you mean “Delete project”? ↵
Do not guess for the user on no results"Did you mean Delete project?" one Enter away from running is a trap. State the failure plainly and let them retype.

Accessibility

Not a checklist to run at the end. These are the requirements the component was built from.

2.1.1KeyboardA2.1.2No Keyboard TrapA2.4.3Focus OrderA4.1.2Name, Role, ValueA4.1.3Status MessagesAA

Contrast

  • The active row must be distinguishable from idle rows by more than a faint tint — at small row heights a 6% wash is invisible on a laptop screen in daylight.
  • Section headers may be quiet but are still content and owe 4.5:1.
  • A destructive row must not rely on red alone. The icon carries the second signal.
  • The keyboard legend in the footer is content — it is the documentation for the component — and owes full text contrast.

Keyboard

⌘K / ⌃KOpens and closes. Also bind "/" when no input is focused, which is the other convention users arrive with.
↑ / ↓Moves the active row, wrapping at both ends. Focus does not move.
EnterRuns the active command. Destructive commands open a confirmation instead.
EscCloses and returns focus to wherever it was before opening.
TabNothing inside the palette. There is one focusable element, so there is nowhere to tab to.
Home / EndJumps to the first or last result.

Screen readers

  • Announce the result count after typing settles, debounced by about 300ms. Announcing on every keystroke is unusable.
  • Each row should announce its section: "Actions, Deploy to production, has shortcut Command Shift D".
  • The palette is modal, so content behind it must be inert. A screen-reader user who can arrow into the page underneath has no way to tell the palette is still open.

Focus & touch

  • Focus enters the input on open and never leaves it. On close it returns to the element that was focused before — not to the body, and not to the trigger if the palette was opened by shortcut from somewhere else. Running a command that navigates should move focus to the new view’s heading.
  • A command palette is a keyboard accelerator, and on touch it is mostly ceremony. If you ship it there, drop the keyboard legend, grow rows to 44px, and accept that the on-screen keyboard will cover half the results — which is the real reason the pattern does not belong on a phone.
AttributeApplied toNotes
role="combobox"The inputWith aria-expanded and aria-controls pointing at the list. This is the pattern; a plain input with a div of results announces nothing.
aria-activedescendantThe inputThe id of the active row. This is what moves the screen-reader cursor without moving DOM focus.
role="listbox" / "option"The list and its rowsWith aria-selected on the active row. Section headers must be outside the option elements or they are announced as results.
role="dialog" aria-modal="true"The panelIt traps interaction with the page behind it, so it owes the modal contract — including inert content behind.
aria-live="polite"A result count"9 of 24 commands" after typing stops. Without it a screen-reader user has no idea whether their query matched anything.

Code

Example usage

tsx
1import { CommandPalette } from '@/ui/CommandPalette'23// One global shortcut, bound once, high in the tree.4React.useEffect(() => {5  const onKey = (e: KeyboardEvent) => {6    if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {7      e.preventDefault()8      setOpen((o) => !o)9    }10    // "/" is the other convention users arrive with — but only when they are11    // not already typing somewhere.12    if (e.key === '/' && !isTypingTarget(e.target)) {13      e.preventDefault()14      setOpen(true)15    }16  }17  window.addEventListener('keydown', onKey)18  return () => window.removeEventListener('keydown', onKey)19}, [])2021// Subsequence matching: "dtp" must find "Deploy to production".22function fuzzy(query: string, text: string) {23  const t = text.toLowerCase()24  let i = 025  for (const ch of query.toLowerCase()) {26    i = t.indexOf(ch, i)27    if (i === -1) return false28    i++29  }30  return true31}3233<CommandPalette34  open={open}35  onClose={() => setOpen(false)}36  groups={[37    { id: 'recent',  label: 'Recent',  items: recents },38    { id: 'actions', label: 'Actions', items: actions },39    { id: 'goto',    label: 'Go to',   items: destinations },40  ]}41  onRun={(cmd) => {42    // Never execute something irreversible straight off Enter.43    if (cmd.destructive) return confirmThen(cmd.run)44    cmd.run()45  }}46/>

Framework-free HTML

html
<div role="dialog" aria-modal="true" aria-label="Command palette">
  <!-- One focusable element. The list is driven by aria-activedescendant. -->
  <input
    role="combobox"
    aria-expanded="true"
    aria-controls="cp-list"
    aria-activedescendant="cp-deploy"
    aria-label="Search commands"
    autocomplete="off"
  />

  <div id="cp-list" role="listbox" aria-label="Commands">
    <!-- Headers sit OUTSIDE the options, or they are announced as results. -->
    <p id="cp-h-actions" class="cp-header">Actions</p>

    <div id="cp-deploy" role="option" aria-selected="true">
      <svg aria-hidden="true">…</svg>
      Deploy to production
      <kbd>⌘⇧D</kbd>
    </div>
  </div>

  <p class="sr-only" role="status" aria-live="polite">9 of 24 commands</p>
</div>

CSS

css
.ds-palette {
  position: fixed;
  inset: 0;
  display: flex;
  justify-content: center;
  align-items: flex-start;
  padding-block-start: 12vh;        /* NOT centred: the list grows downward */
  z-index: 96;
}

.ds-palette__panel {
  inline-size: min(36rem, 100% - 2rem);
  border-radius: var(--radius-2xl);
  background: var(--ds-surface-overlay);
  box-shadow: var(--shadow-e5);
  animation: scale-in 180ms cubic-bezier(0.32, 0.72, 0, 1) both;
}

.ds-palette__input { block-size: 48px; font-size: 15px; }

/* Fixed height. A panel that shrinks as results narrow moves the row the
   pointer is aiming at. */
.ds-palette__list {
  max-block-size: 24rem;
  overflow-y: auto;
  padding: 6px;
}

.ds-palette__option[aria-selected='true'] {
  background: var(--ds-accent-subtle);
  color: var(--ds-fg);
}

@media (prefers-reduced-motion: reduce) {
  .ds-palette__panel { animation: none; }
}

/* On touch the legend is describing a keyboard that is not there. */
@media (pointer: coarse) {
  .ds-palette__footer { display: none; }
  .ds-palette__option { min-block-size: 44px; }
}

Component API

CommandPalette

PropTypeDefaultDescription
open*boolean—Controlled. The shortcut lives in the app shell, not inside the component.
onClose*() => void—Called on Esc, on backdrop click, and after a command runs.
groups*CommandGroup[]—Ordered sections. Recents first, then actions, then destinations.
onRun*(cmd: Command) => void—Runs the active command. Gate destructive commands behind a confirmation here.
placeholderstring'Search commands…'Should hint at scope: "Search commands, projects and settings…".
emptyRenderReactNode—Shown when the query matches nothing. State the query; do not guess.

Command

PropTypeDefaultDescription
id*string—Stable. Used for the aria-activedescendant target and for recents.
label*string—What the user types towards. Lead with the verb: "Deploy to production".
keywordsstring[]—Synonyms the label does not contain — "remove" for a delete command.
shortcutstring—Displayed on the row. Showing it is how the palette teaches itself out of a job.
destructivebooleanfalseStyles the row and forces a confirmation instead of running on Enter.

Notes

Professional tips

  • Lead every command with its verb. Users type the action, and "Deploy to production" sorts and matches far better than "Production deployment".
  • Give commands hidden keyword synonyms. Half your users will type "remove" for the command you called "Delete".
  • Weight recents heavily in the ranking. What someone did five minutes ago is the best available predictor of what they want now.
  • Scope the palette when it is opened from inside a context — a palette opened in a document should offer that document’s commands first.
  • Advertise the shortcut in the header search box. A palette nobody knows about has no users, and the ⌘K chip in a fake search field is how everyone learns.

Performance

  • Debounce the announced result count, not the filtering. Filtering must feel instant; announcing on every keystroke is what makes it unusable with a screen reader.
  • Pre-index commands once at registration rather than lowercasing every label on every keystroke. With 300 commands that difference is visible.
  • Virtualise past roughly 100 visible rows — though a palette that regularly shows 100 rows needs better ranking, not a virtualiser.
  • Mount the panel only when open. A permanently mounted palette holding a focus trap and a keydown listener is a subtle source of interference across the app.

Common mistakes

  • Moving DOM focus into the list, so typing after the first arrow key goes nowhere.
  • Prefix-only matching, which forces the user to recall how the command starts.
  • A blank empty state that teaches nothing about what the palette can do.
  • A panel that resizes as results narrow, moving the row under the pointer.
  • Running destructive commands directly on Enter.
  • Section headers marked up as options, so the screen reader announces "Actions" as a runnable result.
  • Vertically centring the panel, so it jumps every time the result count changes.

Real-world recommendations

  • Adoption follows discoverability, not capability. Products that put a fake search box with a ⌘K chip in the header get several times the usage of ones that only bind the shortcut.
  • Instrument which commands are run from the palette. Anything in the top ten deserves a real shortcut and probably a real button too.
  • Editors set the expectation — VS Code, Linear, Raycast. Users arrive knowing ⌘K, arrow keys and Enter, and every deviation from that model costs more than whatever it bought.
  • A palette works best when it can navigate as well as act. "Go to api-gateway" and "Deploy api-gateway" in the same list is what makes it feel like the whole product is one input.