Command Palette
A modal, filterable, keyboard-first index of everything an app can do.
Live demo
CommandPalette is the surface behind Ctrl-K. It takes the whole list of things an app can do, filters it as the user types, groups what survives, spells out each command's own shortcut in keycaps, and runs the one they land on.
It filters itself. A palette handed a pre-filtered list is just a list, so the component ships a working ranker: a subsequence matcher, which is why of finds "Open File" and gcm finds "Git: Commit Message". It scores runs, word starts, and camelCase humps, docks late and long matches, and falls back to an item's group, hint, and keywords so a command surfaces on a synonym it never displays. The matched characters come back marked in the label. Nothing about that is load-bearing: assign a scorer and the palette ranks with yours instead — a real fuzzy library, a usage-weighted model, a server-side search — and renders whatever you return, in the order you score it. That is the whole extension point, and it is one function.
The surface is a native <dialog>, so the scrim, the focus trap, and Escape are the platform's rather than a re-derived imitation. Focus stays in the input the entire time: the list is a listbox under virtual focus (aria-activedescendant), so ↑/↓ walk the commands while the caret keeps typing. Enter runs the active command, Escape dismisses, and focus goes back to whatever had it when the palette opened — a button, a menu item, a text caret mid-document. Home and End are deliberately left alone; they belong to the caret in an editable combobox, and stealing them is the classic palette bug.
Recently-run commands lift to the top of the unfiltered list under their own heading — free, and the affordance every real palette grows within a week. Point storage-key at a localStorage key and they survive the reload. Give it a hotkey (mod+k, ⌘ on Apple and Ctrl elsewhere) and it binds itself to the document; the docs site you are reading uses exactly that.
When to use
How this component composes with the rest of the set.
Props
26 props, straight from the manifest.
| Prop | Type | Default | Bindings | Description |
|---|---|---|---|---|
{ id, label, group?, hint?, shortcut?, keywords?, disabled? }. Set it as a property, or as a JSON string on the attribute. | ||||
(query, item) => CommandMatch | null. Return null to drop an item, or { score, indices? } to keep it — higher scores rank earlier, and indices are the label characters to highlight. Defaults to the built-in subsequence matcher. Property only; it is a function. | ||||
mod+k, ctrl+shift+p. mod is ⌘ on Apple and Ctrl everywhere else. Omit it and the palette only opens through show() / open. | ||||
items renders that command; one that names nothing renders as itself, so a palette whose query carries arguments can remember whole lines — rect 10,10 40,40 azul rather than the fact that you once ran rect. Selecting a line reports it as the select event's id. | ||||
0 shows none. | ||||
localStorage key to persist recents under, so they survive a reload. Omit and recents live only as long as the page does. | ||||
cl cannot wipe a layer on one keystroke. Once the first word names the command, Enter runs it, arguments and all. Leave it off for an index of complete commands, where one Enter should run what you landed on; turn it on for a palette whose query carries a command's arguments. While it is on, a query whose first word is a command's id matches that command and leads the list, so a completed name never filters itself out. | ||||
close event. | ||||
select event, the recents bump, the close. | ||||
"false" because a spell-checker underlining command names helps nobody. Enumerated rather than boolean, so overriding needs an explicit value — a bare spellcheck is the HTML spelling of on. | ||||
off — an autofill dropdown over the results list is never what you wanted. | ||||
spellcheck is. | ||||
go, since Enter runs the highlighted command rather than inserting a newline. |
Events
What the component emits, and what rides along on event.detail. The name in the
first column is the one addEventListener takes.
| Event | Detail | Bindings | Description |
|---|---|---|---|
Methods
What you can call on the element itself — the half of the surface a prop or an event cannot express. A control driven by a gesture, or one whose state the platform owns, opens here. Astro renders on the server and hands back no instance, so no method is reachable from it.
| Method | Returns | Bindings | Description |
|---|---|---|---|
close event. | |||
select event, the recents bump, the close. Does nothing for an id that is missing or disabled. |
Appearance
States
active
The command under virtual focus — what Enter runs. Marked data-active and aria-selected; it is never DOM-focused, because the caret must stay in the input.
disabled
A command listed but not runnable: still filterable, never active, never selected.
empty
Nothing matched: the list is gone and the empty text stands in its place.
Anatomy
The named parts that make up the component. A ::part() handle is reachable from your own stylesheet; the class beside it is the component's internal selector, which a shadow boundary keeps to itself.
palette
The host wrapper. display: contents — the palette puts nothing in flow until it opens.
dialog
The modal surface: a native <dialog> in the top layer, so the scrim, the focus trap, and Escape come from the platform. Sits high in the viewport, where a palette belongs.
search
The filter row: the search glyph and the combobox input that owns focus the whole time.
list
The listbox of surviving commands, under virtual focus. Scrolls on its own; the input never moves.
heading
A group's heading — a command's group, or the recents label over the commands last run.
option
One command: its label (with the matched characters marked), its hint, and its shortcut. The active row is marked data-active, not focused — the caret stays in the input.
match
The characters the query matched, marked inside the label — a real <mark>, so a mod can restyle or drop it.
keys
A command's own shortcut, spelled out as Kbd keycaps: Ctrl+Shift+P becomes three caps.
empty
What stands where the list would be when nothing matches.
footer
The keyboard legend along the bottom edge — ↑↓ to navigate, ↵ to run, Esc to dismiss.
Tokens & coverage
What the component consumes, checked live against what the algorithm produces.
Live coverage check against the xtyle-default register
(derive(xtyleDefault, { anchors }) →
coverComponent(manifest, register)). Every token this component
consumes must be a key the algorithm produces.
--accent--accent-bg--border-thin--duration-fast--ease-standard--elevation-5--fg-0--fg-1--fg-2--fg-3--fg-disabled--font-sans--leading-normal--line--radius-lg--radius-sm--scrim--space-1--space-2--space-3--space-4--space-6--space-8--surface-overlay--surface-overlay-border--text-body--text-sm--text-xs--weight-semiboldAccessibility
Code
The whole list, filtered
Hand it every command, bind a hotkey, and listen for select. The palette does the filtering, the grouping, the keycaps, and the keyboard.
<xtyle-command-palette id="palette" hotkey="mod+k" label="Command palette"></xtyle-command-palette>
<script>
const palette = document.querySelector("#palette");
// hand it the WHOLE command list — the palette does the filtering
palette.items = [
{ id: "file.new", label: "New file", group: "File", shortcut: "Ctrl+N" },
{ id: "file.open", label: "Open file…", group: "File", shortcut: "Ctrl+O", keywords: ["browse"] },
{ id: "file.save", label: "Save", group: "File", shortcut: "Ctrl+S" },
{ id: "view.theme", label: "Toggle theme", group: "View", hint: "light / dark" },
{ id: "view.zen", label: "Zen mode", group: "View", disabled: true },
];
palette.addEventListener("select", (event) => {
const { id, item } = event.detail;
console.log("run", id, item.label);
});
// or open it yourself, from a button
document.querySelector("#launch").addEventListener("click", () => palette.show());
</script><script lang="ts">
import { CommandPalette, Button, type CommandItem } from "@xtyle/svelte";
let open = $state(false);
const commands: CommandItem[] = [
{ id: "file.new", label: "New file", group: "File", shortcut: "Ctrl+N" },
{ id: "file.open", label: "Open file…", group: "File", shortcut: "Ctrl+O", keywords: ["browse"] },
{ id: "view.theme", label: "Toggle theme", group: "View", hint: "light / dark" },
];
</script>
<Button onclick={() => (open = true)}>Commands</Button>
<CommandPalette
bind:open
items={commands}
hotkey="mod+k"
storageKey="demo.palette.recent"
onselect={(event) => console.log("run", event.detail.id)}
/>---
import CommandPalette from "@xtyle/astro/CommandPalette.astro";
const commands = [
{ id: "file.new", label: "New file", group: "File", shortcut: "Ctrl+N" },
{ id: "file.open", label: "Open file…", group: "File", shortcut: "Ctrl+O" },
{ id: "view.theme", label: "Toggle theme", group: "View", hint: "light / dark" },
];
---
<CommandPalette id="palette" items={commands} hotkey="mod+k" />
<script>
document.querySelector("#palette").addEventListener("select", (event) => {
console.log("run", event.detail.id);
});
</script>Your ranking, not ours
The override hook: one function. Return null to drop an item, { score, indices } to keep it. The palette renders whatever you return, in the order you score it.
<xtyle-command-palette id="ranked"></xtyle-command-palette>
<script>
const palette = document.querySelector("#ranked");
palette.items = commands;
// the override hook: return null to drop an item, or { score, indices } to keep it.
// higher scores rank earlier; `indices` are the label characters to highlight.
palette.scorer = (query, item) => {
if (query === "") return { score: item.pinned ? 100 : 0 };
const at = item.label.toLowerCase().indexOf(query.toLowerCase());
if (at === -1) return null;
return {
score: 100 - at,
indices: Array.from({ length: query.length }, (_, i) => at + i),
};
};
</script><script lang="ts">
import { CommandPalette, type CommandScorer } from "@xtyle/svelte";
import { rank } from "./my-fuzzy-lib";
// bring your own ranking: a real fuzzy library, a usage-weighted model, anything
const scorer: CommandScorer = (query, item) => {
const hit = rank(query, item.label);
return hit ? { score: hit.score, indices: hit.positions } : null;
};
</script>
<CommandPalette {items} {scorer} hotkey="mod+k" />---
import CommandPalette from "@xtyle/astro/CommandPalette.astro";
---
<CommandPalette id="ranked" items={commands} />
<script>
import { rank } from "../lib/my-fuzzy-lib";
// the palette always filters — it just doesn't insist on filtering *its* way
document.querySelector("#ranked").scorer = (query, item) => {
const hit = rank(query, item.label);
return hit ? { score: hit.score, indices: hit.positions } : null;
};
</script>A command line, where the query carries the arguments
completeFirst makes Enter commit a name before it runs anything, so cl fills in clear and waits rather than wiping the layer on one keystroke. Recents are whole lines rather than command ids, because an id can only remember that you ran rect, never the rectangle.
<xtyle-command-palette id="line" complete-first label="Command line"></xtyle-command-palette>
<script type="module">
import "@xtyle/core/elements";
const palette = document.querySelector("#line");
palette.items = [
{ id: "rect", label: "rect <x,y> <w,h> [color]", hint: "draw a rectangle" },
{ id: "goto", label: "goto <line>", hint: "jump to a line" },
{ id: "clear", label: "clear", hint: "wipe the layer" },
];
// recents are whole lines, so history keeps the rectangle and not just "rect"
let history = ["rect 10,10 40,40 azul", "goto 214"];
palette.recent = history;
palette.addEventListener("select", (event) => {
const { id, query } = event.detail;
const line = query.trim().split(/\s+/)[0] === id ? query.trim() : id;
run(line);
if (line !== id) palette.recent = history = [line, ...history.filter((seen) => seen !== line)].slice(0, 8);
});
</script><script lang="ts">
import { CommandPalette } from "@xtyle/svelte";
const items = [
{ id: "rect", label: "rect <x,y> <w,h> [color]", hint: "draw a rectangle" },
{ id: "goto", label: "goto <line>", hint: "jump to a line" },
{ id: "clear", label: "clear", hint: "wipe the layer" },
];
let history = $state(["rect 10,10 40,40 azul", "goto 214"]);
function onselect(event: CustomEvent) {
const { id, query } = event.detail;
const line = query.trim().split(/\s+/)[0] === id ? query.trim() : id;
run(line);
if (line !== id) history = [line, ...history.filter((seen) => seen !== line)].slice(0, 8);
}
</script>
<!-- Enter on a half-typed name completes it; Enter on a finished name runs the line -->
<CommandPalette {items} completeFirst recent={history} {onselect} />---
import CommandPalette from "@xtyle/astro/CommandPalette.astro";
const items = [
{ id: "rect", label: "rect <x,y> <w,h> [color]", hint: "draw a rectangle" },
{ id: "goto", label: "goto <line>", hint: "jump to a line" },
{ id: "clear", label: "clear", hint: "wipe the layer" },
];
---
<CommandPalette id="line" {items} completeFirst label="Command line" />
<script>
const palette = document.querySelector("#line");
palette.recent = ["rect 10,10 40,40 azul", "goto 214"];
</script>