Navigation

v0.0.4
Overlays

Command palette

A dialog that is one search field over a list of commands.

On this page

A command palette is one search field over a list of commands, opened from anywhere with a shortcut. It is a dialog with a different interior: add command beside dialog, and put a command-search row, a listbox and an optional command-footer inside. Because it is a native <dialog>, a button opens it with command="show-modal", and the browser supplies the scrim, the focus trap and Esc.

Command palette

<button type="button" class="btn" commandfor="palette" command="show-modal">Search commands  <kbd class="kbd">⌘K</kbd></button><dialog id="palette" class="dialog command" closedby="any" aria-label="Command palette">  <label class="command-search">    <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">      <circle cx="11" cy="11" r="6" />      <path d="m20 20-4.35-4.35" />    </svg>    <input class="command-input" id="palette-input" type="search" role="combobox" aria-expanded="true" aria-controls="palette-list" aria-activedescendant="cmd-new" aria-autocomplete="list" autocomplete="off" placeholder="Type a command or search" autofocus>    <kbd class="kbd">Esc</kbd>  </label>  <div class="listbox command-list" id="palette-list" role="listbox" aria-label="Commands">    <div class="listbox-group" role="group" aria-labelledby="group-suggestions">      <div class="listbox-label" id="group-suggestions">Suggestions</div>      <div class="listbox-option" role="option" id="cmd-new" aria-selected="true">        <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">          <path d="M12 5v14M5 12h14" />        </svg>New project        <span class="listbox-end">          <kbd class="kbd">⌘</kbd>          <kbd class="kbd">N</kbd>        </span>      </div>      <div class="listbox-option" role="option" id="cmd-deploy">        <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">          <path d="M12 16V4M7 9l5-5 5 5M5 20h14" />        </svg>Deploy to production</div>      <div class="listbox-option" role="option" id="cmd-invite">        <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">          <circle cx="9" cy="8" r="3.5" />          <path d="M3 20a6 6 0 0 1 12 0M18 8v6M15 11h6" />        </svg>Invite a member</div>    </div>    <div class="listbox-group" role="group" aria-labelledby="group-go">      <div class="listbox-label" id="group-go">Go to</div>      <div class="listbox-option" role="option" id="cmd-settings">        <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">          <path d="M4 6h10M18 6h2M4 12h2M10 12h10M4 18h10M18 18h2" />          <circle cx="16" cy="6" r="2" />          <circle cx="8" cy="12" r="2" />          <circle cx="16" cy="18" r="2" />        </svg>Settings        <span class="listbox-end">          <kbd class="kbd">G</kbd>          <kbd class="kbd">S</kbd>        </span>      </div>      <div class="listbox-option" role="option" id="cmd-billing">        <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">          <rect x="3" y="6" width="18" height="13" rx="2" />          <path d="M3 10h18" />        </svg>Billing</div>    </div>    <div class="listbox-empty">No commands match.</div>  </div>  <footer class="command-footer">    <span class="command-hint">      <kbd class="kbd">↑</kbd>      <kbd class="kbd">↓</kbd>to move</span>    <span class="command-hint">      <kbd class="kbd">↵</kbd>to run</span>    <span class="command-hint">      <kbd class="kbd">Esc</kbd>to close</span>  </footer></dialog><script type="module">  import { attach } from '@master/ui/behaviors'  attach()</script>
Open the palette with the button, or with ⌘K or Ctrl K while focus is in the preview. Type to filter, move with the arrow keys and run a command with Enter.

#Parts

  • command-search is a <label>, so the whole row focuses the input. It holds an icon, the command-input and an optional hint.
  • command-input has no material of its own: the dialog is its surface, and a hairline parts it from the results.
  • command-list goes on the listbox. It takes the remaining height and scrolls inside the dialog.
  • command-footer holds command-hint groups: keys in kbd, then what they do.

The palette opens near the top of the viewport and grows downward, so the field stays where it is while the list under it changes length.

#The behavior

The dialog opens and closes without script. The command behavior adds what a palette has beyond a dialog. It takes the dialog and returns the function that detaches it.

app.js
import { command } from '@master/ui/behaviors/command.js'command(document.querySelector('.command'))document.getElementById('cmd-new').addEventListener('click', () => {  // Create the project.})
  • The shortcut. ⌘K or Ctrl K opens the palette from anywhere in the document and closes it again.
  • Filtering. Typing in command-input sets hidden on the options that do not match and makes the first match the active option. The listbox shows its empty state, and hides a group whose commands are all filtered out, by itself.
  • The active option has aria-selected="true" and is named by aria-activedescendant on the field. The arrow keys move it, and the palette has one from the start, so Enter has something to run.
  • Running a command. Enter clicks the active option, so an option that is a link navigates and a click listener on an option runs, as for a pointer. A click on an option, from Enter or the pointer, closes the palette. The behavior dispatches no event of its own: the click is the event.
OptionWhat it sets
shortcutThe key that opens and closes the palette with Command or Control. 'k' by default, false for none

Without the behavior the button still opens the dialog, the field takes text and Esc closes it. Nothing filters, and the arrow keys do not move through the list.

#Keyboard

KeysAction
Meta+K, Control+KWith the behavior: open the palette from anywhere, or close it when it is open
TypingWith the behavior: hide the options that do not match and make the first match the active one
ArrowDown, ArrowUpWith the behavior: move the active option, skipping disabled ones and wrapping at the ends
Home, EndWith the behavior: move to the first or the last option
EnterWith the behavior: run the active command and close the palette
EscapeClose the palette and return focus to where it was, as the native dialog does

#Accessibility

  • Name the dialog with aria-label. Put autofocus on the input so typing starts at once.
  • The input is a combobox that controls the listbox, as in a combobox; here the list is always expanded.
  • Every command in the palette must be reachable some other way. A palette is a shortcut, not the only door.

#React, Svelte and Vue

The command component renders the palette from a list of commands, attaches the behavior and tells the application when the palette opens and closes. Copy it into the project with npx master-ui add command; React, Svelte and Vue says how the components are built and used.

ui/command.tsx
'use client'import { useCallback, useEffect, useId, useReducer, useRef, type ComponentProps, type ReactNode } from 'react'import { command } from '@master/ui/behaviors/command.js'import { useBehavior } from './use-behavior'export interface CommandItem {  label: string  /** A shortcut or a hint at the end of the row. */  hint?: ReactNode  /** The caption of the group the command belongs to. Commands of one group are written together. */  group?: string  /** Where the command goes. Without it, `onSelect` runs. */  href?: string  onSelect?: () => void  disabled?: boolean}export interface CommandProps extends Omit<ComponentProps<'dialog'>, 'open' | 'onSelect'> {  items: CommandItem[]  open?: boolean  onOpenChange?: (open: boolean) => void  /** The key that opens and closes the palette with Command or Control. `'k'` by default, `false` for none. */  shortcut?: string | false  /** False when `items` are already the matches; `onSearch` then says what was typed. */  filter?: boolean  onSearch?: (text: string) => void  /** The name of the search field, and the placeholder it shows. */  searchLabel: string  placeholder?: string  /** The name of the list of commands. */  listLabel: string  empty?: ReactNode}/** A command palette: a native dialog over a search field and a list. Name the dialog with `aria-label`. */export function Command({ items, open, onOpenChange, shortcut = 'k', filter = true, onSearch, searchLabel, placeholder, listLabel, empty, className, ...dialog }: CommandProps) {  const id = useId()  const element = useRef<HTMLDialogElement | null>(null)  const attach = useBehavior(command, { shortcut, filter })  const ref = useCallback((root: HTMLDialogElement | null) => {    element.current = root    return attach(root) ?? undefined  }, [attach])  const [tick, again] = useReducer((count: number) => count + 1, 0)  const heard = useRef(onOpenChange)  /** What the owner knows: the last state it set or was told. */  const known = useRef(false)  useEffect(() => { heard.current = onOpenChange })  useEffect(() => {    const root = element.current    if (!root || open === undefined) return    known.current = open    if (open && !root.open) root.showModal()    else if (!open && root.open) root.close()  }, [open, tick])  useEffect(() => {    const root = element.current    if (!root) return    const controller = new AbortController()    // The shortcut, Esc and a command that ran all open or close the dialog without asking: say so.    const report = () => {      if (root.open !== known.current) heard.current?.(known.current = root.open)      again()    }    for (const type of ['toggle', 'close']) root.addEventListener(type, report, { signal: controller.signal })    return () => controller.abort()  }, [])  const groups = [...new Set(items.map(item => item.group))]  const option = (item: CommandItem, at: number) => item.href    ? <a className="listbox-option" role="option" key={at} href={item.href} aria-disabled={item.disabled || undefined}>{item.label}{item.hint !== undefined && <span className="listbox-end">{item.hint}</span>}</a>    : <div className="listbox-option" role="option" key={at} onClick={item.onSelect} aria-disabled={item.disabled || undefined}>{item.label}{item.hint !== undefined && <span className="listbox-end">{item.hint}</span>}</div>  return (    <dialog      className={className ? `dialog command ${className}` : 'dialog command'}      ref={ref}      {...{ closedby: 'any' }}      {...dialog}    >      <label className="command-search">        <input className="command-input" type="search" role="combobox" aria-expanded="true" aria-controls={`${id}list`} aria-autocomplete="list" aria-label={searchLabel} autoComplete="off" placeholder={placeholder} autoFocus onInput={event => onSearch?.(event.currentTarget.value)} />      </label>      <div className="listbox command-list" id={`${id}list`} role="listbox" aria-label={listLabel}>        {groups.map((group, index) => group === undefined          ? items.map((item, at) => item.group === undefined && option(item, at))          : (              <div className="listbox-group" role="group" aria-labelledby={`${id}group${index}`} key={group}>                <div className="listbox-label" id={`${id}group${index}`}>{group}</div>                {items.map((item, at) => item.group === group && option(item, at))}              </div>            ))}        {empty !== undefined && <div className="listbox-empty">{empty}</div>}      </div>    </dialog>  )}
ui/Command.svelte
<script lang="ts" module>  import type { HTMLDialogAttributes } from 'svelte/elements'  export interface CommandItem {    label: string    /** A shortcut or a hint at the end of the row. */    hint?: string    /** The caption of the group the command belongs to. Commands of one group are written together. */    group?: string    /** Where the command goes. Without it, `onSelect` runs. */    href?: string    onSelect?: () => void    disabled?: boolean  }  export interface CommandProps extends Omit<HTMLDialogAttributes, 'open' | 'children'> {    items: CommandItem[]    /** Whether the palette is open. Bind it, or leave it out and the shortcut opens it. */    open?: boolean    onOpenChange?: (open: boolean) => void    /** The key that opens and closes the palette with Command or Control. `'k'` by default, `false` for none. */    shortcut?: string | false    /** False when `items` are already the matches; `onSearch` then says what was typed. */    filter?: boolean    onSearch?: (text: string) => void    /** The name of the search field, and the placeholder it shows. */    searchLabel: string    placeholder?: string    /** The name of the list of commands. */    listLabel: string    empty?: string  }</script><script lang="ts">  import type { Attachment } from 'svelte/attachments'  import { command } from '@master/ui/behaviors/command.js'  import { behavior } from './behavior.svelte.js'  let { items, open = $bindable(), onOpenChange, shortcut = 'k', filter = true, onSearch, searchLabel, placeholder, listLabel, empty, class: className, ...dialog }: CommandProps = $props()  const id = $props.id()  const attach = behavior(command, () => ({ shortcut, filter }))  const groups = $derived([...new Set(items.map(item => item.group))])  /** What the owner knows: the last state it set or was told. */  let known = false  const presence: Attachment<HTMLDialogElement> = (root) => {    const controller = new AbortController()    // The shortcut, Esc and a command that ran all open or close the dialog without asking: say so.    const report = () => {      if (root.open === known) return      open = known = root.open      onOpenChange?.(known)    }    for (const type of ['toggle', 'close']) root.addEventListener(type, report, { signal: controller.signal })    $effect(() => {      if (open === undefined) return      known = open      if (open && !root.open) root.showModal()      else if (!open && root.open) root.close()    })    return () => controller.abort()  }</script>{#snippet option(item: CommandItem)}  <!-- The behavior writes `aria-selected` on the option the keys are on. -->  <!-- svelte-ignore a11y_role_has_required_aria_props -->  {#if item.href}    <a class="listbox-option" role="option" href={item.href} aria-disabled={item.disabled || undefined}>{item.label}{#if item.hint !== undefined}<span class="listbox-end">{item.hint}</span>{/if}</a>  {:else}    <!-- Focus stays in the field, and Enter there clicks the option. -->    <!-- svelte-ignore a11y_click_events_have_key_events, a11y_interactive_supports_focus -->    <div class="listbox-option" role="option" onclick={item.onSelect} aria-disabled={item.disabled || undefined}>{item.label}{#if item.hint !== undefined}<span class="listbox-end">{item.hint}</span>{/if}</div>  {/if}{/snippet}<!--  @component  A command palette: a native dialog over a search field and a list. Name the dialog with `aria-label`.--><dialog class={['dialog', 'command', className]} closedby="any" {@attach attach} {@attach presence} {...dialog}>  <label class="command-search">    <!-- The dialog gives the field focus as it opens. -->    <!-- svelte-ignore a11y_autofocus -->    <input class="command-input" type="search" role="combobox" aria-expanded="true" aria-controls="{id}list" aria-autocomplete="list" aria-label={searchLabel} autocomplete="off" {placeholder} autofocus oninput={event => onSearch?.(event.currentTarget.value)} />  </label>  <div class="listbox command-list" id="{id}list" role="listbox" aria-label={listLabel}>    {#each groups as group, index (group)}      {#if group === undefined}        {#each items.filter(item => item.group === undefined) as item}{@render option(item)}{/each}      {:else}        <div class="listbox-group" role="group" aria-labelledby="{id}group{index}">          <div class="listbox-label" id="{id}group{index}">{group}</div>          {#each items.filter(item => item.group === group) as item}{@render option(item)}{/each}        </div>      {/if}    {/each}    {#if empty !== undefined}<div class="listbox-empty">{empty}</div>{/if}  </div></dialog>
ui/Command.vue
<script lang="ts">export interface CommandItem {  label: string  /** A shortcut or a hint at the end of the row. */  hint?: string  /** The caption of the group the command belongs to. Commands of one group are written together. */  group?: string  /** Where the command goes. Without it, `onSelect` runs. */  href?: string  onSelect?: () => void  disabled?: boolean}</script><script setup lang="ts">import { computed, h, nextTick, onMounted, useId, useTemplateRef, watch, type FunctionalComponent } from 'vue'import { command } from '@master/ui/behaviors/command.js'import { useBehavior } from './use-behavior'defineOptions({ inheritAttrs: false })const { items, shortcut = 'k', filter = true } = defineProps<{  items: CommandItem[]  /** The key that opens and closes the palette with Command or Control. `'k'` by default, `false` for none. */  shortcut?: string | false  /** False when `items` are already the matches; `search` then says what was typed. */  filter?: boolean  /** The name of the search field, and the placeholder it shows. */  searchLabel: string  placeholder?: string  /** The name of the list of commands. */  listLabel: string  empty?: string}>()/** Whether the palette is open: `v-model:open`. */const open = defineModel<boolean>('open')const emit = defineEmits<{  /** What was typed in the search field. */  search: [text: string]}>()/* A command palette: a native dialog over a search field and a list. Name the dialog with `aria-label`. */const id = useId()const root = useTemplateRef<HTMLDialogElement>('root')useBehavior(root, command, () => ({ shortcut, filter }))/** What the owner knows: the last state it set or was told. */let known = falseconst sync = () => {  const dialog = root.value  if (!dialog || open.value === undefined) return  known = open.value  if (open.value && !dialog.open) dialog.showModal()  else if (!open.value && dialog.open) dialog.close()}// The shortcut, Esc and a command that ran all open or close the dialog without asking: say so.const report = () => {  const dialog = root.value  if (!dialog) return  if (dialog.open !== known) open.value = known = dialog.open  void nextTick(sync)}onMounted(sync)watch(open, sync, { flush: 'post' })const groups = computed(() => [...new Set(items.map(item => item.group))])const Row: FunctionalComponent<{ item: CommandItem }> = ({ item }) => {  const content = [item.label, item.hint !== undefined ? h('span', { class: 'listbox-end' }, item.hint) : undefined]  return item.href    ? h('a', { 'class': 'listbox-option', 'role': 'option', 'href': item.href, 'aria-disabled': item.disabled || undefined }, content)    : h('div', { 'class': 'listbox-option', 'role': 'option', 'onClick': () => item.onSelect?.(), 'aria-disabled': item.disabled || undefined }, content)}Row.props = ['item']</script><template>  <dialog class="dialog command" closedby="any" ref="root" v-bind="$attrs" @toggle="report" @close="report">    <label class="command-search">      <input class="command-input" type="search" role="combobox" aria-expanded="true" :aria-controls="`${id}-list`" aria-autocomplete="list" :aria-label="searchLabel" autocomplete="off" :placeholder="placeholder" autofocus @input="emit('search', ($event.target as HTMLInputElement).value)">    </label>    <div class="listbox command-list" :id="`${id}-list`" role="listbox" :aria-label="listLabel">      <template v-for="(group, index) in groups" :key="group ?? ''">        <template v-if="group === undefined">          <template v-for="(item, at) in items" :key="at"><Row v-if="item.group === undefined" :item="item" /></template>        </template>        <div v-else class="listbox-group" role="group" :aria-labelledby="`${id}-group-${index}`">          <div class="listbox-label" :id="`${id}-group-${index}`">{{ group }}</div>          <template v-for="(item, at) in items" :key="at"><Row v-if="item.group === group" :item="item" /></template>        </div>      </template>      <div v-if="empty !== undefined" class="listbox-empty">{{ empty }}</div>    </div>  </dialog></template>

#Classes

ClassDescription
commandBeside dialog, on the same dialog element: the palette's interior without padding, 36rem wide, placed near the top of the viewport so the field stays where it is while the list changes length
command-searchThe field row, on a label so the whole row focuses the input. It holds an icon, the command-input and an optional hint
command-inputThe bare search input, with role="combobox" and aria-controls pointing at the list. It has no material of its own: the dialog is its surface
command-listOn the listbox of results: it takes the remaining height and scrolls inside the dialog
command-footerA row of key hints under the list
command-hintOne hint in the footer: keys, then what they do

#Source

@master/ui/components/command.css
/* * Command palette: a dialog that is one search field over a listbox of commands. * It is the dialog component with a different interior: no padding, a large quiet field on a hairline, a * list that takes the remaining height, and a footer of key hints. It opens near the top of the viewport, * so the field stays where it is while the list under it grows and shrinks. */@layer components {  .dialog.command {    --dialog-width: 36rem;    --dialog-radius: 1rem;    gap: 0;    max-height: min(28rem, calc(100dvh - 6rem));    margin-top: min(18vh, 9rem);    padding: 0;  }  /* The field row: an icon, the input and an optional hint, as one label so the whole row focuses the input. */  .command-search {    --icon-size: 1.125rem;    display: flex;    flex: none;    align-items: center;    gap: .75rem;    min-height: 3.25rem;    padding: 0 1rem;    color: var(--color-text-muted);    box-shadow: inset 0 -1px 0 var(--color-line-hair);    cursor: text;    /* A painted hairline is dropped with every other shadow: a real one parts the field from the list. */    @media (forced-colors: active) {      border-bottom: 1px solid CanvasText;    }  }  /* The input has no material of its own: the dialog is its surface. */  .command-input {    flex: 1 1 0;    align-self: stretch;    min-width: 0;    margin: 0;    padding: 0;    border: 0;    outline: none;    background: transparent;    color: var(--color-text-strong);    font: 400 1rem/1.5 var(--font-family-sans);    font-feature-settings: inherit;    caret-color: var(--color-text-strong);    appearance: none;    &::placeholder {      color: var(--color-text-muted);      opacity: 1;    }    /* With no matching option, the field must still identify keyboard focus in system colors. */    @media (forced-colors: active) {      &:focus-visible {        outline: 2px solid Highlight;        outline-offset: -2px;      }    }    &::-webkit-search-cancel-button {      display: none;    }  }  /* The results: a listbox that scrolls inside the dialog, a little further in than a floating one. */  .command-list {    --listbox-inset: 6px;    flex: 1 1 auto;    min-height: 0;  }  /* Key hints under the list. */  .command-footer {    display: flex;    flex: none;    flex-wrap: wrap;    align-items: center;    gap: .375rem 1rem;    padding: .5rem .875rem;    color: var(--color-text-muted);    box-shadow: inset 0 1px 0 var(--color-line-hair);    font-size: .75rem;    @media (forced-colors: active) {      border-top: 1px solid CanvasText;    }  }  /* One hint: keys, then what they do. */  .command-hint {    display: inline-flex;    align-items: center;    gap: .375rem;  }}