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