Command palette

dialog.palette[data-ui="command-palette"][data-palette-key="k"]

Command palette — a searchable list of commands, opened from anywhere.

Examples

A combobox, which is not a menu

View “A combobox, which is not a menu” as
  • Navigation
  • Go to dashboard
  • Go to reports
  • Actions
  • Create project ⌘N
  • Invite people
  • Sign out
<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">&#8984;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">&#8984;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.

Level 2 tokens declared by command-palette
TokenDefault
--palette-backdropvar(--color-overlay)
--palette-durationvar(--duration-fast)
--palette-empty-colorvar(--color-on-surface-muted)
--palette-group-colorvar(--color-on-surface-muted)
--palette-group-font-sizevar(--font-size-xs)
--palette-input-font-sizevar(--font-size-base)
--palette-item-font-sizevar(--font-size-sm)
--palette-item-min-sizevar(--target-size-compact)
--palette-item-padding-blockvar(--space-2)
--palette-item-padding-inlinevar(--space-3)
--palette-item-radiusvar(--radius-field)
--palette-item-rail-width0
--palette-offset22vb
--palette-placeholder-colorvar(--color-on-surface-subtle)
--palette-search-border-colorvar(--color-border-subtle)
--palette-search-paddingvar(--space-3)
--palette-trigger-colorvar(--color-on-surface-muted)
--palette-trigger-inline-sizemin(20rem, 100%)
--palette-bgvar(--color-surface-raised)
--palette-border-colorvar(--color-border-subtle)
--palette-border-widthvar(--border-width)
--palette-colorvar(--color-on-surface)
--palette-inline-size36rem
--palette-max-block-size60vb
--palette-radiusvar(--radius-box)
--palette-shadowvar(--elevation-overlay)
--palette-item-rail-colorvar(--color-accent)
--palette-item-selected-bgvar(--color-secondary)
--palette-item-selected-colorvar(--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

  1. Name itaria-label on the <dialog>, and aria-label on the input. Group labels are visual only. A role="listbox" may contain only option and group children, so the module marks .palette-group-label role="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 real role="group" nesting was not chosen. Give every item a data-palette-value when its visible text is not what should be searched. Filtering matches that string; without it the item's whole textContent is 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 a palette-select event on the item, bubbling, with detail.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