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.
Everything below is the real component. Change the controls, tab through it, and turn on Inspector Mode to read any value off the screen.
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.
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.
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.
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.
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.
Every part, every measurement, and the reason it is that number.
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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
Values are read live from the running stylesheet, so this table can never drift from the code. Click any value to copy it.
Color
| Token | Value | Used 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
| Token | Value | Used for |
|---|---|---|
| panel top | Distance from the viewport top |
Radius
| Token | Value | Used for |
|---|---|---|
| --radius-2xl | Panel corners |
Shadow
| Token | Value | Used for |
|---|---|---|
| --shadow-e5 | — | Panel elevation |
Motion
| Token | Value | Used for |
|---|---|---|
| --duration-normal | Scale-in entrance |
Pick a size from this table. Do not invent a new one — a fourth height is how a design system starts dying.
| Size | Height | Padding | Radius | Label gap | Type | Max width | Touch target | When to use |
|---|---|---|---|---|---|---|---|---|
| Panel | — | — | 20px | — | — | 36rem | — | Capped to the viewport with 16px of margin on small screens. |
| Input | 48px | 0 14px | — | — | 15px | — | — | The only input on screen; sized to be looked at, not filled in carefully. |
| List | max 24rem | — | — | — | — | — | — | About eight rows. Fixed height so the panel never resizes as results narrow. |
| Row | 32px | 0 8px | — | 10px | 13px | — | 44px on coarse pointers | Dense. Two-line rows go to 44px and cut the visible count to five. |
| Section header | 24px | — | — | — | 11px uppercase | — | — | Quiet. It is a divider with a name, not a destination. |
| Footer | 32px | — | — | — | 12px | — | — | The keyboard legend. Drop it on touch, where there is no keyboard to legend. |
<input role="combobox"
aria-activedescendant="cp-a1" />dtpDeploy to productionNot a checklist to run at the end. These are the requirements the component was built from.
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 / ⌃K | Opens 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. |
| Enter | Runs the active command. Destructive commands open a confirmation instead. |
| Esc | Closes and returns focus to wherever it was before opening. |
| Tab | Nothing inside the palette. There is one focusable element, so there is nowhere to tab to. |
| Home / End | Jumps 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.
| Attribute | Applied to | Notes |
|---|---|---|
| role="combobox" | The input | With aria-expanded and aria-controls pointing at the list. This is the pattern; a plain input with a div of results announces nothing. |
| aria-activedescendant | The input | The id of the active row. This is what moves the screen-reader cursor without moving DOM focus. |
| role="listbox" / "option" | The list and its rows | With 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 panel | It 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. |
Example usage
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
<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
.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
| Prop | Type | Default | Description |
|---|---|---|---|
| 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. |
| placeholder | string | 'Search commands…' | Should hint at scope: "Search commands, projects and settings…". |
| emptyRender | ReactNode | — | Shown when the query matches nothing. State the query; do not guess. |
Command
| Prop | Type | Default | Description |
|---|---|---|---|
| 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". |
| keywords | string[] | — | Synonyms the label does not contain — "remove" for a delete command. |
| shortcut | string | — | Displayed on the row. Showing it is how the palette teaches itself out of a job. |
| destructive | boolean | false | Styles the row and forces a confirmation instead of running on Enter. |
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.