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

A command line, where the query carries the arguments

With completeFirst, Enter commits a name before it runs anything. Type cl — you land on clear, and Enter fills the name in rather than wiping the layer; a second Enter runs it. Once the first word names a command, Enter runs it with whatever follows, so goto 214 goes straight through. That is the whole safeguard, and it needs no per-command flag: a name you have not finished typing completes, a name you have finished runs.

Its recent entries are lines, not ids. An id can only ever remember that you once ran rect; the rectangle was the part worth keeping. Open it on an empty query and the three under Recent are whole lines — selecting one reports it as the select event's id.

Nothing run yet.

rect <x,y> <w,h> [color]draw a rectangle
circle <x,y> <r> [color]draw a circle
fill [color]flood with the active pigment
goto <line>jump to a line
clearwipe the layer
undostep back
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.

While open, the host relocates to document.body — a modal <dialog> anchors to the nearest ancestor that establishes a containing block, so one declared inside a transformed or filtered panel would center on that panel rather than the viewport. The consequence is that it stops being a descendant of whatever declared it, precisely while it is visible: a bind:this container query and a framework-scoped selector both go dead on open and come back on close. Reach it by id (document.getElementById, :global(#that-id)), which survives the move.
An inherited property an app sets document-wide (cursor, font-family) reaches this component's internals only through its light-DOM host, so set it on the host rather than on the rendered dialog. A document-level rule cannot cross the shadow boundary, which is why an app that hides the OS cursor gets a cursorless overlay until the declaration moves onto the element itself.
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

26 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
property
CommandScorer
html svelte
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
property
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 html: empty-text string "No matching commands"
html svelte astro
What stands in for the list when the query matches nothing.
recent
property
string[]
html svelte
What was run recently, most recent first. Read it to persist them yourself; assign it to seed them from your own store. An entry naming a command in 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.
recentLabel html: recent-label string "Recent"
html svelte astro
The heading over the recents group.
recentLimit html: recent-limit number 5
html svelte astro
How many recently-run commands lead the unfiltered list. 0 shows none.
noRecent html: no-recent boolean false
html svelte astro
Don't track or surface recently-run commands at all.
storageKey html: storage-key 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 html: no-footer boolean false
html svelte astro
Drop the keyboard legend along the bottom edge.
noCloseOnSelect html: no-close-on-select boolean false
html svelte astro
Keep the palette open after a command runs — for a surface whose commands toggle state rather than navigate away.
completeFirst html: complete-first boolean false
html svelte astro
Make Enter commit a command's name before it runs anything. Landing on a command the query's first word does not already name — half-typed, fuzzy-matched, or arrowed to — fills the name in and waits, so a second Enter runs it; 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.
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.
spellcheck "true" | "false"
html svelte astro
Forwarded to the search box, and defaulted to "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.
inputmode "text" | "numeric" | "decimal" | "tel" | "email" | "url" | "search" | "none"
html svelte astro
Forwarded to the inner control: which on-screen keyboard a touch device should raise. Purely a hint — it does not validate or restrict what can be typed.
autocomplete string
html svelte astro
Forwarded to the search box, and defaulted to off — an autofill dropdown over the results list is never what you wanted.
autocapitalize "off" | "none" | "on" | "sentences" | "words" | "characters"
html svelte astro
Forwarded to the inner control: whether a touch keyboard capitalizes as you type.
autocorrect "on" | "off"
html svelte astro
Forwarded to the inner control: the browser's own autocorrect. Non-standard but widely honored, and worth turning off wherever spellcheck is.
enterkeyhint "enter" | "done" | "go" | "next" | "previous" | "search" | "send"
html svelte astro
Forwarded to the search box, and defaulted to 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.

EventDetailBindingsDescription
open
html svelte astro
The palette opened. The query is always empty at this point.
close { reason: "escape" | "dismiss" | "select" | "api" }
html svelte astro
The palette closed, however it closed.
query { query }
html svelte astro
The filter text changed. What a palette whose commands take arguments watches.
select { id, label, item, index, query }
html svelte astro
A command was run.

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.

MethodReturnsBindingsDescription
show() void
html svelte
Open the palette, remembering what had focus so closing can hand it back.
close(reason?: "escape" | "dismiss" | "select" | "api") void
html svelte
Close the palette and return focus to wherever it came from. The reason rides along on the close event.
toggle() void
html svelte
Open if closed, close if open — what the hotkey does.
run(id: string) void
html svelte
Run a command by id, exactly as selecting it would: the select event, the recents bump, the close. Does nothing for an id that is missing or disabled.

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

Success:::part(palette) .xtyle-command-palette

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

dialog

Success:::part(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

Success:::part(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

Success:::part(list) .xtyle-command-palette__list

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

--space-2

heading

Success:::part(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

Success:::part(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

Success:::part(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

Success:::part(keys) .xtyle-command-palette__keys

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

--space-1

empty

Success:::part(empty) .xtyle-command-palette__empty

What stands where the list would be when nothing matches.

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

footer

Success:::part(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: 310 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>

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>

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>