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 module lang="ts"> type CmdAction = { id?: string | number label: string action: (label: string) => void group?: string shortcut?: string description?: string } & Record<string, unknown> </script> <script lang="ts" generics="Action extends CmdAction = CmdAction"> 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, split_shortcut } from './utils' // MultiSelect's option snippet param (option + idx/selected/active/disabled) type OptionSnippetParams = Parameters< NonNullable<MultiSelectProps<Action>[`option`]> >[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_action_ids = $state<string[]>([]) const get_action_id = (action: CmdAction): string => `${action.id ?? action.label}` const can_track_recents = $derived( new Set(actions.map(get_action_id)).size === actions.length, ) // load persisted recents (client-only since $effect doesn't run during SSR) $effect(() => { if (!recent_actions_key || !can_track_recents) return try { const stored: unknown = JSON.parse(localStorage.getItem(recent_actions_key) ?? `[]`) recent_action_ids = Array.isArray(stored) ? stored.filter((rec) => typeof rec === `string`) : [] } catch { recent_action_ids = [] // ignore corrupted storage } }) function record_recent(action: Action) { if (!recent_actions_key || !can_track_recents) return const action_id = get_action_id(action) recent_action_ids = [ action_id, ...recent_action_ids.filter((recent_id) => recent_id !== action_id), ].slice(0, max_recent) try { localStorage.setItem(recent_actions_key, JSON.stringify(recent_action_ids)) } 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 || !can_track_recents || recent_action_ids.length === 0) return actions // drop stale persisted ids (actions removed/renamed since) so they don't // occupy low ranks and push real recents below non-recent actions const current_ids = new Set(actions.map(get_action_id)) const rank = new Map( recent_action_ids .filter((recent_id) => current_ids.has(recent_id)) .map((action_id, idx) => [action_id, idx]), ) return [...actions].toSorted( (left_action, right_action) => (rank.get(get_action_id(left_action)) ?? actions.length) - (rank.get(get_action_id(right_action)) ?? 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[] => split_shortcut(shortcut).map((part) => { const key_segment = part.trim().toLowerCase() // title-case unknown multi-char segments, upper-case single chars (empty stays empty) const title_case = key_segment.charAt(0).toUpperCase() + key_segment.slice(1) return ( key_symbols[key_segment] ?? (key_segment.length > 1 ? title_case : key_segment.toUpperCase()) ) }) // only swap in the custom option snippet when an action actually uses shortcut/description const has_action_meta = $derived( actions.some((action) => action.shortcut || action.description), ) $effect(() => { if (!open) return if (dialog && !dialog.open) { try { dialog.showModal() } catch { // showModal missing (older DOM impls) or dialog not in document dialog.setAttribute(`open`, ``) } } if (input && document.activeElement !== input) input.focus() }) function toggle(event: KeyboardEvent): boolean { const should_open = !open && triggers.includes(event.key) && (event.metaKey || event.ctrlKey) const should_close = open && close_keys.includes(event.key) if (!should_open && !should_close) return false event.preventDefault() open = should_open return true } function handle_window_keydown(event: KeyboardEvent) { if (toggle(event)) return // run action hotkeys globally while the palette is closed if (open || !global_shortcuts) return const action = actions.find((cmd_action) => matches_shortcut(event, cmd_action.shortcut), ) if (!action) return event.preventDefault() record_recent(action) action.action(action.label) } function close_if_outside(event: MouseEvent) { const target = event.target // dialog is null until the next flush after open is set, e.g. when a button's // click handler sets open=true and the same click bubbles up to window - don't // treat that click as outside. Element (not HTMLElement) so SVG targets count. if (!open || !dialog || !(target instanceof Element)) 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`) const listbox = listbox_id && document.querySelector(`#${CSS.escape(listbox_id)}`) if (listbox && listbox.contains(target)) return open = false } function trigger_action_and_close({ option }: { option: Action }) { if (!option?.action) return record_recent(option) 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} key={get_action_id} onadd={trigger_action_and_close} onkeydown={toggle} option={has_action_meta ? action_item : 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>