Command Palette

A ⌘K command palette built on the native <dialog> element: live fuzzy filtering, full arrow-key + Enter/Esc control, roving listbox ARIA, and a blurred backdrop with a spring entrance. Feels like Raycast or Linear.

  • <dialog> showModal
  • role=listbox/option
  • backdrop-filter
  • live filter

Source

A single self-contained .astro component. It uses Prism's design tokens (--spectrum-*, --ink…) with sensible fallbacks, so it renders even outside this site.

navigation/command-palette.astro
---
export const meta = {
  title: "Command Palette",
  tags: ["navigation", "command-k", "dialog", "search"],
  description:
    "A ⌘K command palette built on the native <dialog> element: live fuzzy filtering, full arrow-key + Enter/Esc control, roving listbox ARIA, and a blurred backdrop with a spring entrance. Feels like Raycast or Linear.",
  tech: ["<dialog> showModal", "role=listbox/option", "backdrop-filter", "live filter"],
  interactive: true,
  height: 520,
};

const commands = [
  { icon: "◐", label: "Toggle theme", hint: ["⌘", "L"], group: "General" },
  { icon: "✦", label: "New document", hint: ["⌘", "N"], group: "General" },
  { icon: "⧉", label: "Duplicate current view", hint: ["⌘", "D"], group: "General" },
  { icon: "◇", label: "Go to dashboard", hint: ["G", "H"], group: "Navigate" },
  { icon: "◈", label: "Open settings", hint: ["⌘", ","], group: "Navigate" },
  { icon: "⌘", label: "Search the docs", hint: ["/"], group: "Navigate" },
  { icon: "↗", label: "Invite teammates", hint: ["⌘", "I"], group: "Team" },
  { icon: "◔", label: "View activity feed", hint: ["G", "A"], group: "Team" },
  { icon: "⎋", label: "Sign out", hint: [], group: "Account" },
];
---

<section class="cmdk" data-cmdk>
  <button type="button" class="cmdk__trigger" data-cmdk-open>
    <span class="cmdk__trigger-ic" aria-hidden="true">⌕</span>
    <span class="cmdk__trigger-txt">Search commands…</span>
    <kbd class="cmdk__trigger-kbd" aria-hidden="true">⌘K</kbd>
  </button>

  <dialog class="cmdk__dialog" data-cmdk-dialog aria-label="Command palette">
    <div class="cmdk__panel" role="none">
      <div class="cmdk__search">
        <span class="cmdk__search-ic" aria-hidden="true">⌕</span>
        <input
          class="cmdk__input"
          type="text"
          placeholder="Type a command or search…"
          role="combobox"
          aria-expanded="true"
          aria-controls="cmdk-list"
          aria-autocomplete="list"
          autocomplete="off"
          spellcheck="false"
          data-cmdk-input
        />
        <kbd class="cmdk__esc" aria-hidden="true">esc</kbd>
      </div>

      <ul class="cmdk__list" id="cmdk-list" role="listbox" aria-label="Commands" data-cmdk-list>
        {commands.map((c, i) => (
          <li
            class="cmdk__item"
            role="option"
            id={`cmdk-opt-${i}`}
            aria-selected={i === 0 ? "true" : "false"}
            data-cmdk-item
            data-label={c.label.toLowerCase()}
            data-group={c.group}
          >
            <span class="cmdk__item-ic" aria-hidden="true">{c.icon}</span>
            <span class="cmdk__item-label">{c.label}</span>
            <span class="cmdk__item-group">{c.group}</span>
            {c.hint.length > 0 && (
              <span class="cmdk__item-hint" aria-hidden="true">
                {c.hint.map((k) => <kbd>{k}</kbd>)}
              </span>
            )}
          </li>
        ))}
        <li class="cmdk__empty" data-cmdk-empty hidden>No commands match your search.</li>
      </ul>

      <footer class="cmdk__footer">
        <span><kbd>↑</kbd><kbd>↓</kbd> navigate</span>
        <span><kbd>↵</kbd> run</span>
        <span><kbd>esc</kbd> close</span>
      </footer>
    </div>
  </dialog>

  <p class="cmdk__toast" data-cmdk-toast role="status" aria-live="polite"></p>
</section>

<style>
  .cmdk {
    position: relative;
    min-height: 520px;
    display: grid;
    place-items: center;
    padding: 3rem 1.5rem;
    background:
      radial-gradient(60rem 40rem at 50% -10%, color-mix(in oklab, var(--spectrum-1, #8b5cf6) 16%, transparent), transparent 70%),
      var(--bg-sunk, #0b0c15);
    color: var(--ink, #f2f2f7);
    font-family: var(--font-sans, system-ui, sans-serif);
    isolation: isolate;
  }

  /* --- Trigger ---------------------------------------------------------- */
  .cmdk__trigger {
    display: inline-flex;
    align-items: center;
    gap: 0.7rem;
    padding: 0.75rem 0.85rem 0.75rem 1rem;
    min-width: min(22rem, 90vw);
    border-radius: var(--radius-m, 14px);
    border: 1px solid var(--border, #2a2c3a);
    background: color-mix(in oklab, var(--surface, #16171f) 82%, transparent);
    color: var(--ink-muted, #b7b9c9);
    font: inherit;
    cursor: pointer;
    box-shadow: var(--shadow-m, 0 8px 24px rgba(0, 0, 0, 0.3));
    transition: transform var(--dur-fast, 160ms) var(--ease-out, ease),
      border-color var(--dur-fast, 160ms) var(--ease-out, ease),
      box-shadow var(--dur-fast, 160ms) var(--ease-out, ease);
  }
  .cmdk__trigger:hover {
    transform: translateY(-1px);
    border-color: var(--border-strong, #3d4152);
    box-shadow: var(--shadow-l, 0 16px 40px rgba(0, 0, 0, 0.4));
  }
  .cmdk__trigger-ic { font-size: 1.1rem; color: var(--spectrum-3, #45d3e8); }
  .cmdk__trigger-txt { flex: 1; text-align: left; }
  .cmdk__trigger-kbd {
    font-family: var(--font-mono, monospace);
    font-size: 0.72rem;
    padding: 0.2rem 0.45rem;
    border-radius: 6px;
    border: 1px solid var(--border, #2a2c3a);
    background: var(--bg, #0e0f1a);
    color: var(--ink-faint, #8a8c9c);
  }

  /* --- Dialog ----------------------------------------------------------- */
  .cmdk__dialog {
    margin: auto;
    padding: 0;
    border: none;
    background: transparent;
    color: inherit;
    max-width: min(40rem, 92vw);
    width: 100%;
    overflow: visible;
  }
  .cmdk__dialog::backdrop {
    background: color-mix(in oklab, #05060c 55%, transparent);
    backdrop-filter: blur(6px) saturate(1.1);
    -webkit-backdrop-filter: blur(6px) saturate(1.1);
  }

  .cmdk__panel {
    display: grid;
    grid-template-rows: auto 1fr auto;
    max-height: min(30rem, 78vh);
    border-radius: var(--radius-l, 22px);
    border: 1px solid var(--border-strong, #3a3d4d);
    background: color-mix(in oklab, var(--surface, #14151f) 92%, transparent);
    background-image: linear-gradient(180deg, color-mix(in oklab, var(--spectrum-1, #8b5cf6) 8%, transparent), transparent 30%);
    box-shadow: var(--shadow-l, 0 30px 80px rgba(0, 0, 0, 0.55)), var(--glow, 0 0 40px rgba(139, 92, 246, 0.25));
    overflow: hidden;
  }

  /* --- Search ----------------------------------------------------------- */
  .cmdk__search {
    display: flex;
    align-items: center;
    gap: 0.7rem;
    padding: 1rem 1.1rem;
    border-bottom: 1px solid var(--border, #2a2c3a);
  }
  .cmdk__search-ic { font-size: 1.25rem; color: var(--ink-faint, #8a8c9c); }
  .cmdk__input {
    flex: 1;
    background: transparent;
    border: none;
    outline: none;
    color: var(--ink, #fff);
    font: inherit;
    font-size: 1.1rem;
  }
  .cmdk__input::placeholder { color: var(--ink-faint, #8a8c9c); }
  .cmdk__esc {
    font-family: var(--font-mono, monospace);
    font-size: 0.68rem;
    padding: 0.2rem 0.45rem;
    border-radius: 6px;
    border: 1px solid var(--border, #2a2c3a);
    color: var(--ink-faint, #8a8c9c);
  }

  /* --- List ------------------------------------------------------------- */
  .cmdk__list {
    list-style: none;
    margin: 0;
    padding: 0.5rem;
    overflow-y: auto;
    overscroll-behavior: contain;
    scrollbar-width: thin;
  }
  .cmdk__item {
    display: grid;
    grid-template-columns: 1.6rem 1fr auto auto;
    align-items: center;
    gap: 0.8rem;
    padding: 0.65rem 0.8rem;
    border-radius: var(--radius-s, 8px);
    cursor: pointer;
    color: var(--ink-muted, #c4c6d4);
    scroll-margin: 0.5rem;
  }
  .cmdk__item[hidden] { display: none; }
  .cmdk__item-ic {
    display: grid;
    place-items: center;
    width: 1.6rem;
    height: 1.6rem;
    border-radius: 7px;
    font-size: 0.95rem;
    color: var(--spectrum-3, #45d3e8);
    background: color-mix(in oklab, var(--spectrum-3, #45d3e8) 12%, transparent);
  }
  .cmdk__item-label { color: var(--ink, #fff); font-size: 0.98rem; }
  .cmdk__item-group {
    font-size: 0.72rem;
    color: var(--ink-faint, #8a8c9c);
    text-transform: uppercase;
    letter-spacing: 0.06em;
  }
  .cmdk__item-hint { display: inline-flex; gap: 0.25rem; }
  .cmdk__item-hint kbd,
  .cmdk__footer kbd {
    font-family: var(--font-mono, monospace);
    font-size: 0.7rem;
    min-width: 1.4rem;
    text-align: center;
    padding: 0.15rem 0.35rem;
    border-radius: 6px;
    border: 1px solid var(--border, #2a2c3a);
    background: var(--bg, #0e0f1a);
    color: var(--ink-faint, #9a9cac);
  }

  /* active row (roving highlight) */
  .cmdk__item[aria-selected="true"] {
    background: color-mix(in oklab, var(--spectrum-1, #8b5cf6) 20%, transparent);
    color: var(--ink, #fff);
    box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--spectrum-1, #8b5cf6) 40%, transparent);
  }
  .cmdk__item[aria-selected="true"] .cmdk__item-ic {
    color: var(--ink, #fff);
    background: color-mix(in oklab, var(--spectrum-1, #8b5cf6) 45%, transparent);
  }

  .cmdk__empty {
    padding: 2rem 1rem;
    text-align: center;
    color: var(--ink-faint, #8a8c9c);
    font-size: 0.95rem;
  }
  .cmdk__empty[hidden] { display: none; }

  /* --- Footer / toast --------------------------------------------------- */
  .cmdk__footer {
    display: flex;
    gap: 1.2rem;
    padding: 0.7rem 1.1rem;
    border-top: 1px solid var(--border, #2a2c3a);
    font-size: 0.78rem;
    color: var(--ink-faint, #8a8c9c);
    background: color-mix(in oklab, var(--bg, #0e0f1a) 40%, transparent);
  }
  .cmdk__footer span { display: inline-flex; align-items: center; gap: 0.35rem; }

  .cmdk__toast {
    position: absolute;
    bottom: 1.5rem;
    left: 50%;
    transform: translateX(-50%) translateY(0.5rem);
    margin: 0;
    padding: 0.6rem 1rem;
    border-radius: var(--radius-round, 999px);
    background: var(--surface, #16171f);
    border: 1px solid var(--border-strong, #3a3d4d);
    color: var(--ink, #fff);
    font-size: 0.85rem;
    box-shadow: var(--shadow-m, 0 8px 24px rgba(0, 0, 0, 0.35));
    opacity: 0;
    pointer-events: none;
    transition: opacity var(--dur, 320ms) var(--ease-out, ease),
      transform var(--dur, 320ms) var(--ease-spring, ease);
  }
  .cmdk__toast[data-show="1"] {
    opacity: 1;
    transform: translateX(-50%) translateY(0);
  }

  /* --- Motion ----------------------------------------------------------- */
  @keyframes cmdk-in {
    from { opacity: 0; transform: translateY(10px) scale(0.97); }
    to { opacity: 1; transform: translateY(0) scale(1); }
  }
  @keyframes cmdk-fade { from { opacity: 0; } to { opacity: 1; } }

  .cmdk__dialog[open] .cmdk__panel {
    animation: cmdk-in var(--dur, 320ms) var(--ease-spring, cubic-bezier(0.34, 1.56, 0.64, 1));
  }
  .cmdk__dialog[open]::backdrop { animation: cmdk-fade var(--dur, 320ms) var(--ease-out, ease); }

  @media (prefers-reduced-motion: reduce) {
    .cmdk__dialog[open] .cmdk__panel,
    .cmdk__dialog[open]::backdrop { animation: none; }
    .cmdk__trigger,
    .cmdk__toast { transition: none; }
  }
</style>

<script>
  (function () {
    function bind() {
      document.querySelectorAll<HTMLElement>("[data-cmdk]").forEach((root) => {
        if (root.dataset.bound) return;
        root.dataset.bound = "1";

        const dialog = root.querySelector<HTMLDialogElement>("[data-cmdk-dialog]");
        const openBtn = root.querySelector<HTMLButtonElement>("[data-cmdk-open]");
        const input = root.querySelector<HTMLInputElement>("[data-cmdk-input]");
        const list = root.querySelector<HTMLElement>("[data-cmdk-list]");
        const empty = root.querySelector<HTMLElement>("[data-cmdk-empty]");
        const toast = root.querySelector<HTMLElement>("[data-cmdk-toast]");
        if (!dialog || !openBtn || !input || !list || !empty || !toast) return;

        const items = Array.from(list.querySelectorAll<HTMLElement>("[data-cmdk-item]"));
        let active = 0;
        let toastTimer = 0;

        function visible() {
          return items.filter((el) => !el.hidden);
        }

        function setActive(next: number) {
          const vis = visible();
          if (vis.length === 0) return;
          active = (next + vis.length) % vis.length;
          items.forEach((el) => el.setAttribute("aria-selected", "false"));
          const el = vis[active];
          el.setAttribute("aria-selected", "true");
          input!.setAttribute("aria-activedescendant", el.id);
          el.scrollIntoView({ block: "nearest" });
        }

        function filter() {
          const q = input!.value.trim().toLowerCase();
          items.forEach((el) => {
            const hit = !q || (el.dataset.label || "").includes(q);
            el.hidden = !hit;
          });
          const vis = visible();
          empty!.hidden = vis.length > 0;
          active = 0;
          if (vis.length) setActive(0);
          else input!.removeAttribute("aria-activedescendant");
        }

        function run(el?: HTMLElement) {
          const target = el || visible()[active];
          if (!target) return;
          const label = target.querySelector(".cmdk__item-label")?.textContent || "Command";
          close();
          toast!.textContent = "Ran: " + label;
          toast!.dataset.show = "1";
          clearTimeout(toastTimer);
          toastTimer = window.setTimeout(() => { toast!.dataset.show = "0"; }, 1800);
        }

        function open() {
          if (dialog!.open) return;
          input!.value = "";
          filter();
          dialog!.showModal();
          input!.focus();
        }
        function close() {
          if (dialog!.open) dialog!.close();
        }

        openBtn.addEventListener("click", open);
        input.addEventListener("input", filter);

        input.addEventListener("keydown", (e) => {
          if (e.key === "ArrowDown") { e.preventDefault(); setActive(active + 1); }
          else if (e.key === "ArrowUp") { e.preventDefault(); setActive(active - 1); }
          else if (e.key === "Enter") { e.preventDefault(); run(); }
          else if (e.key === "Home") { e.preventDefault(); setActive(0); }
          else if (e.key === "End") { e.preventDefault(); setActive(visible().length - 1); }
        });

        list.addEventListener("click", (e) => {
          const el = (e.target as HTMLElement).closest<HTMLElement>("[data-cmdk-item]");
          if (el) run(el);
        });
        list.addEventListener("pointermove", (e) => {
          const el = (e.target as HTMLElement).closest<HTMLElement>("[data-cmdk-item]");
          if (!el || el.hidden) return;
          const idx = visible().indexOf(el);
          if (idx >= 0 && idx !== active) setActive(idx);
        });

        // global ⌘K / Ctrl+K within this template's root while focused, plus scoped listener
        root.addEventListener("keydown", (e) => {
          if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
            e.preventDefault();
            open();
          }
        });

        // native dialog fires close on Esc; reset show state cleanly
        dialog.addEventListener("cancel", () => { /* allow default close */ });
      });
    }
    document.addEventListener("astro:page-load", bind);
    if (document.readyState !== "loading") bind();
  })();
</script>