Skip to main content
xriptxr

Command Palette

Info:Overlay
29

A modal, filterable, keyboard-first index of everything an app can do.

command bar ctrl-k cmd-k quick open launcher fuzzy search action search spotlight

Live demo

live · @xtyle/astro

CommandPalette

The whole list, filtered

Press Ctrl K anywhere on this page ( K on a Mac) — the palette binds itself. Type of and "Open file…" surfaces: the default ranker matches the query as a subsequence, so the characters do not have to be adjacent. Arrows move, Enter runs, Escape dismisses, and focus lands back on whatever you left. Run a few and reopen it: they lift to the top under Recent, and they survive a reload.

Nothing run yet.

New fileCtrlN
Open file…CtrlO
SaveCtrlS
Save all filesCtrlShiftS
Close editorCtrlW
Toggle themelight / darkCtrlShiftT
Zen mode
Toggle terminalCtrl`
Toggle sidebarCtrlB
Git: Commit messageCtrlEnter
Git: Pushorigin/main
Git: Stash changes
Derive a themeopens the generator
Audit contrastWCAG AACtrlShiftA
Publish algorithm
navigate runEsc dismiss

Your ranking, not ours

The same component with a scorer assigned: an exact-substring matcher that pins two commands to the top on an empty query and drops everything the query does not literally contain. Type of here and nothing matches — proof the palette is ranking with the supplied function, not its own. That hook is the whole extension point: return null to drop an item, { score, indices } to keep it.

Nothing run yet.

Derive a themealways first
Audit contrastalways second
New file
Open file…
Settings
Keyboard shortcuts
navigate runEsc dismiss

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.

Give every command in your app an entry and bind hotkey="mod+k". That is the whole integration: one element, one list, one select handler.
group is what the headings are made of. Group by surface (File, View, Git), not by rarity — the filter already handles rarity.
Put the command's real shortcut in shortcut and the palette spells it in Kbd keycaps, so the palette teaches the shortcut that makes it unnecessary.
keywords is where synonyms go: an old name, the word a newcomer would type, the thing a competitor calls it. They match but never render.
Reach for scorer the moment your ranking is a product decision — usage-weighted, recency-weighted, server-side. The palette's own matcher is a good default, not a policy.
Menu is for the commands that belong to one thing on screen. CommandPalette is for the commands that belong to the app.

Props

19 props, straight from the manifest.

PropTypeDefaultBindingsDescription
items CommandItem[]
html svelte astro
Every command the palette can run, unfiltered — `{ id, label, group?, hint?, shortcut?, keywords?, disabled? }`. Set it as a property, or as a JSON string on the attribute.
open boolean false
html svelte
Reflects (and controls) whether the palette is open. Every open starts with a fresh query.
scorer CommandScorer
html svelte astro
The ranking override: `(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.
query string ""
html svelte
The live filter text. Setting it re-filters and re-ranks, exactly as typing does.
hotkey string
html svelte astro
A document-wide chord that opens the palette: `mod+k`, `ctrl+shift+p`. `mod` is ⌘ on Apple and Ctrl everywhere else. Omit it and the palette only opens through `show()` / `open`.
label string "Command palette"
html svelte astro
The accessible name, carried by the dialog, the input, and the listbox.
placeholder string "Type a command or search…"
html svelte astro
The filter input's placeholder.
emptyText string "No matching commands"
html svelte astro
What stands in for the list when the query matches nothing.
recent string[]
html svelte
The ids of recently-run commands, most recent first. Read it to persist them yourself; assign it to seed them from your own store.
recentLabel string "Recent"
html svelte astro
The heading over the recents group.
recentLimit number 5
html svelte astro
How many recently-run commands lead the unfiltered list. `0` shows none.
noRecent boolean false
html svelte astro
Don't track or surface recently-run commands at all.
storageKey string
html svelte astro
A `localStorage` key to persist recents under, so they survive a reload. Omit and recents live only as long as the page does.
noFooter boolean false
html svelte astro
Drop the keyboard legend along the bottom edge.
noCloseOnSelect boolean false
html svelte astro
Keep the palette open after a command runs — for a surface whose commands toggle state rather than navigate away.
show() () => void
html svelte astro
Method: open the palette, remembering what had focus so closing can hand it back.
close(reason?) (reason?: "escape" | "dismiss" | "select" | "api") => void
html svelte astro
Method: close the palette and return focus. The reason rides out on the `close` event.
toggle() () => void
html svelte astro
Method: open if closed, close if open — what the hotkey does.
run(id) (id: string) => void
html svelte astro
Method: run a command by id, exactly as selecting it would — the `select` event, the recents bump, the close.

Appearance

States

active

.xtyle-command-palette__option[data-active="true"]

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

.xtyle-command-palette__option[aria-disabled="true"]

A command listed but not runnable: still filterable, never active, never selected.

empty

.xtyle-command-palette__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

.xtyle-command-palette

The host wrapper. display: contents — the palette puts nothing in flow until it opens.

dialog

.xtyle-command-palette__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.

--surface-overlay --surface-overlay-border --border-thin --radius-lg --elevation-5 --scrim --font-sans --text-body --leading-normal --fg-0 --space-6 --space-8

search

.xtyle-command-palette__search

The filter row: the search glyph and the combobox input that owns focus the whole time.

--space-3 --space-4 --line --border-thin --fg-2 --fg-3

list

.xtyle-command-palette__list

The listbox of surviving commands, under virtual focus. Scrolls on its own; the input never moves.

--space-2

heading

.xtyle-command-palette__heading

A group's heading — a command's group, or the recents label over the commands last run.

--text-xs --weight-semibold --fg-2 --space-1 --space-3

option

.xtyle-command-palette__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.

--fg-1 --accent-bg --fg-0 --fg-disabled --radius-sm --space-2 --space-3 --duration-fast --ease-standard

match

.xtyle-command-palette__match

The characters the query matched, marked inside the label — a real <mark>, so a mod can restyle or drop it.

--accent --weight-semibold

keys

.xtyle-command-palette__keys

A command's own shortcut, spelled out as Kbd keycaps: Ctrl+Shift+P becomes three caps.

--space-1

empty

.xtyle-command-palette__empty

What stands where the list would be when nothing matches.

--text-sm --fg-2 --space-4 --space-6

footer

.xtyle-command-palette__footer

The keyboard legend along the bottom edge — ↑↓ to navigate, ↵ to run, Esc to dismiss.

--text-xs --fg-2 --line --border-thin --space-2 --space-4

Tokens & coverage

What the component consumes, checked live against what the algorithm produces.

Success:fully covered 29/29 consumed tokens produced default register: 305 tokens

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

The surface is a native <dialog> opened with showModal(), so the scrim, the focus trap, the top layer, and Escape are the platform's — not an imitation of them.
The input is a combobox (aria-autocomplete="list", aria-controls, aria-expanded) and the results are a listbox of options grouped by group. Focus never leaves the input: the active command is tracked with aria-activedescendant, which is what lets ↑/↓ walk the list while the caret keeps typing.
Full keyboard operation: type to filter, ↑/↓ to move (wrapping at the ends), PageUp/PageDown to jump, Enter to run, Escape to dismiss. Home and End are left to the caret, where an editable combobox owes them.
Closing hands focus back to whatever had it when the palette opened, however it closed — Enter, Escape, a click on the scrim, or close().
A command's shortcut is announced through aria-keyshortcuts on its option, not left as decorative keycaps.
A disabled command carries aria-disabled and is skipped by the keyboard entirely — it can be read, but never landed on or run.
The active row is marked, never focused, so the browser's own focus ring can't fight the virtual cursor. The search glyph is aria-hidden: it is decoration.

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>