CommandPalette
Spotlight-style fuzzy command launcher.
Live demo
01Default Β· keyboard or click
Press βK (Mac) or Ctrl+K (Windows/Linux), or click the button below.
Controlled state Β· bind:isOpen
The bound boolean below is the single source of truth. Toggle it from anywhere and the palette obeys.
falseGrouped + keyword fuzzy-match
Four groups (File / Edit / View / Help) with keyword aliases. Try typing preferences β βSettingsβ matches via its hidden keyword. Or try hotkeys, magnify, or grep.
Implementation
02<script lang="ts">
import CommandPalette from '$lib/components/CommandPalette.svelte';
const items = [
{ id: 'new', label: 'New File', group: 'File', shortcut: 'βN' },
{ id: 'open', label: 'Open File', group: 'File', shortcut: 'βO' },
{ id: 'settings', label: 'Settings', group: 'App', keywords: ['preferences', 'config'] }
];
</script>
β
<CommandPalette {items} />CommandPalette wires CMD/Ctrl+K to a global keydown listener and traps focus inside the dialog while open. Each result is scored against the query (label > description > hidden keywords) using a tiny in-file fuzzy ranker that exits early once enough matches are found, so even hundreds of items stay snappy. Up/Down arrows roll over, Enter activates, Escape closes β the underlying state is a single bindable isOpen so parents can open the palette programmatically.
Logic explainer
03What Does It Do? (Plain English)
A keyboard-first overlay for searching and executing commands β VS Code's βK, Linear's command bar, Raycast on the web. The user presses βK (or Ctrl+K) anywhere in the app, an overlay appears with a search box, they type a few characters, fuzzy-matched commands appear scored by relevance and grouped by section, arrow keys move the highlight, Enter activates, Escape closes. The entire interaction happens without ever moving the hand off the keyboard.
Think of it like Spotlight on macOS or Cmd-Shift-P in your editor β a floating "what do you want to do?" prompt that's faster than navigating menus, and that turns the whole app's surface area into one autocomplete.
How It Works (Pseudo-Code)
state:
isOpen = bindable boolean
query = current search text
activeIndex = highlighted item in the flat list
previouslyFocused = element that was focused before the palette opened
on global keydown (Cmd/Ctrl + shortcutKey):
preventDefault
if isOpen: close() else: open()
open():
previouslyFocused = document.activeElement
isOpen = true
query = ''
activeIndex = 0
rAF β inputEl.focus() // wait for mount, then focus the search input
close():
isOpen = false
query = ''
rAF β previouslyFocused?.focus()
fire onClose()
derive filteredItems:
for each enabled item:
score = max(
fuzzyScore(label) * 2,
fuzzyScore(description) * 1,
max(fuzzyScore(kw) * 1.5 for kw in keywords)
)
drop score === 0
sort descending by score
slice to maxResults
derive groupedItems = flatItems grouped by item.group
derive flatItems = groupedItems.flatMap(g => g.items) // for keyboard indexing
events while open:
on input change (typing): activeIndex = 0 (jump back to top)
on ArrowDown: activeIndex = (activeIndex + 1) mod flatItems.length; scroll
on ArrowUp: activeIndex = (activeIndex - 1 + n) mod n; scroll
on Enter: selectItem(flatItems[activeIndex])
on Escape: close()
on Tab / Shift+Tab: focus trap cycles within dialog
on backdrop mousedown (target === backdrop): close()
selectItem(item):
if item.disabled: return
item.onSelect?.()
fire onSelect(item)
if item.href: window.location.href = item.href
close()The requestAnimationFrame calls around focus are critical β inputEl doesn't exist until the dialog has mounted (after isOpen becomes true and the next tick renders). rAF defers the focus call until after the DOM update, so inputEl?.focus() actually finds an element to focus.
The Core Concept: Fuzzy Scoring
The heart of the palette is fuzzyScore, a relevance-ranking function. The naΓ―ve approach is "include all items where the label contains the query as a substring", which misses common patterns like typing gpw to find Git Push (skipping characters), or prefs to find Settings β Preferences.
The two-tier algorithm:
1. Exact substring match (fast path):
if label.toLowerCase().includes(query.toLowerCase()):
return 100 + (50 if label starts with query else 0) - indexOf(query)Exact matches score 100+, with a 50-point bonus for prefix matches and a small penalty proportional to where the match starts. So "Save" scores 150 for query sav, while "Search Archives" scores ~100 for the same query β both match, but the prefix wins.
2. Character-by-character fuzzy:
walk query characters left-to-right
for each query char, find the next matching char in label
score += 10 + consecutive * 5 // bonus for sequential matches
if all query chars found: return score (typically 30-80)
else: return 0 (drop from results)So gpw against git push walks: find g at 0 (+10), find p at 4 (+10, consecutive broken), find w β not found, return 0. gpu against git push: g at 0 (+10), p at 4 (+10), u at 6 (+15 consecutive bonus) = 35. Match.
The per-field weighting:
labelΓ 2 β what the user is most likely searching fordescriptionΓ 1 β secondary contextkeywordsΓ 1.5 β hidden synonyms ("kbd", "shortcut", "keyboard" all match KbdShortcut)
Math.max(...) across the three fields means the best match wins, not the sum β a perfect description match doesn't outscore a partial label match.
Focus Trapping & XSS-Safe Highlighting
Two security/UX details worth calling out:
Focus trap. While open, Tab cycles within the dialog. The handler queries dialogEl.querySelectorAll('input, button, [tabindex]:not([tabindex="-1"])'), finds the first and last, and intercepts:
on Tab:
if Shift+Tab and active === first: preventDefault; last.focus()
if !Shift and active === last: preventDefault; first.focus()When the palette closes, focus returns to previouslyFocused β captured at open-time β so the user lands back where they were before the overlay.
XSS-safe match highlighting. A common mistake is to do {@html label.replaceAll(query, '<mark>...</mark>')} so matches are bolded. That's a script-injection vector if label comes from a user-supplied source. This component avoids @html entirely:
function getSegments(text, search):
idx = text.toLowerCase().indexOf(search.toLowerCase())
return [
{ text: text.slice(0, idx), highlight: false },
{ text: text.slice(idx, idx+len), highlight: true },
{ text: text.slice(idx + len), highlight: false }
]
template:
{#each getSegments(item.label, query) as seg}
{#if seg.highlight}<mark>{seg.text}</mark>{:else}{seg.text}{/if}
{/each}Each segment renders through Svelte's normal text interpolation, which escapes HTML. User-supplied labels can contain <script> tags and they'll display literally, not execute.
State Flow Diagram
ββββββββββββββββββββ
β CLOSED β
β isOpen=false β
βββββββββββ¬βββββββββ
β
Cmd/Ctrl + shortcutKey
β
βΌ
ββββββββββββββββββββ
β OPENING β
β snapshot focus β
β isOpen = true β
β rAF β focus β
β search input β
βββββββββββ¬βββββββββ
β
βΌ
ββββββββββββββββββββ
β OPEN β
β type query β β βββ fuzzy score β group β flat
β ArrowKeys move β β render
β highlight β
β Tab cycles β
β (focus trap) β
βββββββββββ¬βββββββββ
β
βββββββββββββββββββββΌββββββββββββββββββββββ
β β β
Enter on item Escape pressed backdrop click
β β β
βΌ βΌ βΌ
ββββββββββββββββββββ
β CLOSING β
β selectItem? β
β isOpen = false β
β rAF β restore β
β focus β
β onClose() fires β
ββββββββββββββββββββ
β
βΌ
back to CLOSEDProps Reference
| Prop | Type | Default | Description |
|---|---|---|---|
items |
CommandPaletteItem[] |
required | Available commands. Each item: { id, label, description?, group?, icon?, shortcut?, keywords?, href?, onSelect?, disabled? }. |
placeholder |
string |
'Type a command or search...' |
Search input placeholder. |
emptyMessage |
string |
'No results found.' |
Message shown when the filter returns no matches. |
shortcutKey |
string |
'k' |
Single character that, with Cmd/Ctrl, opens the palette. |
maxResults |
number |
10 |
Hard cap on rendered results β keeps the DOM lean for large items arrays. |
isOpen |
boolean |
false |
Bindable open state β for parent-controlled show/hide. |
onSelect |
(item: CommandPaletteItem) => void |
undefined |
Fires when an item is selected (Enter or click). |
onClose |
() => void |
undefined |
Fires after the palette closes for any reason. |
class |
string |
'' |
Extra classes on the backdrop wrapper. |
Edge Cases
| Situation | Behaviour |
|---|---|
| User opens, types nothing, presses Enter | flatItems[0] is the highest-scored item with query='' (all items pass with score 1, so they keep their original order). It activates. |
Empty items array |
Empty state ("No results found.") renders. |
User has 500 items and types s |
Every item is scored; sort is O(n log n); slice to maxResults keeps the DOM at 10 rows. Fine for ~500 items. Beyond that, consider server-side filtering. |
Cmd+K is bound by the browser (e.g. Chrome's address bar) |
The handler calls preventDefault before the browser default fires, so the palette opens reliably. |
| User holds Cmd+K with palette already open | The handler toggles β the palette closes. Same shortcut, opposite direction. |
Item has href and onSelect |
Both run: onSelect first, then window.location.href = item.href. Order matters if onSelect does analytics. |
| User navigates to a result that opens a modal β modal also uses Escape | Escape closes the palette first (since it's already closed by the time the modal mounts). The two don't conflict. |
User has prefers-reduced-motion: reduce |
The 150 ms backdrop fade and palette slide-in are removed; the palette appears instantly. |
User-supplied label contains HTML |
Rendered as text via segment iteration; no @html. Tags display literally. |
User selects a disabled item |
selectItem short-circuits; nothing fires. (Disabled items are also filtered out of filteredItems, so this is defensive only.) |
Dependencies
- Svelte 5.x β
$state,$bindable,$derived.by,$effect, snippets. Global keydown listener is mounted/unmounted via the$effectreturn. svelte/reactivityβSvelteMapfor the group bucket (reactive Map so the derive tracks correctly).- Zero other external dependencies. Native search input, scoped CSS, inline search SVG.
File Structure
src/lib/components/CommandPalette.svelte # implementation
src/lib/components/CommandPalette.md # this file (rendered inside ComponentPageShell)
src/routes/commandpalette/+page.svelte # demo page
src/lib/types.ts # CommandPaletteItem + CommandPalettePropsAPI
04| Prop | Type | Default | Description |
|---|---|---|---|
items | CommandPaletteItem[] | β | Required. Each item: id, label, optional description, icon, group, shortcut, keywords, onSelect, href. |
isOpen | boolean | false | Bindable open state. |
placeholder | string | 'Type a command or searchβ¦' | Search input placeholder. |
emptyMessage | string | 'No results found.' | Shown when nothing matches the query. |
shortcutKey | string | 'k' | Key to combine with β/Ctrl to open globally. |
maxResults | number | 10 | Cap on visible results per render. |
onSelect | (item) => void | β | Fires when an item is activated. |
onClose | () => void | β | Fires when the palette closes. |