1 CommandPalette
L4stIdi0t edited this page 2026-10-06 12:08:41 +02:00

CommandPalette

The ⌘K box: one field that finds anything and runs it.

  • a modal with one input and a grouped list of results
  • a global shortcut opens it (⌘K on macOS, Ctrl+K elsewhere, or your own), but not while the user is typing in another field
  • focus stays in the input; ↑/↓ move the active row, Enter runs it, Escape closes and focus goes back where it was
  • items is the list the app already has, filtered here as you type; sources are asked while you type and their rows appear as each one answers, with answers to a query already typed past dropped
  • a mode prefix (>) and filter terms (size:>1gb) become chips that Backspace removes
  • recent picks are listed first while the field is empty
  • the result count is announced, and the active row is the input's aria-activedescendant

Combobox picks a value from its own flat list. This runs an action, groups its rows, and shows rows it did not filter itself.

Navigation · two-way: bind:open · portalled to <body> · keyboard contract

<script lang="ts">
	import { CommandPalette } from '@l4st/driftwood';
</script>
Light Dark
command-palette command-palette in dark mode

Props

Prop Type Default What it does
open bind: boolean false Opening moves focus to the input and makes the rest of the page inert.
items CommandItem[] [] Rows the app already has: pages, actions. Filtered here, on every keystroke.
sources CommandSource[] [] Rows that come from somewhere slow. Each source is asked as the user types and shown when it answers.
modes CommandMode[] [] Prefixes that narrow the list to some groups: { prefix: '>', label: 'Actions', groups: ['Actions'] }.
filters string[] [] Keys that turn a typed key:value term into a chip, once it is followed by a space. They reach sources and filter in the query.
shortcut string | null 'mod+k' Opens and closes the palette from anywhere on the page, in Kbd notation. null leaves opening to you.
recentKey string — A localStorage key. With it, recent picks survive a reload — for rows in items; a row from a source is only remembered until then.
recentLimit number 5 How many recent picks to list. 0 turns the list off.
debounce number 120 Milliseconds of quiet before sources are asked.
filter (item: CommandItem, query: CommandQuery) => boolean — Replaces the built-in match for items: every typed word in the title, description or keywords.
onSelect (item: CommandItem) => void — Fires for every pick, before the row's own onselect.
onError (error: unknown) => void — A source that rejects shows no rows. The reason arrives here.
label string 'Command palette' Names the dialog and its input for assistive tech.
placeholder string 'Search or run a command…' Shown while the field is empty.
emptyLabel string 'No results' Shown, and announced, when nothing matches.
loadingLabel string 'Searching…' Shown, and announced, while sources are asked and there is nothing to list yet.
recentLabel string 'Recent' Heading over the recent picks.
resultsLabel (count: number) => string (count) => (count === 1 ? '1 result' : `${count} results`) Announced when the list changes. Localise here.
navigateLabel string 'Navigate' Footer hint beside ↑ ↓.
runLabel string 'Run' Footer hint beside Enter.
closeLabel string 'Close' Footer hint beside Escape.
removeLabel (chip: string) => string (chip) => `Remove ${chip}` Accessible name of a chip's remove button, given the chip's text.
class string — Merged with the component's own classes, last — so yours win.

Types

export type CommandItem = {
	/** Stable and unique. Recent picks are remembered by it. */
	id: string;
	title: string;
	/** One muted line under the title. */
	description?: string;
	icon?: Component;
	/** The heading this row sits under. */
	group?: string;
	/** Extra words the built-in filter matches. Never shown. */
	keywords?: string[];
	/** Trailing text: a size, a count, a date. */
	hint?: string;
	/** Trailing shortcut in Kbd notation (`'mod+s'`). Shown, not bound. */
	shortcut?: string;
	disabled?: boolean;
	/** Runs on Enter or a click, once the palette has closed. Receives the event (read `metaKey`). */
	onselect?: (e: MouseEvent | KeyboardEvent) => void;
};
export type CommandFilter = {
	/** The part before the colon, lower-cased: `size`. */
	key: string;
	/** The part after it, as typed: `>1gb`. */
	value: string;
};
export type CommandQuery = {
	/** What is typed, trimmed, without the mode prefix or any filter term. */
	text: string;
	/** The chips, in the order they were typed. */
	filters: CommandFilter[];
	/** The active mode's prefix, or null. */
	mode: string | null;
};
export type CommandSource = {
	/** Heading for results that do not name their own group. */
	group?: string;
	/** Called as the user types. Honour the signal: it aborts when the query has moved on. */
	search: (query: CommandQuery, signal: AbortSignal) => Promise<CommandItem[]>;
};
export type CommandMode = {
	/** Typed first to enter the mode: `>`. */
	prefix: string;
	/** Shown on the chip that replaces the prefix. */
	label: string;
	/** Only rows under these headings are shown while the mode is on. */
	groups?: string[];
	/** Replaces the input's placeholder while the mode is on. */
	placeholder?: string;
};

Keyboard

Key Does
Cmd/Ctrl + K open or close from anywhere, unless a field has focus — the shortcut prop changes it
↑ / ↓ move the active row; the list wraps
Enter run the active row and close
Backspace from the start of the field, remove the last chip, then leave the mode
Escape close, focus returns to where it was

Example

The markup behind the screenshot above, lifted from src/routes/shots/ — so it compiles, and it is exactly what produced the image.

<script lang="ts">
	import { CommandPalette, toast, type CommandItem, type CommandSource } from '$lib/index.js';
	import { LayoutDashboard, HardDrive, Settings, Play, Trash2, FileText } from '@lucide/svelte';

	let open = $state(true);
	const items: CommandItem[] = [
		{ id: 'overview', group: 'Go to', title: 'Overview', description: 'Totals for every volume', icon: LayoutDashboard },
		{ id: 'volumes', group: 'Go to', title: 'Volumes', description: 'Four mounted, one read-only', icon: HardDrive },
		{ id: 'settings', group: 'Go to', title: 'Settings', icon: Settings, shortcut: 'mod+,' },
		{
			id: 'scan',
			group: 'Actions',
			title: 'Start a scan',
			description: 'Walk a volume and index what changed',
			icon: Play,
			shortcut: 'mod+shift+s',
			onselect: () => toast.success({ title: 'Scan started' })
		},
		{ id: 'purge', group: 'Actions', title: 'Empty the recycle bin', icon: Trash2, hint: '3.2 GB' }
	];
	const files: CommandSource = {
		group: 'Files',
		search: async ({ text, filters }, signal) => {
			const params = new URLSearchParams({ q: text, ...Object.fromEntries(filters.map((f) => [f.key, f.value])) });
			const res = await fetch(`/api/files?${params}`, { signal });
			const found: { path: string; name: string; size: string }[] = await res.json();
			return found.map((f) => ({ id: f.path, title: f.name, description: f.path, hint: f.size, icon: FileText }));
		}
	};
</script>

<CommandPalette
	bind:open
	{items}
	sources={[files]}
	modes={[{ prefix: '>', label: 'Actions', groups: ['Actions'], placeholder: 'Run an action…' }]}
	filters={['size', 'type']}
	recentKey="palette-recent"
	placeholder="Search pages, actions and files…"
/>

Notes

Notes

  • Mount one, at the root. Next to the Toaster, in the layout. It listens for its shortcut on the window, so it works from every page, and renders nothing until it opens. A button that opens it sets bind:open.

  • The shortcut is a prop. shortcut="mod+k" is the default; mod is ⌘ on macOS and Ctrl elsewhere. Any string in Kbd notation works ('mod+shift+p', '/'), and the same string given to <Kbd keys=…> draws it. shortcut={null} turns the listener off. The shortcut does nothing while focus is in an input, a textarea, a select or a contenteditable, so it never takes a keystroke from someone typing. Pressing it again closes the palette.

  • items or sources. items is what the app holds in memory: the palette filters it on every keystroke, and ranks a match at the start of a title above one in the description or the keywords. A source is for what only a server knows. It is not filtered here: what it returns is shown as returned.

    const files: CommandSource = {
    	group: 'Files',
    	search: async ({ text, filters }, signal) => {
    		const res = await fetch(`/api/files?q=${encodeURIComponent(text)}`, { signal });
    		return (await res.json()).map((f) => ({ id: f.path, title: f.name, hint: f.size }));
    	}
    };
    

    Pass the signal on. The palette aborts it when the query changes and ignores whatever an aborted question still answers, so an old answer cannot overwrite a newer one. Sources are not asked while the field is empty.

  • While a source is working a line runs along the bottom of the field and the rows from its previous answer stay, dimmed, until the new ones replace them. Each source lands on its own; a slow one does not hold back a fast one.

  • Modes and chips are generic. The palette does not know what > or size: mean. A mode only says which groups stay visible. A filter term becomes a chip when it is followed by a space, and is handed to sources (and to filter, if you pass one) as { key, value }. The built-in match for items ignores chips, because it cannot know what they compare against.

  • A row runs, it does not select. There is no value. onselect gets the event, so e.metaKey can open a page in a new tab. The palette closes first, and gives focus back only if the action did not move it somewhere itself.

  • Recent picks are kept by id. With recentKey the ids go to localStorage; after a reload a row is listed again if its id is in items. A row that came from a source is remembered until the page is reloaded.

  • A row's shortcut is shown, not bound. The palette draws it; the app still has to listen for it. matchesShortcut(e, 'mod+s') from the kit tests a keydown against the same notation.

  • Motion. One highlight slides between rows, the list's height follows its content as results arrive, and the panel plays its entrance backwards on close. All of it stops under prefers-reduced-motion.

Navigation · Tree · Tabs · Pagination · Kbd


← All components · Getting started · Theming · source