A ⌘K-style launcher: a modal Dialog whose header is a search field and whose body is a fuzzy-filtered ListBox of commands. It composes three existing primitives — the Dialog shell (with its new header slot), the Input search field, and the ListBox option engine — behind one headless connectCommandPalette core.
Like the Combobox, the field keeps DOM focus and drives the list with aria-activedescendant (the ListBox’s focusStrategy="active-descendant", revealStrategy="deferred" modes). Arrow keys move the cursor without leaving the input; Enter runs the focused command and closes the palette. Unlike a combobox, nothing is “selected” — every row is an action (selectionMode="none"), so activating one fires onCommand and dismisses.
Accessible names. The palette’s header is the search field, not a title, so the <dialog> is named by labels.dialog (default "Command palette"). A consumer aria-label / aria-labelledby on the palette (or on passThrough.dialog.props) replaces it. The search field is named by labels.input (default "Search commands") and points at the results with aria-controls; the results listbox is named by labels.listbox (default "Commands"). Set all three to localize the palette.
Open the palette and type to filter. Matching is fuzzy — nf finds “New File” — and results rank best-match-first. Each command can carry a leading icon, extra keywords (matched but not shown), and a shortcut hint rendered on the trailing edge as a KbdGroup — "soft" by default (quiet square keys on a subtle fill, no border or frame); set shortcutAppearance to "framed", "separate" or "plain" for every row, and shortcutPlatform to pin the platform labels (write mod so the key reads ⌘ on a Mac and Ctrl elsewhere). A command’s own trailing content renders alongside its shortcut. Set showShortcuts={false} to hide them.
---
// GENERATED from packages/demos/command-palette/default.demo.tsx — edit the source, not this file.
import CommandPalette from "@pindoba/astro-command-palette";
import Button from "@pindoba/astro-button";
import { stack } from "@pindoba/styled-system/patterns";
import NewFileIcon from "./_fragments/default.newFileIcon.astro";
import OpenIcon from "./_fragments/default.openIcon.astro";
import SettingsIcon from "./_fragments/default.settingsIcon.astro";
import ProfileIcon from "./_fragments/default.profileIcon.astro";
import ThemeIcon from "./_fragments/default.themeIcon.astro";
const lastRun = null as string | null;
---
<div
class={stack({ gap: "sm", alignItems: "flex-start" })}
data-ir-root="command-palette/default"
>
<Button data-ir="1">Open command palette</Button>
<!--ir:2-->
{lastRun && (
<span>
Ran:{" "}
<strong>
<!--ir:3-->
{lastRun}
<!--/ir-->
</strong>
</span>
)}
<!--/ir-->
<CommandPalette
items={[
{
id: "new-file",
label: "New File",
leading: NewFileIcon,
shortcut: ["mod", "N"],
keywords: ["create", "blank"],
},
{
id: "open-file",
label: "Open File…",
leading: OpenIcon,
shortcut: ["mod", "O"],
},
{
id: "settings",
label: "Open Settings",
leading: SettingsIcon,
shortcut: ["mod", ","],
keywords: ["preferences", "config"],
},
{
id: "profile",
label: "View Profile",
leading: ProfileIcon,
},
{
id: "theme",
label: "Toggle Theme",
leading: ThemeIcon,
keywords: ["dark", "light", "appearance"],
},
]}
data-ir="4"
/>
</div>
<script>
import { hydrate } from "./_client/default.ts";
document
.querySelectorAll('[data-ir-root="command-palette/default"]')
.forEach((root) => hydrate([root]));
</script>The palette is framed by its Dialog’s Card: the search field is the card header (there is no close button — Esc dismisses), the results ListBox is the card content and the footer is the card footer. By default the card is transparent, divided by hairlines, and its content runs edge to edge (content.padding: "none"), so the result rows keep their own padding.
card is forwarded to the Dialog’s card — contentBackground / content, size, divided, footer, passThrough… Pair the palette’s background with card.contentBackground for a shell with the results on an inset panel.footer (the footer body), footerLeading and footerTrailing (the flanks, after any card.footer.leading / card.footer.trailing). Svelte snippets, React props, Vue slots (#footer, #footerLeading, #footerTrailing), Astro named slots (footer, footer-leading, footer-trailing). Keyboard hints are the classic use.card: false), so it never draws a card of its own inside the dialog.---
// GENERATED from packages/demos/command-palette/inset.demo.tsx — edit the source, not this file.
import CommandPalette from "@pindoba/astro-command-palette";
import Button from "@pindoba/astro-button";
import Kbd from "@pindoba/astro-kbd";
import { flex, stack } from "@pindoba/styled-system/patterns";
import RerunIcon from "./_fragments/inset.rerunIcon.astro";
import CancelIcon from "./_fragments/inset.cancelIcon.astro";
import LogsIcon from "./_fragments/inset.logsIcon.astro";
import PromoteIcon from "./_fragments/inset.promoteIcon.astro";
import RollbackIcon from "./_fragments/inset.rollbackIcon.astro";
import PageIcon from "./_fragments/inset.pageIcon.astro";
const hints = flex({ alignItems: "center", gap: "3xs" });
const hintGroup = flex({ alignItems: "center", gap: "sm" });
const lastRun = null as string | null;
---
<div
class={stack({ gap: "sm", alignItems: "flex-start" })}
data-ir-root="command-palette/inset"
>
<Button data-ir="1">Open release console</Button>
<!--ir:2-->
{lastRun && (
<span>
Ran:{" "}
<strong>
<!--ir:3-->
{lastRun}
<!--/ir-->
</strong>
</span>
)}
<!--/ir-->
<CommandPalette
background="surface.valley"
card={{ contentBackground: "surface.peak", size: "sm" }}
labels={{ placeholder: "Search pipelines, environments, people…" }}
items={[
{
id: "pipelines",
type: "section",
title: "Pipeline #1287",
items: [
{
id: "rerun",
label: "Re-run failed jobs",
leading: RerunIcon,
shortcut: ["⇧", "R"],
keywords: ["retry", "flaky"],
},
{
id: "cancel",
label: "Cancel running build",
leading: CancelIcon,
keywords: ["stop", "abort"],
},
{
id: "logs",
label: "Stream build logs",
leading: LogsIcon,
shortcut: ["mod", "L"],
},
],
},
{
id: "environments",
type: "section",
title: "Environments",
items: [
{
id: "promote",
label: "Promote staging to production",
leading: PromoteIcon,
keywords: ["ship", "release", "deploy"],
},
{
id: "rollback",
label: "Roll back to v4.12.0",
leading: RollbackIcon,
keywords: ["revert", "undo"],
},
],
},
{
id: "people",
type: "section",
title: "People",
items: [
{
id: "page",
label: "Page the on-call engineer",
leading: PageIcon,
keywords: ["incident", "alert"],
},
],
},
]}
data-ir="4"
>
<span slot="footer-leading" class={hintGroup}>
<span class={hints}>
<Kbd>↑</Kbd>
<Kbd>↓</Kbd> move
</span>
<span class={hints}>
<Kbd>↵</Kbd> run
</span>
</span>
<span slot="footer-trailing" class={hints}>
<Kbd>Esc</Kbd> close
</span>
</CommandPalette>
</div>
<script>
import { hydrate } from "./_client/inset.ts";
document
.querySelectorAll('[data-ir-root="command-palette/inset"]')
.forEach((root) => hydrate([root]));
</script>card={false} renders the Dialog’s pre-card bare wrapper, with the footer inline under the list (footerLeading / footerTrailing apply only in the card frame).
Migrating from the pre-card palette: the footer now renders in the Dialog card’s footer instead of a div under the list — style it with
passThrough.footer(the card footer in card mode) orpassThrough.card.passThrough.headernow reaches the header Banner around the search field. The results list keeps its selectors: it is still the bare ListBox, with no[data-card-kind="listbox"]card around it.
Group commands into sections with { type: "section", title, items }. Sections render as labelled groups while the query is empty; once you start typing, results flatten and rank globally so the strongest match is always the top row — the norm for a command palette.
---
// GENERATED from packages/demos/command-palette/grouped.demo.tsx — edit the source, not this file.
import CommandPalette from "@pindoba/astro-command-palette";
import Button from "@pindoba/astro-button";
import { stack } from "@pindoba/styled-system/patterns";
import HomeIcon from "./_fragments/grouped.homeIcon.astro";
import InboxIcon from "./_fragments/grouped.inboxIcon.astro";
import CalendarIcon from "./_fragments/grouped.calendarIcon.astro";
import ArchiveIcon from "./_fragments/grouped.archiveIcon.astro";
import ShareIcon from "./_fragments/grouped.shareIcon.astro";
import TrashIcon from "./_fragments/grouped.trashIcon.astro";
const lastRun = null as string | null;
---
<div
class={stack({ gap: "sm", alignItems: "flex-start" })}
data-ir-root="command-palette/grouped"
>
<Button data-ir="1">Open palette</Button>
<!--ir:2-->
{lastRun && (
<span>
Ran:{" "}
<strong>
<!--ir:3-->
{lastRun}
<!--/ir-->
</strong>
</span>
)}
<!--/ir-->
<CommandPalette
items={[
{
id: "navigation",
type: "section",
title: "Navigation",
items: [
{
id: "go-home",
label: "Go to Home",
leading: HomeIcon,
shortcut: "g h",
},
{
id: "go-inbox",
label: "Go to Inbox",
leading: InboxIcon,
shortcut: "g i",
},
{
id: "go-calendar",
label: "Go to Calendar",
leading: CalendarIcon,
shortcut: "g c",
},
],
},
{
id: "actions",
type: "section",
title: "Actions",
items: [
{
id: "archive",
label: "Archive",
leading: ArchiveIcon,
shortcut: "e",
},
{ id: "share", label: "Share…", leading: ShareIcon },
{
id: "delete",
label: "Delete",
leading: TrashIcon,
shortcut: ["mod", "⌫"],
},
],
},
]}
data-ir="4"
/>
</div>
<script>
import { hydrate } from "./_client/grouped.ts";
document
.querySelectorAll('[data-ir-root="command-palette/grouped"]')
.forEach((root) => hydrate([root]));
</script>Set isLoading while a command source is being fetched; the body shows a Loading spinner in place of results. The Svelte demo simulates a fetch on open, then swaps in the commands.
---
// GENERATED from packages/demos/command-palette/async.demo.tsx — edit the source, not this file.
import CommandPalette from "@pindoba/astro-command-palette";
import Button from "@pindoba/astro-button";
import { stack } from "@pindoba/styled-system/patterns";
import type { CommandPaletteNodeInput } from "@pindoba/core-command-palette";
const loading = false;
const items = [] as CommandPaletteNodeInput[];
const lastRun = null as string | null;
---
<div
class={stack({ gap: "sm", alignItems: "flex-start" })}
data-ir-root="command-palette/async"
>
<Button data-ir="1">Open (async load)</Button>
<!--ir:2-->
{lastRun && (
<span>
Ran:{" "}
<strong>
<!--ir:3-->
{lastRun}
<!--/ir-->
</strong>
</span>
)}
<!--/ir-->
<CommandPalette
items={items}
isLoading={loading}
labels={{ placeholder: "Search deployments…" }}
data-ir="4"
/>
</div>
<script>
import { hydrate } from "./_client/async.ts";
document
.querySelectorAll('[data-ir-root="command-palette/async"]')
.forEach((root) => hydrate([root]));
</script>Set hotkey to register a global open shortcut — true binds ⌘K / Ctrl+K, or pass a key string (still requires ⌘/Ctrl). Leave it off (the default) to control open yourself (a trigger button, a menu item, your own key handler).
---
// GENERATED from packages/demos/command-palette/hotkey.demo.tsx — edit the source, not this file.
import CommandPalette from "@pindoba/astro-command-palette";
import Badge from "@pindoba/astro-badge";
import { stack } from "@pindoba/styled-system/patterns";
const lastRun = null as string | null;
---
<div
class={stack({ gap: "sm", alignItems: "flex-start" })}
data-ir-root="command-palette/hotkey"
>
<span>
Press <Badge size="sm">⌘ K</Badge> / <Badge size="sm">Ctrl K</Badge> to open
the palette.
</span>
<!--ir:1-->
{lastRun && (
<span>
Ran:{" "}
<strong>
<!--ir:2-->
{lastRun}
<!--/ir-->
</strong>
</span>
)}
<!--/ir-->
<CommandPalette
hotkey
items={[
{ id: "search", label: "Search Everywhere", shortcut: ["mod", "P"] },
{ id: "command", label: "Run Command", shortcut: ["⇧", "mod", "P"] },
{ id: "goto", label: "Go to Line", shortcut: ["mod", "G"] },
{ id: "format", label: "Format Document", shortcut: ["⌥", "⇧", "F"] },
]}
data-ir="3"
/>
</div>
<script>
import { hydrate } from "./_client/hotkey.ts";
document
.querySelectorAll('[data-ir-root="command-palette/hotkey"]')
.forEach((root) => hydrate([root]));
</script>The built-in fuzzy matcher (filter={true}, the default) ranks by match quality. To plug in your own ranking — or to drive results from a server — pass a filter function (command, query) => number | boolean | null: return a score (higher ranks first), true/false to keep/drop without ranking, or null to drop. Pass filter={false} to disable client filtering entirely and feed pre-filtered items yourself.