Skip to content

Components / UI

Command Palette

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.

Live demo

Live, from the library itself: scroll, hover, navigate away and back. The demo is never gated.

Measured

JavaScript of its own

3.0 kB raw

1.4 kB gzip.

Uses the shared runtime (1.9 kB raw, once per site). With those included: 4.7 kB raw.

CSS 9.6 kB raw including the base tokens. Measured from a production build, and again in CI for every change that can move it.

Browser support

Baseline · newly available

Chrome 135 · Firefox 144 · Safari 26.2; needs dialog, command, starting-style. 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.

Install

One command adds the integration, the base tokens and every component. Then import what you use.

npx astro add moonarc
pnpm astro add moonarc
bunx astro add moonarc
src/pages/index.astro
---
import CommandPalette from '@moonarc/core/CommandPalette';
---
<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>
Copy it into your project instead (shadcn registry)

Owns the file, no dependency. The registry item also installs the shared runtime, Dialog, the morph helper and the base tokens.

terminal
npx shadcn@latest add https://moonarc.dev/r/command-palette.json

The CLI needs a components.json and the @/* alias, which Setup has. The file lands in src/components/moonarc/.

Source

The whole component. Self-contained styles in a cascade layer so your classes always win. If you paste it, also copy runtime.ts, Dialog.astro, internal/DialogMorph.astro and morph.ts.

CommandPalette.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>

Props

PropTypeDefaultDescription
idstringnoneDialog 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.
sourcestringnoneURL 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.
hotkeystring'k'⌘ / Ctrl + this key toggles the palette. The trigger shows ⌘ on Apple platforms and Ctrl elsewhere.
placeholderstring'Search…'Input placeholder.
labelstring'Search'Trigger label and the accessible name of the input and the list.
maxnumber12Results shown at most.
emptystring'Nothing found'Shown when a query matches nothing.
countLabelstring'{n} found'The result count under the list, a polite live region; {n} is the number.
polyfillbooleanfalseInline the invoker click handler (403 B raw) for browsers without command / commandfor.

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.

Why it is built this way

Replaces: Command (shadcn / cmdk) · CommandPalette (Motion Primitives) · kbar · Search modal (Tailwind Plus). See the migration table.

dialog

Native, animated, zero script.

Escape, backdrop click and focus are the browser's. The scale and the blur are @starting-style and allow-discrete.

A native modal that scales in over a blurred backdrop and scales out again, closes on Escape, the close button or a backdrop click, and traps focus, all handled by the browser.

newly · Chrome 135 · Firefox 144 · Safari 26.2click
  • src
    • components
      • Hero.astro
      • Nav.astro
    • layouts
      • Base.astro
    • pages
      • index.astro
      • work.astro
  • astro.config.mjs
  • package.json

A project tree whose folders open with an animated height, a turning chevron and a folder glyph that tips open; rows can be lit up to say "this is the file you change".

newly · Chrome 131 · Firefox 143 · Safari 18.4click

A documentation layout: a sticky sidebar with the current page marked, a reading-progress hairline bound to the scroll, the header and content revealed in two beats, and previous/next links.

Chrome 115 · not Firefox · Safari 26scroll

Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown