Command palette
Command palette — a searchable list of commands, opened from anywhere.
Examples
<button class="btn btn-outline palette-trigger" type="button"
command="show-modal" commandfor="demo-palette">
<svg class="icon" aria-hidden="true"><use href="#icon-search"></use></svg>
Search commands…
<kbd class="kbd">⌘K</kbd>
</button>
<dialog class="palette" id="demo-palette" aria-label="Command palette"
data-ui="command-palette" data-palette-key="k">
<div class="palette-search">
<svg class="icon palette-search-icon" aria-hidden="true"><use href="#icon-search"></use></svg>
<input class="palette-input" type="text" placeholder="Type a command…"
aria-label="Search commands" />
</div>
<ul class="palette-list">
<li class="palette-group-label">Navigation</li>
<li class="palette-item" data-palette-value="Go to dashboard">
<svg class="icon palette-item-icon" aria-hidden="true"><use href="#icon-columns"></use></svg>
Go to dashboard
</li>
<li class="palette-item" data-palette-value="Go to reports">
<svg class="icon palette-item-icon" aria-hidden="true"><use href="#icon-file"></use></svg>
Go to reports
</li>
<li class="palette-group-label">Actions</li>
<li class="palette-item" data-palette-value="Create project">
<svg class="icon palette-item-icon" aria-hidden="true"><use href="#icon-plus"></use></svg>
Create project
<kbd class="kbd kbd-plain palette-shortcut">⌘N</kbd>
</li>
<li class="palette-item" data-palette-value="Invite people">
<svg class="icon palette-item-icon" aria-hidden="true"><use href="#icon-upload"></use></svg>
Invite people
</li>
<li class="palette-item" data-palette-value="Sign out">
<svg class="icon palette-item-icon" aria-hidden="true"><use href="#icon-external-link"></use></svg>
Sign out
</li>
</ul>
<p class="palette-empty" hidden>No matching commands</p>
</dialog>Open it and type, then use the arrows: the caret never leaves the input.
That is the difference between a combobox and a menu, and choosing wrong is the standard bug here — a roving tabindex moves real focus between items, which is right for a menu but would send your next keystroke somewhere else while you are still typing. Instead aria-activedescendant on the input names the active option, the screen reader announces it, and focus stays put. So the highlight is [aria-selected="true"] and not :focus, because nothing in the list is focused. The overlay is a <dialog>, so the top layer, backdrop, inert, focus trap and Escape are all the browser’s. A click on the backdrop closes it, which <dialog> does not do by itself — press and release are both checked, so a selection dragged out of the input does not dismiss the panel. .palette-item-icon is the slot for a glyph; it is aria-hidden, because the row already says what it does. Two things are genuinely missing from the platform: the ⌘K/Ctrl+K binding, since there is no declarative way to bind a shortcut to an element, and light dismiss — closedby="any" looks like the answer and is not, because it closes on the pointerup and a touchscreen then sends its compatibility click to whatever was behind. Closing on the click keeps the panel in the top layer at the moment the browser decides where that click goes, so a tap that dismisses this palette over a link does not follow the link.
Tokens 29
Level 2, declared on .palette itself. Set any of them on that selector to restyle this component without touching the skin.
| Token | Default |
|---|---|
| --palette-backdrop | var(--color-overlay) |
| --palette-duration | var(--duration-fast) |
| --palette-empty-color | var(--color-on-surface-muted) |
| --palette-group-color | var(--color-on-surface-muted) |
| --palette-group-font-size | var(--font-size-xs) |
| --palette-input-font-size | var(--font-size-base) |
| --palette-item-font-size | var(--font-size-sm) |
| --palette-item-min-size | var(--target-size-compact) |
| --palette-item-padding-block | var(--space-2) |
| --palette-item-padding-inline | var(--space-3) |
| --palette-item-radius | var(--radius-field) |
| --palette-item-rail-width | 0 |
| --palette-offset | 22vb |
| --palette-placeholder-color | var(--color-on-surface-subtle) |
| --palette-search-border-color | var(--color-border-subtle) |
| --palette-search-padding | var(--space-3) |
| --palette-trigger-color | var(--color-on-surface-muted) |
| --palette-trigger-inline-size | min(20rem, 100%) |
| --palette-bg | var(--color-surface-raised) |
| --palette-border-color | var(--color-border-subtle) |
| --palette-border-width | var(--border-width) |
| --palette-color | var(--color-on-surface) |
| --palette-inline-size | 36rem |
| --palette-max-block-size | 60vb |
| --palette-radius | var(--radius-box) |
| --palette-shadow | var(--elevation-overlay) |
| --palette-item-rail-color | var(--color-accent) |
| --palette-item-selected-bg | var(--color-secondary) |
| --palette-item-selected-color | var(--color-on-secondary) |
Variants and states
Variants
.palette-lg.palette-sm
Inside it
.palette-input.palette-item.palette-trigger.kbd.palette-empty.palette-footer.palette-group-label.palette-item-icon.palette-list.palette-search.palette-search-icon.palette-shortcut
State it reads
Read from the platform, never mirrored into a class that could disagree with it.
:focus[open]data-legacy
Before you ship it
This one needs a script. Opt the markup in with data-ui="command-palette" and import import 'mostlycss/js/components/command-palette' — that specifier and not the package root, which is a bundle carrying a second copy of the registry, so a page holding both initialises every element twice. What you lose without the script is listed below — for some components that is only polish, for others it is the whole interaction.
What you have to do 1 requirements
- Name it —
aria-labelon the<dialog>, andaria-labelon the input. Group labels are visual only. Arole="listbox"may contain onlyoptionandgroupchildren, so the module marks.palette-group-labelrole="presentation": the headings help the eye scan and the accessibility tree hears one flat list of options. If a group genuinely disambiguates its items — two "Settings" under different headings — put that in the item's own text, where it is announced with the option rather than beside it. See command-palette.ts for why realrole="group"nesting was not chosen. Give every item adata-palette-valuewhen its visible text is not what should be searched. Filtering matches that string; without it the item's wholetextContentis used, which includes the shortcut hint, so typing "g" would match every row with a G in its key combination. Handle activation. The module dispatches apalette-selectevent on the item, bubbling, withdetail.item. What a command does is your application. An item that is a link may simply be an<a>inside the<li>— the module activates it by clicking it. Keep the palette out of the tab order of the page: it is a modal, so the browser already does.
src/css/components/command-palette.css · npx mostlycss add command-palette