Nav Palette
You can use <MultiSelect /> to build a navigation palette in just 70 lines of code (50 without styles).
svelte<script lang="ts">
import { goto } from '$app/navigation'
import { CmdPalette } from '$lib'
import { routes } from '../index'
interface Action {
label: string
action: () => Promise<void>
}
const actions: Action[] = routes.map(({ route }) => ({
label: route,
action: () => goto(route),
}))
</script>
<CmdPalette {actions} />Shortcuts, Descriptions & Recent Actions
Actions can carry a shortcut (rendered as ⌘-style key hints and triggered globally while the palette is closed unless global_shortcuts={false}) and a description shown below the label. Pass recent_actions_key to persist triggered actions to localStorage and rank them first when the palette reopens.
Open with cmd/ctrl+p or press ctrl+shift+l / ctrl+shift+u anywhere on this page. Last triggered: none
svelte<script lang="ts">
import { CmdPalette } from '$lib'
let last_triggered = $state(``)
const actions = [
{
label: `Toggle theme`,
description: `Switch between light and dark mode`,
shortcut: `ctrl+shift+l`,
action: (label: string) => (last_triggered = label),
},
{
label: `Copy page URL`,
description: `Copy the current address to the clipboard`,
shortcut: `ctrl+shift+u`,
action: (label: string) => (last_triggered = label),
},
{ label: `Open settings`, action: (label: string) => (last_triggered = label) },
]
</script>
<CmdPalette
{actions}
triggers={[`p`]}
recent_actions_key="demo-recent-actions"
placeholder="Recently used actions float to the top..."
/>
<p>
Open with <kbd>cmd/ctrl+p</kbd> or press <kbd>ctrl+shift+l</kbd> /
<kbd>ctrl+shift+u</kbd> anywhere on this page. Last triggered:
<strong>{last_triggered || `none`}</strong>
</p>CmdPalette.sveltesource codesvelte
<script lang="ts" generics="Action extends { label: string; action: (label: string) => void; group?: string; shortcut?: string; description?: string } & Record<string, unknown> = { label: string; action: (label: string) => void; group?: string; shortcut?: string; description?: string }" > import type { ComponentProps } from 'svelte' import type { HTMLAttributes } from 'svelte/elements' import { fade } from 'svelte/transition' import MultiSelect from './MultiSelect.svelte' import type { MultiSelectProps } from './types' import { matches_shortcut } from './utils' // MultiSelect's option snippet type and its param (option + idx/selected/active/disabled) type OptionSnippet = NonNullable<MultiSelectProps<Action>[`option`]> type OptionSnippetParams = Parameters<OptionSnippet>[0] let { actions, triggers = [`k`], close_keys = [`Escape`], fade_duration = 200, dialog_style = ``, open = $bindable(false), dialog = $bindable(null), input = $bindable(null), aria_label = `Command palette`, placeholder = `Filter actions...`, dialog_props, global_shortcuts = true, recent_actions_key = null, max_recent = 20, ...rest }: Omit<ComponentProps<typeof MultiSelect<Action>>, `options`> & { actions: Action[] triggers?: string[] close_keys?: string[] fade_duration?: number // in ms dialog_style?: string // inline style for the dialog element open?: boolean dialog?: HTMLDialogElement | null input?: HTMLInputElement | null aria_label?: string placeholder?: string dialog_props?: HTMLAttributes<HTMLDialogElement> // run action.shortcut hotkeys globally while the palette is closed (default: true) global_shortcuts?: boolean // localStorage key to persist recently triggered actions. When set, recent // actions rank first in the dropdown (most recent on top). null = disabled recent_actions_key?: string | null max_recent?: number // cap on persisted recent actions (default: 20) } = $props() // === Recent actions (frecency ranking) === let recent_labels = $state<string[]>([]) // load persisted recents (client-only since $effect doesn't run during SSR) $effect(() => { if (!recent_actions_key) return try { const stored: unknown = JSON.parse( localStorage.getItem(recent_actions_key) ?? `[]`, ) recent_labels = Array.isArray(stored) ? stored.filter((rec) => typeof rec === `string`) : [] } catch { recent_labels = [] // ignore corrupted storage } }) function record_recent(label: string) { if (!recent_actions_key) return recent_labels = [label, ...recent_labels.filter((rec) => rec !== label)] .slice(0, max_recent) try { localStorage.setItem(recent_actions_key, JSON.stringify(recent_labels)) } catch { // storage full or unavailable - recents just won't persist } } // recently triggered actions first (most recent on top), rest keep original order const sorted_actions = $derived.by(() => { if (!recent_actions_key || recent_labels.length === 0) return actions const rank = new Map(recent_labels.map((label, idx) => [label, idx])) // actions.length as fallback keeps non-recent actions in original order (stable sort) return [...actions].toSorted((act1, act2) => (rank.get(act1.label) ?? actions.length) - (rank.get(act2.label) ?? actions.length) ) }) // === Shortcut display === // Map shortcut segments to display symbols (deterministic across platforms) const KEY_SYMBOLS: Record<string, string> = { meta: `⌘`, cmd: `⌘`, shift: `⇧`, alt: `⌥`, ctrl: `Ctrl`, enter: `↵`, backspace: `⌫`, delete: `⌦`, escape: `Esc`, arrowup: `↑`, arrowdown: `↓`, arrowleft: `←`, arrowright: `→`, } const format_shortcut = (shortcut: string): string[] => shortcut.split(`+`).map((part) => { const seg = part.trim().toLowerCase() // title-case unknown multi-char segments, upper-case single chars (empty stays empty) return KEY_SYMBOLS[seg] ?? (seg.length > 1 ? seg[0].toUpperCase() + seg.slice(1) : seg.toUpperCase()) }) // only swap in the custom option snippet when an action actually uses shortcut/description const has_action_meta = $derived( actions.some((act) => act.shortcut || act.description), ) $effect(() => { if (!dialog || !open || dialog.open) return try { dialog.showModal() } catch { // showModal missing (older DOM impls) or dialog not in document dialog.setAttribute(`open`, ``) } }) $effect(() => { if (open && input && document.activeElement !== input) input.focus() }) function toggle(event: KeyboardEvent) { const is_trigger = triggers.includes(event.key) && (event.metaKey || event.ctrlKey) if (is_trigger && !open) open = true else if (close_keys.includes(event.key) && open) open = false } function handle_window_keydown(event: KeyboardEvent) { toggle(event) // run action hotkeys globally while the palette is closed if (open || !global_shortcuts) return const action = actions.find((act) => matches_shortcut(event, act.shortcut)) if (action) { event.preventDefault() record_recent(action.label) action.action(action.label) } } function close_if_outside(event: MouseEvent) { const target = event.target if (!open || !(target instanceof HTMLElement)) return // backdrop clicks on a modal dialog have target === dialog, so close unless the click // is on this palette's MultiSelect (scoped inside the dialog) or its options list if (dialog?.contains(target) && target.closest(`div.multiselect`)) return const listbox_id = input?.getAttribute(`aria-controls`) if ( listbox_id && document.querySelector(`#${CSS.escape(listbox_id)}`)?.contains(target) ) { return } open = false } function trigger_action_and_close({ option }: { option: Action }) { if (!option?.action) return record_recent(option.label) option.action(option.label) open = false } </script> <svelte:window onkeydown={handle_window_keydown} onclick={close_if_outside} /> {#snippet action_item({ option }: OptionSnippetParams)} <span class="cmd-action"> <span class="cmd-label"> {option.label} {#if option.description} <small class="cmd-description">{option.description}</small> {/if} </span> {#if option.shortcut} <span class="cmd-shortcut" aria-hidden="true"> {#each format_shortcut(option.shortcut) as part, idx (idx)}<kbd>{part}</kbd>{/each} </span> {/if} </span> {/snippet} {#if open} <dialog bind:this={dialog} transition:fade={{ duration: fade_duration }} style={dialog_style} aria-label={aria_label} onclose={() => (open = false)} {...dialog_props} > <MultiSelect options={sorted_actions} bind:input {placeholder} onadd={trigger_action_and_close} onkeydown={toggle} option={has_action_meta // svelte2tsx types inline snippets as `() => ReturnType<Snippet>`, whose // unique-symbol brand doesn't unify with Snippet<[...]> (svelte#13670). A // plain assertion suffices since the shapes are otherwise identical. ? action_item as OptionSnippet : undefined} {...rest} --sms-bg="var(--sms-options-bg)" --sms-width="min(20em, 90vw)" --sms-max-width="none" --sms-placeholder-color="lightgray" --sms-padding="3pt" --sms-options-margin="1px 0" --sms-options-border-radius="0 0 1ex 1ex" /> </dialog> {/if} <style> .cmd-action { flex: 1; display: flex; align-items: center; justify-content: space-between; gap: 1em; min-width: 0; } .cmd-description { display: block; opacity: 0.6; font-size: var(--cmd-description-font-size, 0.75em); } .cmd-shortcut { flex-shrink: 0; display: flex; gap: 2pt; } .cmd-shortcut kbd { background: var( --cmd-kbd-bg, light-dark(rgba(0, 0, 0, 0.08), rgba(255, 255, 255, 0.15)) ); border-radius: 3pt; padding: 0 4pt; font-size: var(--cmd-kbd-font-size, 0.8em); line-height: 1.5; } :where(dialog) { position: fixed; top: 30%; left: 0; right: 0; bottom: auto; margin: 0 auto; border: none; padding: var(--cmd-dialog-padding, 6pt); background-color: transparent; display: flex; /* Let command results/popovers escape the dialog; default clipping hides suggestions. */ overflow: visible; color: light-dark(#222, #eee); z-index: var(--cmd-palette-z-index, 10); font-size: 2.4ex; } </style>