Components Controls & Input CommandPalette

CommandPalette

Spotlight-style fuzzy command launcher.

Live demo

01

Default Β· 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.

isOpen = false

Grouped + 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
CommandPalette.svelte
<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

03

What 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 for
  • description Γ— 1 β€” secondary context
  • keywords Γ— 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 CLOSED

Props 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 $effect return.
  • svelte/reactivity β€” SvelteMap for 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 + CommandPaletteProps

API

04
PropTypeDefaultDescription
itemsCommandPaletteItem[]β€”Required. Each item: id, label, optional description, icon, group, shortcut, keywords, onSelect, href.
isOpenbooleanfalseBindable open state.
placeholderstring'Type a command or search…'Search input placeholder.
emptyMessagestring'No results found.'Shown when nothing matches the query.
shortcutKeystring'k'Key to combine with ⌘/Ctrl to open globally.
maxResultsnumber10Cap on visible results per render.
onSelect(item) => voidβ€”Fires when an item is activated.
onClose() => voidβ€”Fires when the palette closes.