Github

Command

Native <dialog class="command">. Open with showModal(), close with close(), Escape, or a click on the backdrop (event.target === dialog). Search sits in .command-search: muted fill, a 1px border, and a magnifying-glass SVG — not a naked .input with a full-width rule under it. Inner padding is 0.25rem. Optional .command-group with a title; a .command-separator only between groups, inset from the edges. A row may start with an icon and end with a .kbd-group chord (Command or Ctrl plus a letter). While the dialog is open, that combo runs the row; a bare letter types into the filter. Shortcuts are optional — only these examples use them. Filter is a simple includes match on the row label; an empty query shows every sample. Arrow keys move data-active on a row; Enter runs it. There is no wrapper and no cmdk. Tiny $state on this page is enough.

Preview

No command yet.

Command palette
Suggestions
Settings

Markup

<script>
	function mark(inner) {
		return `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${inner}</svg>`;
	}
	const glyphs = {
		search: mark('<circle cx="11" cy="11" r="7"></circle><path d="m20 20-3-3"></path>'),
		calendar: mark('<rect width="18" height="18" x="3" y="4" rx="2"></rect><path d="M16 2v4M8 2v4M3 10h18"></path>'),
		smile: mark('<circle cx="12" cy="12" r="10"></circle><path d="M8 14s1.5 2 4 2 4-2 4-2"></path><path d="M9 9h.01M15 9h.01"></path>'),
		calculator: mark('<rect width="16" height="20" x="4" y="2" rx="2"></rect><path d="M8 6h8M16 14v4M16 10h.01M12 10h.01M8 10h.01M12 14h.01M8 14h.01M12 18h.01M8 18h.01"></path>'),
		user: mark('<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"></path><circle cx="12" cy="7" r="4"></circle>'),
		card: mark('<rect width="20" height="14" x="2" y="5" rx="2"></rect><path d="M2 10h20"></path>'),
		gear: mark('<circle cx="12" cy="12" r="3"></circle><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"></path>')
	};
	const samples = [
		{ label: 'Calendar', group: 'Suggestions', icon: 'calendar' },
		{ label: 'Search emoji', group: 'Suggestions', icon: 'smile' },
		{ label: 'Calculator', group: 'Suggestions', icon: 'calculator' },
		{ label: 'Profile', group: 'Settings', icon: 'user', shortcut: 'P' },
		{ label: 'Billing', group: 'Settings', icon: 'card', shortcut: 'B' },
		{ label: 'Settings', group: 'Settings', icon: 'gear', shortcut: 'S' }
	];
	let dialog;
	let query = $state('');
	let active = $state(0);
	let ran = $state('');
	let hover_ok = $state(false);
	const filtered = $derived(samples.filter((item) => item.label.toLowerCase().includes(query.trim().toLowerCase())));

	function group_items(list) {
		const out = [];
		let index = 0;
		for (const item of list) {
			const title = item.group || '';
			const last = out[out.length - 1];
			if (!last || last.title !== title) out.push({ title, items: [] });
			out[out.length - 1].items.push({ item, index });
			index += 1;
		}
		return out;
	}
	const groups = $derived(group_items(filtered));

	function open() {
		query = '';
		active = 0;
		hover_ok = false;
		dialog.showModal();
	}
	function on_backdrop(event) {
		if (event.target === dialog) dialog.close();
	}
	function move(delta) {
		if (!filtered.length) return;
		const next = active + delta;
		if (next < 0) active = filtered.length - 1;
		else if (next >= filtered.length) active = 0;
		else active = next;
	}
	function run(item) {
		if (!item) return;
		ran = item.label;
		dialog.close();
	}
	function on_key(event) {
		if (event.key === 'ArrowDown') { event.preventDefault(); hover_ok = false; move(1); }
		else if (event.key === 'ArrowUp') { event.preventDefault(); hover_ok = false; move(-1); }
		else if (event.key === 'Enter') { event.preventDefault(); run(filtered[active]); }
		else if (event.isComposing || event.repeat || event.altKey) return;
		else if ((event.metaKey || event.ctrlKey) && event.key.length === 1) {
			const hit = filtered.find((item) => item.shortcut === event.key.toUpperCase());
			if (!hit) return;
			event.preventDefault();
			run(hit);
		}
	}
	function hover(i) {
		if (!hover_ok) return;
		active = i;
	}
</script>

<button class="button" type="button" onclick={open}>Open command</button>
<p class="field-description">{ran ? `Ran ${ran}.` : 'No command yet.'}</p>

<dialog class="command" bind:this={dialog} aria-labelledby="command-title" onclick={on_backdrop} onkeydown={on_key}>
	<div class="sr-only" id="command-title">Command palette</div>
	<label class="sr-only" for="command-q">Search commands</label>
	<div class="command-search">
		{@html glyphs.search}
		<input class="input" id="command-q" type="text" placeholder="Type a command…" autocomplete="off" bind:value={query} oninput={() => active = 0}>
	</div>
	<div class="command-list" role="listbox" tabindex="-1" onpointermove={() => hover_ok = true}>
		{#each groups as group, gi}
			{#if gi > 0}
				<div class="command-separator" role="separator"></div>
			{/if}
			<div class="command-group" role="group" aria-label={group.title || undefined}>
				{#if group.title}
					<div class="command-group-title">{group.title}</div>
				{/if}
				{#each group.items as entry}
					<button class="command-item" type="button" role="option" tabindex="-1" aria-selected={entry.index === active} data-active={entry.index === active ? '' : undefined} onpointerenter={() => hover(entry.index)} onclick={() => run(entry.item)}>
						{#if entry.item.icon}{@html glyphs[entry.item.icon]}{/if}
						<span>{entry.item.label}</span>
						{#if entry.item.shortcut}<span class="kbd-group"><kbd class="kbd">⌘</kbd><kbd class="kbd">{entry.item.shortcut}</kbd></span>{/if}
					</button>
				{/each}
			</div>
		{/each}
		{#if query.trim() && !filtered.length}
			<p class="command-empty">No matches</p>
		{/if}
	</div>
</dialog>