Command palette

Summary

The Ctrl/+K command palette: a modal search field over a registry of commands, the GitHub/Linear pattern. Soma.palette() renders its own DOM (there is nothing to author) as a native <dialog> shown in the top layer, exactly like dialogs, and follows the APG combobox pattern: focus stays in the input and the highlighted option is conveyed via aria-activedescendant. Choosing a command hides the palette, fires the bubbling soma-palette-run event and calls the command's run().

When to use

SituationGuidance
Power-user access to actions scattered across the appThe home turf: one searchable entry point for navigation and commands, without hunting through menus.
Searching content (pages, records, people)Use quicksearch, an inline, non-modal field where you render the results.
A handful of fixed key bindingsUse Soma.shortcuts directly; the palette is worth it once commands need discovery. The two compose — a command's kbd hint can advertise its dedicated shortcut.
Choosing a value for a form fieldUse select. The palette runs actions, it doesn't hold a value.

Examples

Basic

Eight commands across three groups (one unsectioned, listed first), with end-aligned kbd hints. Press Ctrl/+K anywhere on this page, or:

Last run: · nothing yet.

Custom placeholder and empty text

A second palette with its own placeholder and emptyText, opted out of the global shortcut with shortcut: false (the page's main palette owns Ctrl/+K). Type something that misses to see the empty state:

Dynamic registration

register() adds or replaces commands (matched by id) and unregister() removes them, at any time, even while the palette is open:

The main palette has its original 8 commands.

Filtering and sections

Filtering is a case-insensitive substring match over each command's title and keywords (no fuzzy ranking, so results are predictable). Matches keep registration order and regroup under their section headings; a section with no matches drops its heading, and unsectioned commands list first. When nothing matches, the palette shows the localised palette.empty string (or your emptyText). Every open starts from an empty query and the full list, with the first command pre-highlighted so Enter always has a target.

Keyboard

KeyAction
Ctrl/+KOpens the palette, from anywhere except a typing context (see The global shortcut below).
TypingRe-filters on every keystroke; the first match is pre-highlighted.
/ Move the highlight, wrapping at either end. The pointer re-anchors the same single highlight on hover.
EnterRuns the highlighted command and hides the palette.
EscHides without running (the platform's close request on the native dialog) — as does a click on the backdrop.
TabContained natively: the page behind the modal is inert, and the input is the palette's only tab stop, so focus never escapes.

Top layer, focus and ARIA

The palette is a native <dialog> opened with showModal(), like dialog2: the platform puts it in the top layer with its ::backdrop, makes the page behind it inert (focus containment without a JS trap), routes Esc to it, and restores focus to the previously focused element on close, so a command that acts on the page finds focus back where the user left it. The dialog role and modality are native (no aria-modal bookkeeping); the element is labelled from the palette.label catalog string. Inside, the input is a role="combobox" over the always-visible role="listbox"; focus stays in the input the whole time and the highlighted option is exposed via aria-activedescendant + aria-selected (the APG pattern). Sections are role="group" wrappers labelled by a presentational heading. While the palette is open, other Soma.shortcuts bindings are suppressed, exactly as with an open modal dialog.

The global shortcut

Each instance binds ctrl+k and meta+k through the shared Soma.shortcuts registry, so registry semantics apply: the combo never fires while an input, textarea, select or contenteditable has focus, nor while a modal is open. Create one palette per page — a second instance would race it for the combo; pass shortcut: false to auxiliary palettes (or to take over the binding yourself) and open them with .show():

const aux = Soma.palette({ shortcut: false, commands: […] });
document.querySelector('#open-aux').addEventListener('click', () => aux.show());

HTML

Nothing to author — the component builds and appends its DOM to <body> on first show. The generated anatomy, for styling and testing reference:

<!-- a native <dialog>: role/modality are implicit, the open attribute
     (managed by the platform via showModal/close) is the state -->
<dialog class="soma-palette" aria-label="Command palette" open>
  <div class="soma-palette-field">
    <span class="soma-icon soma-icon-search"></span>
    <input class="soma-palette-input" role="combobox" aria-expanded="true"
           aria-controls="soma-palette-list-1" aria-autocomplete="list"
           placeholder="Type a command…" />
  </div>
  <div class="soma-palette-list" id="soma-palette-list-1" role="listbox">
    <div class="soma-palette-item soma-active" role="option" aria-selected="true">
      <span class="soma-palette-item-title">Open keyboard help</span>
    </div>
    <div class="soma-palette-group" role="group" aria-labelledby="soma-palette-section-1">
      <div class="soma-palette-section" role="presentation"
           id="soma-palette-section-1">Navigate</div>
      <div class="soma-palette-item" role="option" aria-selected="false">
        <span class="soma-palette-item-title">Go to dashboard</span>
        <kbd class="soma-palette-kbd">G D</kbd>
      </div>
    </div>
    <!-- or, when nothing matches: -->
    <div class="soma-palette-empty">No matching commands</div>
  </div>
</dialog>

CSS classes

ClassEffect
.soma-paletteThe modal card, a native <dialog> in the top layer with its ::backdrop. Solid surface, top-anchored at 15vh, 560px wide, fade-and-rise enter transition (off under reduced motion); hidden by the platform while closed.
.soma-palette-fieldThe inset search row wrapping icon + input.
.soma-palette-inputThe combobox input.
.soma-palette-listThe listbox. Scrolls when the results outgrow the card.
.soma-palette-group / .soma-palette-sectionA section wrapper and its small-caps heading.
.soma-palette-itemA command row (row height follows the density tokens); .soma-active marks the single keyboard/pointer highlight.
.soma-palette-item-titleThe row text; truncates with an ellipsis.
.soma-palette-kbdThe end-aligned mono shortcut hint.
.soma-palette-emptyThe "no matching commands" row.

JavaScript

Constructor and options

MemberDescription
Soma.palette(options?)Build a palette. A factory, not a singleton — every call is a new, independent instance (there is no element to key one on); create one per page. No auto-init: palettes are created, not declared.
commandsArray of command objects (shape below). Default []. Commands can arrive later via register().
placeholderInput placeholder. Default: the localised palette.placeholder ("Type a command…").
emptyTextThe no-matches row text. Default: the localised palette.empty ("No matching commands").
shortcutfalse skips binding the global Ctrl/+K. Default true.

Strings resolve through Soma.i18n at construction (palette.label, palette.placeholder) and at render time (palette.empty). Set the locale before creating the palette.

The command shape

FieldDescription
idRequired, unique. register() replaces an existing command with the same id; soma-palette-run carries it in detail.
titleRequired. The rendered row text, matched by the filter. Inserted as text, never HTML.
sectionOptional group heading. Commands without one list first, ungrouped.
keywordsOptional extra match terms (a plain string, e.g. synonyms).
kbdOptional shortcut hint rendered end-aligned in mono. Display only; bind the actual keys with Soma.shortcuts.
run(cmd)Optional — invoked after the palette hides, with the command object. Omit it to handle everything through the run event instead.

Instance methods

MemberDescription
.show() / .hide()Open / close. Every show resets the query to the full list and focuses the input; hide restores focus. No-ops when already in that state.
.register(cmd | cmds)Add or replace (by id) one command or an array; an open palette re-filters immediately. Throws without an id and title.
.unregister(id)Remove a command; unknown ids are ignored.
.on(event, fn) / .off(event, fn)Subscribe/unsubscribe: 'run', 'show', 'hide' (without the soma-palette- prefix).
.isOpen / .elOpen state; the generated dialog element (attached to <body> on first show).
.destroy()Hide, unbind the global shortcut and the internal listeners, remove the element. The instance is done — build a new one if needed.

All methods except destroy() return the instance, so calls chain (Soma.palette().register(cmds).show()).

Events

EventFiresdetail
soma-palette-runWhen a command is chosen (Enter or click), after the palette closes, before the command's run().{ id }
soma-palette-showWhen the palette opens.
soma-palette-hideWhen it closes, with or without a run. Follows the platform's close event (browsers fire it from a queued task, so on a run it can arrive after soma-palette-run).

All three are bubbling CustomEvents dispatched on the palette element, so a document-level listener works for delegated wiring, e.g. command analytics without touching any run:

document.addEventListener('soma-palette-run', (e) => {
  track('command', e.detail.id);
});

Plugin-style composition: a portal plugin contributes its commands to the page's palette at load time and withdraws them on unload:

// The host page owns the instance…
window.portalPalette = Soma.palette();

// …and each plugin registers what it brings.
portalPalette.register([
  { id: 'audit.export', title: 'Export audit log', section: 'Audit',
    run: () => exportAudit() },
  { id: 'audit.open', title: 'Open audit log', section: 'Audit', kbd: 'G A',
    run: () => { location.href = '/audit'; } },
]);
Soma.shortcuts('ga').followLink('#nav-audit'); // the kbd hint's real binding

// on plugin unload:
portalPalette.unregister('audit.export');
portalPalette.unregister('audit.open');

The live examples above, exactly as this page wires them:

const output = document.getElementById('pal-output');
const say = (line) => { output.textContent = line; };

const pal = Soma.palette({
  commands: [
    { id: 'help', title: 'Open keyboard help',
      run: () => say('Keyboard help opened.') },
    { id: 'go-dashboard', title: 'Go to dashboard', section: 'Navigate', kbd: 'G D',
      run: () => say('Navigated to the dashboard.') },
    { id: 'go-deployments', title: 'Go to deployments', section: 'Navigate', kbd: 'G P',
      run: () => say('Navigated to deployments.') },
    { id: 'go-settings', title: 'Go to settings', section: 'Navigate',
      run: () => say('Navigated to settings.') },
    { id: 'restart', title: 'Restart service', section: 'Actions', keywords: 'reboot bounce',
      run: () => say('Service restart requested.') },
    { id: 'rotate', title: 'Rotate credentials', section: 'Actions', keywords: 'secrets keys',
      run: () => say('Credential rotation started.') },
    { id: 'backup', title: 'Run backup', section: 'Actions', kbd: '⌘B',
      run: () => say('Backup running…') },
    { id: 'toast', title: 'Show a toast', section: 'Actions',
      run: () => Soma.toast({ title: 'Hello from the palette', appearance: 'success' }) },
  ],
});
document.getElementById('pal-open').addEventListener('click', () => pal.show());
pal.on('run', (e) => {
  document.getElementById('pal-last').textContent = e.detail.id;
});

// Custom placeholder + empty text; shortcut: false — the main palette
// owns Ctrl/⌘K on this page.
const pal2 = Soma.palette({
  shortcut: false,
  placeholder: 'Search actions…',
  emptyText: 'No actions match — try "log"',
  commands: [
    { id: 'log-tail', title: 'Tail the log', run: () => say('Tailing the log…') },
    { id: 'log-clear', title: 'Clear the log', run: () => say('Log cleared.') },
    { id: 'log-download', title: 'Download the log', kbd: '⌘L',
      run: () => say('Log downloading…') },
  ],
});
document.getElementById('pal2-open').addEventListener('click', () => pal2.show());

// Dynamic registration against the main palette.
const toggle = document.getElementById('pal-extra');
const status = document.getElementById('pal-extra-status');
let registered = false;
toggle.addEventListener('click', () => {
  registered = !registered;
  if (registered) {
    pal.register({ id: 'purge', title: 'Purge cache', section: 'Actions',
      keywords: 'clear invalidate', run: () => say('Cache purged.') });
  } else {
    pal.unregister('purge');
  }
  toggle.textContent = registered ? 'Unregister “Purge cache”' : 'Register “Purge cache”';
  status.textContent = registered
    ? 'Registered — open the palette and type "purge".'
    : 'Unregistered — the palette is back to its original 8 commands.';
});