# Command Palette (Moonarc)

A ⌘K command palette: a native modal dialog with an input and a ranked list of links. ↑ ↓ move, ↵ follows the link through the ClientRouter, Escape closes and focus returns to where it was; results are re-rendered and each arrives through @starting-style on the stagger token. Items come from the server, from a JSON URL fetched once on the first open, or both. Without JavaScript the invoker still opens the dialog and the server-rendered items are plain links.

- Import: `import CommandPalette from '@moonarc/core/CommandPalette'`
- Install: `npx astro add moonarc` · copy-paste: `npx shadcn@latest add https://moonarc.dev/r/command-palette.json`
- Tier C · category ui · trigger click
- Readout: `<CommandPalette hotkey="k">`
- Browser support: newly (Chrome 135 · Firefox 144 · Safari 26.2); elsewhere: without invoker commands the trigger is the script's (or polyfill's); without @starting-style results appear at once; without closedby (Safari) the script closes it on a tap on the backdrop
- Measured cost: 3.0 kB raw JS · 1.4 kB gzip · + runtime (with dependencies 4.7 kB raw; CSS 9.6 kB raw)
- Page: https://moonarc.dev/components/command-palette/

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `id` | `string` | none | Dialog id; every button with commandfor={id} opens it. |
| `items` | `{ label: string; href: string; group?: string; hint?: string; keywords?: string[] }[]` | `[]` | Server-rendered items: the first index, and the list without JavaScript. |
| `source` | `string` | none | URL of a JSON array in the same shape, fetched on the first open and merged (a fetch that failed is tried again on the next open); an entry without a label or an href is left out. |
| `hotkey` | `string` | `'k'` | ⌘ / Ctrl + this key toggles the palette. The trigger shows ⌘ on Apple platforms and Ctrl elsewhere. |
| `placeholder` | `string` | `'Search…'` | Input placeholder. |
| `label` | `string` | `'Search'` | Trigger label and the accessible name of the input and the list. |
| `max` | `number` | `12` | Results shown at most. |
| `empty` | `string` | `'Nothing found'` | Shown when a query matches nothing. |
| `countLabel` | `string` | `'{n} found'` | The result count under the list, a polite live region; {n} is the number. |
| `polyfill` | `boolean` | `false` | Inline the invoker click handler (403 B raw) for browsers without command / commandfor. |

## Usage

```astro
<CommandPalette
  id="palette"
  items={[
    { label: 'Installation', href: '/docs/installation/', group: 'Docs', hint: '5 min' },
    { label: 'Scroll Reveal', href: '/components/reveal/', group: 'Components', hint: 'scroll', keywords: ['aos', 'fade in'] },
  ]}
  source="/palette.json"
/>

<!-- any button on the page can open it -->
<button type="button" command="show-modal" commandfor="palette">Search</button>
```

## Reduced motion

Results appear without the stagger; the dialog itself cross-fades in 150 ms.

## With ClientRouter

Bound through the shared runtime; the document hotkey listener is aborted before the swap. ↵ clicks the link, so the router handles the navigation and the dialog closes with the page. A palette kept with transition:persist keeps its index (and the fetched source) across the rebind.

## Craft

- A native modal dialog does the focus trap and the focus return; the script owns the search, the keyboard on the list and the fetch, and closes on the first Escape (a search field would spend it clearing the query) and on a tap on the backdrop, which Safari's dialog does not do without closedby.
- The ranking is four levels and stated on the page: a prefix of the label, a prefix of a word, a substring, a keyword. It is applied per token, and every token is required. The order is predictable, which suits a list of a hundred links.
- Results are new elements every time, so @starting-style gives each one an entrance delayed by its index on the tight stagger, capped at nine so a long list never waits. There is no FLIP and no measured position. ↑ ↓ only move aria-selected over the rows already there, so the selection moves and nothing enters again.
- aria-activedescendant on the combobox input, options with ids, aria-selected on one: the screen reader follows the arrow keys without the focus leaving the input.
- The trigger is a real invoker: without the script the dialog still opens and the server-rendered items are links. The script cancels the invoker's default only to focus the input and refresh the list.

## Replaces

- Command (shadcn / cmdk)
- CommandPalette (Motion Primitives)
- kbar
- Search modal (Tailwind Plus)

## Source

```astro
---
/**
 * CommandPalette — ⌘K. A native modal <dialog> (the library's Dialog), an
 * input, and a ranked list; ↑ ↓ move, ↵ follows the link (through the
 * ClientRouter, if there is one), Escape closes, focus returns to what had
 * it. Items come from the server (`items`), from a JSON URL fetched once on
 * the first open (`source`), or both. Ranking is simple and stated: a
 * prefix of the label beats a prefix of a word beats a substring beats a
 * keyword, per query token. Results are re-rendered as new elements, so
 * each arrives through @starting-style on the stagger token — no FLIP.
 * Without JavaScript the trigger still opens the dialog (invoker) and the
 * server-rendered items are plain links.
 */
import type { HTMLAttributes } from 'astro/types';
import Dialog from './Dialog.astro';

export interface PaletteItem {
  label: string;
  href: string;
  /** Group heading. */
  group?: string;
  /** Right-hand hint (a category, a shortcut, a cost). */
  hint?: string;
  /** Extra words the search matches. */
  keywords?: string[];
}

interface Props extends HTMLAttributes<'div'> {
  /** Dialog id; unique per page. */
  id: string;
  items?: PaletteItem[];
  /** URL of a JSON array of items, fetched once on the first open. */
  source?: string;
  /** Key for ⌘ / Ctrl + key. */
  hotkey?: string;
  placeholder?: string;
  /** Trigger label. */
  label?: string;
  /** Results shown at most. */
  max?: number;
  /** Message when nothing matches. */
  empty?: string;
  /** The live count under the list; {n} is the number of results. */
  countLabel?: string;
  /** Add the inline click handler for browsers without invoker commands. */
  polyfill?: boolean;
}

const { id, items = [], source, hotkey = 'k', placeholder = 'Search…', label = 'Search', max = 12, empty = 'Nothing found', countLabel = '{n} found', polyfill = false, class: className, ...rest } = Astro.props;
const listId = `${id}-list`;
const groups = new Map<string, PaletteItem[]>();
for (const it of items) (groups.get(it.group ?? '') ?? groups.set(it.group ?? '', []).get(it.group ?? '')!).push(it);
let n = 0;
---

<Dialog id={id} close={false} polyfill={polyfill} class="ma-cmdk__dialog" aria-label={label}>
  <button slot="trigger" type="button" class="ma-cmdk__trigger" command="show-modal" commandfor={id} aria-label={label}>
    <svg aria-hidden="true" viewBox="0 0 20 20" width="14" height="14"><circle cx="9" cy="9" r="6" fill="none" stroke="currentColor" stroke-width="1.5" /><path d="M14 14l4 4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" /></svg>
    <span class="ma-cmdk__trigger-label">{label}</span>
    <kbd class="ma-cmdk__kbd">⌘{hotkey.toUpperCase()}</kbd>
  </button>
  <div class:list={['ma-cmdk', className]} data-ma-cmdk data-source={source} data-hotkey={hotkey} data-max={max} data-count={countLabel} {...rest}>
    <div class="ma-cmdk__field">
      <svg aria-hidden="true" viewBox="0 0 20 20" width="16" height="16"><circle cx="9" cy="9" r="6" fill="none" stroke="currentColor" stroke-width="1.5" /><path d="M14 14l4 4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" /></svg>
      <input type="search" class="ma-cmdk__input" placeholder={placeholder} aria-label={label} role="combobox" aria-expanded="true" aria-controls={listId} aria-autocomplete="list" autocomplete="off" spellcheck="false" />
    </div>
    <ul class="ma-cmdk__list" id={listId} role="listbox" aria-label={label}>
      {
        [...groups].map(([g, list]) => (
          <>
            {g && <li class="ma-cmdk__group" role="presentation">{g}</li>}
            {list.map((it) => (
              <li class="ma-cmdk__item" role="none" style={`--ma-cmdk-i:${Math.min(n, 8)}`} data-l={it.label} data-g={g || undefined} data-d={it.hint} data-k={(it.keywords ?? []).join(' ').toLowerCase() || undefined}>
                <a href={it.href} tabindex="-1" role="option" id={`${listId}-${n++}`} aria-selected="false">
                  <span class="ma-cmdk__label">{it.label}</span>
                  {it.hint && <small class="ma-cmdk__hint">{it.hint}</small>}
                </a>
              </li>
            ))}
          </>
        ))
      }
    </ul>
    <p class="ma-cmdk__empty" hidden>{empty}</p>
    <p class="ma-cmdk__foot"><span><kbd class="ma-cmdk__kbd">↑↓</kbd> move</span><span><kbd class="ma-cmdk__kbd">↵</kbd> open</span><span><kbd class="ma-cmdk__kbd">esc</kbd> close</span><span class="ma-cmdk__count" aria-live="polite"></span></p>
  </div>
</Dialog>

<style is:global>
  @layer components {
    :where(.ma-cmdk__dialog) {
      max-width: min(36rem, calc(100vw - 2rem));
      margin-top: 10vh;
      margin-bottom: auto;
    }
    :where(.ma-cmdk__dialog .ma-dialog__panel) {
      padding: 0;
    }
    :where(.ma-cmdk__trigger) {
      display: inline-flex;
      align-items: center;
      gap: 0.5rem;
      height: 2.25rem;
      padding: 0 0.625rem 0 0.75rem;
      border: 1px solid var(--ma-edge);
      border-radius: 0.5rem;
      background: var(--ma-panel);
      color: inherit;
      font: inherit;
      font-size: 0.8125rem;
      opacity: 0.8;
      cursor: pointer;
      transition: opacity var(--ma-duration-fast) var(--ma-ease-out), border-color var(--ma-duration-fast) var(--ma-ease-out);
    }
    :where(.ma-cmdk__trigger:hover) {
      opacity: 1;
      border-color: color-mix(in srgb, var(--ma-ink) 30%, transparent);
    }
    :where(.ma-cmdk__trigger:focus-visible) {
      outline: 2px solid currentColor;
      outline-offset: 2px;
    }
    :where(.ma-cmdk__kbd) {
      display: inline-grid;
      place-items: center;
      min-width: 1.5em;
      padding: 0.1em 0.35em;
      border: 1px solid var(--ma-edge);
      border-bottom-width: 2px;
      border-radius: 0.3em;
      font-family: ui-monospace, monospace;
      font-size: 0.6875rem;
      line-height: 1.4;
      opacity: 0.8;
    }
    :where(.ma-cmdk__field) {
      display: flex;
      align-items: center;
      gap: 0.75rem;
      padding: 0.875rem 1rem;
      border-bottom: 1px solid var(--ma-edge);
      opacity: 0.9;
    }
    /* the input's focus ring is the row it sits in: a 2 px line along the bottom (the input itself draws no outline) */
    :where(.ma-cmdk__field:focus-within) {
      box-shadow: inset 0 -2px 0 currentColor;
    }
    :where(.ma-cmdk__input) {
      flex: 1;
      min-width: 0;
      border: 0;
      background: none;
      color: inherit;
      font: inherit;
      font-size: 1rem;
      outline: none;
    }
    :where(.ma-cmdk__input::-webkit-search-cancel-button) {
      display: none;
    }
    :where(.ma-cmdk__list) {
      max-height: min(50vh, 24rem);
      margin: 0;
      padding: 0.375rem;
      overflow: auto;
      overscroll-behavior: contain;
      list-style: none;
    }
    :where(.ma-cmdk__group) {
      padding: 0.625rem 0.625rem 0.25rem;
      font-family: ui-monospace, monospace;
      font-size: 0.6875rem;
      letter-spacing: 0.08em;
      text-transform: uppercase;
      opacity: var(--ma-dim, 0.7);
    }
    /* each result is a new element: it arrives through @starting-style, staggered by its index (capped at nine). The
       index has the palette's own name: the shared --ma-i inherits, and a row would pass it to anything inside it */
    :where(.ma-cmdk__item) {
      opacity: 1;
      translate: 0 0;
      transition:
        opacity var(--ma-duration) var(--ma-ease-out),
        translate var(--ma-duration) var(--ma-ease);
      transition-delay: calc(var(--ma-cmdk-i, 0) * var(--ma-stagger-tight));
    }
    @starting-style {
      :where(.ma-cmdk__item) {
        opacity: 0;
        translate: 0 var(--ma-travel-hover, 4px);
      }
    }
    :where(.ma-cmdk__item a) {
      display: flex;
      align-items: baseline;
      justify-content: space-between;
      gap: 1rem;
      padding: 0.5rem 0.625rem;
      border-radius: 0.5rem;
      color: inherit;
      text-decoration: none;
      font-size: 0.9375rem;
      transition: background-color var(--ma-duration-fast) var(--ma-ease-out);
    }
    :where(.ma-cmdk__item a[aria-selected='true']),
    :where(.ma-cmdk__item a:hover) {
      background: color-mix(in srgb, var(--ma-ink) 7%, transparent);
    }
    :where(.ma-cmdk__hint) {
      flex: none;
      font-family: ui-monospace, monospace;
      font-size: 0.6875rem;
      opacity: var(--ma-dim, 0.7);
      white-space: nowrap;
    }
    :where(.ma-cmdk__empty) {
      margin: 0;
      padding: 1.5rem 1rem;
      text-align: center;
      font-size: 0.875rem;
      opacity: var(--ma-dim, 0.7);
    }
    :where(.ma-cmdk__foot) {
      display: flex;
      gap: 1rem;
      margin: 0;
      padding: 0.625rem 1rem;
      border-top: 1px solid var(--ma-edge);
      font-family: ui-monospace, monospace;
      font-size: 0.6875rem;
      opacity: var(--ma-dim, 0.7);
    }
    :where(.ma-cmdk__count) {
      margin-left: auto;
    }
    @media (prefers-reduced-motion: reduce) {
      :where(.ma-cmdk__item) {
        transition: none;
      }
    }
  }
</style>

<script>
  import { onMount } from '../lib/runtime';

  type Item = { l: string; h: string; g?: string; d?: string; k: string };

  // the index outlives a binding: a palette kept through a swap (transition:persist) is bound again on the same node,
  // and by then its list holds rendered results, which carry none of the item data
  const indexes = new WeakMap<HTMLElement, { items: Item[]; loaded: boolean }>();

  onMount<HTMLElement>('[data-ma-cmdk]', (root, { signal }) => {
    const dialog = root.closest('dialog') as HTMLDialogElement;
    const input = root.querySelector('input')!;
    const list = root.querySelector('ul')!;
    const empty = root.querySelector<HTMLElement>('.ma-cmdk__empty')!;
    const count = root.querySelector<HTMLElement>('.ma-cmdk__count')!;
    const max = Number(root.dataset.max) || 12;
    const esc = (s: string) => s.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
    const key = root.dataset.hotkey!;
    // the server-rendered options are the first index; `source` extends it once
    const ix = indexes.get(root) ?? { items: [...list.querySelectorAll<HTMLElement>('.ma-cmdk__item')].map((o) => ({ l: o.dataset.l!, h: o.querySelector('a')!.getAttribute('href')!, g: o.dataset.g, d: o.dataset.d, k: o.dataset.k ?? '' })), loaded: !root.dataset.source };
    indexes.set(root, ix);
    let results: Item[] = [];
    let active = 0;

    // ↑ ↓ move aria-selected over the rows already there: rendered again, every row would enter again through @starting-style
    const select = (n: number) => {
      active = n;
      const opts = list.querySelectorAll<HTMLElement>('[role="option"]');
      opts.forEach((o, i) => o.setAttribute('aria-selected', String(i === n)));
      const sel = opts[n];
      input.setAttribute('aria-activedescendant', sel?.id ?? '');
      // keep the selected row inside the list by moving the list's own scroll only: scrollIntoView() scrolls every ancestor,
      // and a palette rendered off screen (a catalogue tile typing on a timer) would drag the whole page to itself
      if (sel) {
        const r = sel.getBoundingClientRect();
        const l = list.getBoundingClientRect();
        if (r.top < l.top) list.scrollTop += r.top - l.top;
        else if (r.bottom > l.bottom) list.scrollTop += r.bottom - l.bottom;
      }
    };
    const render = () => {
      let g = '';
      let i = 0;
      list.innerHTML = results
        .map((r) => {
          const head = r.g && r.g !== g ? ((g = r.g), `<li class="ma-cmdk__group" role="presentation">${esc(g)}</li>`) : '';
          const n = i++;
          return `${head}<li class="ma-cmdk__item" role="none" style="--ma-cmdk-i:${Math.min(n, 8)}"><a href='${esc(r.h)}' tabindex="-1" role="option" id="${list.id}-${n}"><span class="ma-cmdk__label">${esc(r.l)}</span>${r.d ? `<small class="ma-cmdk__hint">${esc(r.d)}</small>` : ''}</a></li>`;
        })
        .join('');
      empty.hidden = results.length > 0 || !input.value;
      // the live count says what it counts: a bare "3" is read out as a number and nothing else
      count.textContent = input.value ? root.dataset.count!.replace('{n}', String(results.length)) : '';
      select(0);
    };
    // prefix of the label > prefix of a word > substring > keyword, per token; every token has to match
    const score = (it: Item, tokens: string[]) => {
      const t = it.l.toLowerCase();
      let s = 0;
      for (const q of tokens) {
        const p = t.startsWith(q) ? 4 : t.split(' ').some((w) => w.startsWith(q)) ? 3 : t.includes(q) ? 2 : it.k.includes(q) ? 1 : 0;
        if (!p) return 0;
        s += p;
      }
      return s;
    };
    const search = () => {
      const tokens = input.value.trim().toLowerCase().split(/\s+/).filter(Boolean);
      results = ix.items
        .map((it) => [it, tokens.length ? score(it, tokens) : 1] as const)
        .filter((x) => x[1])
        .sort((a, b) => b[1] - a[1])
        .slice(0, max)
        .map((x) => x[0]);
      render();
    };
    const open = async () => {
      if (!dialog.open) dialog.showModal();
      input.value = '';
      input.focus();
      if (!ix.loaded) {
        ix.loaded = true;
        try {
          const more = (await (await fetch(root.dataset.source!, { signal })).json()) as { label: string; href: string; group?: string; hint?: string; keywords?: string[] }[];
          // an entry without a label or an href (another index's shape) is left out, not ranked into a throw
          ix.items = [...ix.items, ...more.filter((m) => m.label && m.href).map((m) => ({ l: m.label, h: m.href, g: m.group, d: m.hint, k: (m.keywords ?? []).join(' ').toLowerCase() }))];
        } catch {
          // offline, or the binding ended mid-fetch (a swap that keeps the palette): the server list stands, and the next
          // open tries again, since the index outlives the binding
          ix.loaded = false;
        }
      }
      search();
    };

    // every trigger that points at this dialog: the invoker's default is cancelled so the input is focused and the list
    // refreshed; its key hint says Ctrl where the platform is not Apple's
    const ctrl = !/Mac|iP/.test(navigator.platform);
    for (const t of document.querySelectorAll<HTMLElement>(`[commandfor="${dialog.id}"][command="show-modal"]`)) {
      t.addEventListener('click', (e) => { e.preventDefault(); open(); }, { signal });
      if (ctrl) t.querySelector('.ma-cmdk__kbd')?.replaceChildren(`Ctrl ${key.toUpperCase()}`);
    }
    document.addEventListener('keydown', (e) => {
      if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === key) { e.preventDefault(); dialog.open ? dialog.close() : open(); }
    }, { signal });
    input.addEventListener('input', search, { signal });
    input.addEventListener('keydown', (e) => {
      const n = results.length;
      if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault(); if (n) select((active + (e.key === 'ArrowDown' ? 1 : n - 1)) % n); }
      else if (e.key === 'Enter') { e.preventDefault(); list.querySelector<HTMLAnchorElement>('a[aria-selected="true"]')?.click(); }
      // one Escape closes a modal palette: in a search field the first one would only clear the query (Chromium, Firefox)
      else if (e.key === 'Escape' && dialog.matches(':modal')) { e.preventDefault(); dialog.close(); }
    }, { signal });
    // a click on a result follows the link; the dialog closes with the page (or now, on the same page)
    list.addEventListener('click', (e) => { if ((e.target as Element).closest('a')) dialog.close(); }, { signal });
    // Safari has no closedby, and the palette has no close button: there, a click on the backdrop (the dialog itself, the
    // panel fills the rest) closes it, so a phone with no Escape key still has a way out
    if ((dialog as { closedBy?: string }).closedBy == null) dialog.addEventListener('click', (e) => e.target === dialog && dialog.close(), { signal });
  });
</script>

```
