Skip to content

Command palette

A command palette is a modal launcher (⌘K) — a search input over a filterable, groupable list of commands and links. The modal shell is a native <dialog> driven by the existing Dialog behavior (open/close/focus-trap/backdrop/Escape are all native); enhanceCommandPalette adds the combobox/listbox ARIA, type-to-filter, arrow navigation, and an optional global hotkey.

import { enhanceCommandPalette } from "@relements/core/behaviors/command-palette";
enhanceCommandPalette(document);

Open it with a data-re-dialog-trigger button or the data-re-command-hotkey shortcut (e.g. "mod+k"mod is ⌘ on macOS, Ctrl elsewhere). The hotkey claims the combo: it stops the keystroke from bubbling to page-level handlers, so a site-wide ⌘K search won’t also fire. (On this docs page, that’s why ⌘K opens the demo rather than the site search — use / for search here.)

navigate select esc close
Live example
<button
  type="button"
  class="re-button"
  data-variant="secondary"
  data-re-dialog-trigger
  data-re-dialog-target="cmdk"
>
  Search… <kbd class="re-kbd">⌘K</kbd>
</button>

<dialog
  id="cmdk"
  class="re-dialog re-command-palette"
  aria-label="Command palette"
  data-re-dialog-close-on-backdrop
  data-re-command-palette
  data-re-command-hotkey="mod+k"
>
  <form class="re-command-palette__search" role="search" method="dialog">
    <svg
      class="re-command-palette__search-icon"
      viewBox="0 0 24 24"
      fill="none"
      aria-hidden="true"
      focusable="false"
    >
      <circle cx="11" cy="11" r="7" stroke="currentColor" stroke-width="2" />
      <path d="m20 20-3-3" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
    </svg>
    <input
      type="search"
      class="re-command-palette__input"
      autocomplete="off"
      autofocus
      placeholder="Type a command or search…"
      aria-label="Search commands"
    />
    <button type="submit" class="re-sr-only" data-re-dialog-close>Close</button>
  </form>

  <ul class="re-command-palette__list">
    <li class="re-command-palette__group">
      <span class="re-command-palette__group-label">Navigation</span>
      <ul>
        <li class="re-command-palette__item">
          <a href="#dashboard" class="re-command-palette__action">
            <span class="re-command-palette__item-label">Go to Dashboard</span>
            <kbd class="re-kbd re-command-palette__item-hint">G D</kbd>
          </a>
        </li>
        <li class="re-command-palette__item">
          <a href="#settings" class="re-command-palette__action">
            <span class="re-command-palette__item-label">Go to Settings</span>
            <kbd class="re-kbd re-command-palette__item-hint">G S</kbd>
          </a>
        </li>
      </ul>
    </li>
    <li class="re-command-palette__group">
      <span class="re-command-palette__group-label">Actions</span>
      <ul>
        <li class="re-command-palette__item">
          <span class="re-command-palette__action" data-command="new-doc">
            <span class="re-command-palette__item-label">Create document</span>
          </span>
        </li>
        <li class="re-command-palette__item">
          <span class="re-command-palette__action" data-command="invite">
            <span class="re-command-palette__item-label">Invite teammate</span>
          </span>
        </li>
        <li class="re-command-palette__item" aria-disabled="true">
          <span class="re-command-palette__action">
            <span class="re-command-palette__item-label">Delete workspace</span>
          </span>
        </li>
      </ul>
    </li>
  </ul>

  <div class="re-command-palette__empty" hidden>
    <div class="re-empty-state" data-size="sm" role="status">
      <p class="re-empty-state__title">No results</p>
      <p class="re-empty-state__description">Try a different search.</p>
    </div>
  </div>

  <footer class="re-command-palette__footer" aria-hidden="true">
    <span><kbd class="re-kbd">↑</kbd><kbd class="re-kbd">↓</kbd> navigate</span>
    <span><kbd class="re-kbd">↵</kbd> select</span>
    <span><kbd class="re-kbd">esc</kbd> close</span>
  </footer>
</dialog>
  • No-JS baseline: the static markup is a plain <input type="search"> over a list of real links/<span> rows — no role=combobox/listbox/option is shipped until JS can back it. enhanceCommandPalette adds the widget ARIA on enhance.
  • Link rows are real <a href> (navigable without JS); on enhance the href is lifted to data and the row becomes a non-interactive option (so the listbox has no nested interactive controls). Command rows are <span data-command>.
  • Activation dispatches re-command (bubbles, cancelable; detail = { command, href, option }); if not prevented and the row is a link, it navigates.
  • Focus stays in the input (aria-activedescendant). On close the query resets; Escape and the backdrop are handled by the dialog. Always keep palettes dismissible — never add data-re-dialog-no-dismiss.
  • Keyboard — type to filter (substring match on each row’s label). ↓ / ↑ move the active option, wrapping at the ends and skipping hidden or aria-disabled rows. Enter activates the active option (dispatches re-command, then navigates if it’s a link). Escape and the backdrop close the dialog (native). The optional data-re-command-hotkey (e.g. "mod+k") opens it from anywhere — see above. Focus never leaves the input, so there is no Tab-through-rows; rows are non-interactive options, activated by click or Enter.
  • Focus — the dialog opens with native showModal(), which traps focus and makes the background inert; closing returns focus to the trigger. The search input keeps focus the whole time and opts out of the :focus-visible ring on purpose (the open modal and caret already mark it, and a ring would hug the borderless field). The active row is indicated by a bg-muted highlight via aria-activedescendant rather than real focus.
  • Semantics — on enhance the input becomes a role="combobox" (aria-autocomplete="list", aria-controls the list, aria-expanded reflecting whether any rows match, and aria-activedescendant pointing at the highlighted row); the list becomes role="listbox", each group a labelled role="group", and each row a role="option" toggling aria-selected. The empty-state carries role="status" and rewrites its text on the transition to zero results, so the change is announced.
  • Notes — none of that ARIA ships in the static markup; the no-JS baseline is a plain search field over real links/<span> rows (see Notes above). The keyboard-hint footer is aria-hidden, the search icon is decorative (aria-hidden), and the native close control is .re-sr-only. In forced-colors mode the active option is painted with the system Highlight color so keyboard navigation stays visible once the muted background flattens.

See the dialog component for the underlying modal behavior and the accessibility guide for project-wide conventions.