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
19 props, straight from the manifest.
| Prop | Type | Default | Bindings | Description |
|---|---|---|---|---|
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, with their selectors.
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-semibold
Accessibility
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>