Navigation Command Menu
<MultiSelect /> can implement a navigation command menu in 70 lines of code (50 without styles).
svelte<script lang="ts">
import { goto } from '$app/navigation'
import { resolve } from '$app/paths'
import type { Pathname } from '$app/types'
import { CommandMenu } from '$lib'
import { routes } from '../../index'
const resolve_path = resolve as (path: Pathname) => string
const actions = routes.map(({ route }) => ({
label: route,
action: () => goto(resolve_path(route)),
}))
</script>
<CommandMenu {actions} triggers={[`n`]} />Site Search with Pagefind
PageSearch wraps CommandMenu with full-text search over statically generated pages. fallback_actions are matched locally on every keystroke, so known routes show up without
waiting on the index (and remain the only results when the index is absent). Install pagefind as a development dependency, then index the rendered site after the application
build. Run this script before previewing or deploying:
{
"scripts": {
"build:site": "vite build && pagefind --site build"
}
} Open the documentation search with cmd/ctrl+j.
svelte<script lang="ts">
import { goto } from '$app/navigation'
import { asset, resolve } from '$app/paths'
import type { Pathname } from '$app/types'
import { PageSearch } from '$lib'
import { routes } from '../../index'
const resolve_path = resolve as (path: Pathname) => string
const fallback_actions = routes.map(({ route }) => ({
label: route,
action: () => goto(resolve_path(route)),
}))
</script>
<PageSearch
{fallback_actions}
navigate={goto}
strip_html_suffix
pagefind_path={asset(`/pagefind/pagefind.js`)}
triggers={[`j`]}
aria_label="Search documentation"
/>
<p>Open the documentation search with <kbd>cmd/ctrl+j</kbd>.</p>The navigate callback receives the selected result’s query, label, and description as its second argument. A persistent layout can carry the query across client-side
navigation and apply highlight_matches to the destination content:
<script lang="ts">
import { afterNavigate, goto } from '$app/navigation'
import { highlight_matches } from 'svelte-multiselect/attachments'
import type { PageSearchNavigateDetails } from 'svelte-multiselect/types'
let highlight_query = $state(``)
const navigate = async (url: string, { query }: PageSearchNavigateDetails) => {
await goto(url)
highlight_query = ``
queueMicrotask(() => (highlight_query = query))
}
afterNavigate(() => (highlight_query = ``))
</script>
<main
{@attach highlight_matches({
query: highlight_query,
css_class: `site-search-match`,
duration_ms: 8000,
})}
>
...
</main>
<style>
:global(::highlight(site-search-match)) {
background: gold;
color: inherit;
}
</style> See the attachments demo for highlight_matches options and effects.
Stemmed Pagefind results may have no exact substring.
Shortcuts, Descriptions & Recent Actions
Actions can carry a description, metadata, badge, keywords, shortcut, and disabled state. The default filter searches all visible fields plus keywords and
supports multiple terms. Shortcuts render as ⌘-style key hints and trigger
globally while the menu is closed unless global_shortcuts={false}. Pass recent_actions_key to persist triggered actions to localStorage and rank them first
when the menu reopens.
CommandMenu.sveltesource codesvelte
<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 { CmdAction, MultiSelectProps } from './types' import { cmd_action_matches, chain_handlers, format_cmd_metadata, matches_shortcut, split_shortcut, } from './utils' // MultiSelect's option snippet param (option + idx/selected/active/disabled) type OptionSnippetParams = Parameters< NonNullable<MultiSelectProps<Action>[`option`]> >[0] type AddParams = Parameters<NonNullable<MultiSelectProps<Action>[`onadd`]>>[0] type DialogEvent = Parameters< NonNullable<HTMLAttributes<HTMLDialogElement>[`oncancel`]> >[0] let { actions, activeIndex: active_idx = $bindable(null), activeOption: active_option = $bindable(null), triggers = [`k`], close_keys = [`Escape`], fade_duration = 200, dialog_style = ``, open = $bindable(false), dialog = $bindable(null), input = $bindable(null), aria_label = `Command menu`, filterFunc: filter_func, fuzzy = true, inputProps: input_props, input_aria_label = aria_label === `Command menu` ? `Search commands` : aria_label, matchingOptions: matching_actions = $bindable([]), noMatchingOptionsMsg: no_matching_options_msg = `No matching commands`, onadd, onkeydown, option: option_snippet, placeholder = `Type a command…`, searchText: search_text = $bindable(``), dialog_props, global_shortcuts = true, recent_actions_key = null, max_recent = 20, ...rest }: Omit< ComponentProps<typeof MultiSelect<Action>>, `activeOptionFallbackKey` | `autoActiveFirstOption` | `key` | `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 input_aria_label?: string aria_label?: string placeholder?: string dialog_props?: HTMLAttributes<HTMLDialogElement> // run action.shortcut hotkeys globally while the menu 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 recent_limit = $derived( Number.isFinite(max_recent) ? Math.max(0, Math.floor(max_recent)) : 20, ) const action_id_counts = $derived.by(() => { const counts = new Map<string, number>() for (const action of actions) { const action_id = get_action_id(action) counts.set(action_id, (counts.get(action_id) ?? 0) + 1) } return counts }) const action_ids_are_unique = $derived(action_id_counts.size === actions.length) const get_action_signature = (action: CmdAction): string => JSON.stringify([ action.id, action.label, action.description, action.badge, action.disabled, action.group, action.metadata, action.keywords, action.shortcut, ]) const action_id_is_unique = (action: Action) => action_id_counts.get(get_action_id(action)) === 1 const get_action_key = (action: Action) => action_id_is_unique(action) ? get_action_id(action) : action.action const get_action_fallback_key = (action: Action) => action_id_is_unique(action) ? get_action_id(action) : get_action_signature(action) // load persisted recents (client-only since $effect doesn't run during SSR) $effect(() => { if (!recent_actions_key || !action_ids_are_unique) return try { const stored: unknown = JSON.parse(localStorage.getItem(recent_actions_key) ?? `[]`) recent_action_ids = Array.isArray(stored) ? stored.filter((recent) => typeof recent === `string`).slice(0, recent_limit) : [] } catch { recent_action_ids = [] // ignore corrupted storage } }) function record_recent(action: Action) { if (!recent_actions_key || !action_ids_are_unique) 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, recent_limit) try { localStorage.setItem(recent_actions_key, JSON.stringify(recent_action_ids)) } catch { // Storage full or unavailable; recents do not persist. } } // recently triggered actions first (most recent on top), rest keep original order const sorted_actions = $derived.by(() => { if (!recent_actions_key || !action_ids_are_unique || 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()) ) }) // Dynamic actions may contain metadata not present in the static action list. const has_action_meta = $derived( Boolean(rest.loadOptions) || actions.some( (action) => action.shortcut || action.description || action.badge || action.metadata, ), ) $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 close_menu() { open = false active_idx = null active_option = null search_text = `` } 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() if (should_open) open = true else close_menu() return true } function handle_dialog_cancel(event: DialogEvent) { if (!close_keys.includes(`Escape`)) event.preventDefault() dialog_props?.oncancel?.(event) } function handle_window_keydown(event: KeyboardEvent) { const is_close_key = open && close_keys.includes(event.key) if (event.defaultPrevented && !is_close_key) return if (toggle(event)) return // run action hotkeys globally while the menu is closed if (open || !global_shortcuts) return const action = actions.find( (cmd_action) => !cmd_action.disabled && 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 menu'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 close_menu() } function trigger_action_and_close(params: AddParams) { const { option } = params if (!option?.action || option.disabled) return record_recent(option) close_menu() option.action(option.label) onadd?.(params) } </script> <svelte:window onkeydown={handle_window_keydown} onclick={close_if_outside} /> {#snippet action_item({ option }: OptionSnippetParams)} {@const metadata = format_cmd_metadata(option.metadata)} <span class="cmd-action"> <span class="cmd-label"> {option.label} {#if option.description} <small class="cmd-description">{option.description}</small> {/if} </span> {#if metadata || option.badge || option.shortcut} <span class="cmd-meta"> {#if metadata}<small class="cmd-metadata">{metadata}</small>{/if} {#if option.badge}<span class="cmd-badge">{option.badge}</span>{/if} {#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> {/if} </span> {/snippet} {#if open} <dialog bind:this={dialog} transition:fade={{ duration: fade_duration }} style={dialog_style} aria-label={aria_label} {...dialog_props} onclose={chain_handlers(close_menu, dialog_props?.onclose)} oncancel={handle_dialog_cancel} > <MultiSelect {...rest} options={sorted_actions} bind:activeIndex={active_idx} bind:activeOption={active_option} activeOptionFallbackKey={get_action_fallback_key} autoActiveFirstOption bind:input bind:matchingOptions={matching_actions} bind:searchText={search_text} filterFunc={filter_func ?? ((action, search) => cmd_action_matches(action, search, fuzzy))} {fuzzy} inputProps={{ 'aria-label': input_aria_label, ...input_props }} noMatchingOptionsMsg={no_matching_options_msg} {placeholder} key={get_action_key} onadd={trigger_action_and_close} onkeydown={(event) => { toggle(event) onkeydown?.(event) }} option={option_snippet ?? (has_action_meta ? action_item : undefined)} --sms-bg="var(--sms-options-bg)" --sms-width="var(--cmd-width, min(38rem, 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-meta { display: flex; min-width: 0; flex-shrink: 0; align-items: center; gap: 0.5em; } .cmd-metadata { max-width: var(--cmd-metadata-max-width, 14em); overflow: hidden; opacity: 0.6; text-overflow: ellipsis; white-space: nowrap; } .cmd-badge { padding: 0.05em 0.4em; border-radius: 999px; background: var(--cmd-badge-bg, color-mix(in srgb, currentColor 10%, transparent)); opacity: 0.7; font-size: var(--cmd-badge-font-size, 0.7em); } .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-menu-z-index, 10); font-size: 2.4ex; } </style>PageSearch.sveltesource codesvelte
<script module lang="ts"> import type { CmdAction, LoadOptionsParams, LoadOptionsResult, PageSearchNavigateDetails, } from './types' import { slug_to_title } from './utils' type PagefindSubResult = { title: string; url: string; plain_excerpt: string } type PagefindResultData = { url: string plain_excerpt: string meta: Record<string, string> sub_results: PagefindSubResult[] } type PagefindResult = { id: string; data: () => Promise<PagefindResultData> } type PagefindApi = { search: (query: string) => Promise<{ results: PagefindResult[] } | null> } type PagefindSearchCache = { query: string results: Promise<PagefindResult[]> actions: CmdAction[] next_result_idx: number } type PagefindLoaderOptions = { load_pagefind?: () => Promise<PagefindApi> navigate?: (url: string, details: PageSearchNavigateDetails) => unknown pagefind_key?: string pagefind_path?: string transform_url?: (url: string) => string } const MAX_DESCRIPTION_LENGTH = 240 const strip_html_extension = (url: string): string => { const suffix_start = url.search(/[?#]/) const [path, suffix] = suffix_start < 0 ? [url, ``] : [url.slice(0, suffix_start), url.slice(suffix_start)] return `${path.replace(/\/index\.html$/, `/`).replace(/\.html$/, ``)}${suffix}` } // fresh object per call: the returned array is assigned into MultiSelect's $state, and // a shared one would hand every instance the same (deeply proxied) array const no_results = (): LoadOptionsResult<CmdAction> => ({ options: [], hasMore: false }) const decode_html_entities = (text: string): string => { const textarea = document.createElement(`textarea`) textarea.innerHTML = text return textarea.value } const page_title_from_url = (url: string): string => { const path = url .split(/[?#]/)[0] .replace(/(?:\/index)?\.html$/, ``) .replace(/\/+$/, ``) if (!path) return `Home` const final_segment = path.slice(path.lastIndexOf(`/`) + 1) return slug_to_title(decodeURIComponent(final_segment)) } const pagefind_result_to_actions = ( result_id: string, query: string, result: PagefindResultData, get_options: () => PagefindLoaderOptions, ): CmdAction[] => { const page_title = result.meta.title || page_title_from_url(result.url) const sections = result.sub_results.length ? result.sub_results : [undefined] return sections.map((section, section_idx) => { const source_url = section?.url ?? result.url const section_title = section?.title.trim() const label = section_title && section_title !== page_title ? `${page_title} › ${section_title}` : page_title const full_description = decode_html_entities( section?.plain_excerpt ?? result.plain_excerpt, ) .replaceAll(/\s+/g, ` `) .trim() const description = full_description.length > MAX_DESCRIPTION_LENGTH ? `${full_description.slice(0, MAX_DESCRIPTION_LENGTH - 1).trimEnd()}…` : full_description const id = `pagefind:${result_id}:${section_idx}:${source_url}` const action = () => { const { navigate, transform_url } = get_options() const current_url = transform_url?.(source_url) ?? source_url if (navigate) void navigate(current_url, { query, label, description }) else globalThis.location.assign(current_url) } return { id, label, description, action } }) } const create_pagefind_loader = (get_options: () => PagefindLoaderOptions) => { let pagefind_api_promise: Promise<PagefindApi> | undefined let search_cache: PagefindSearchCache | undefined let previous_pagefind_source: string | undefined let pagefind_generation = 0 return async ({ search, offset, limit, }: LoadOptionsParams): Promise<LoadOptionsResult<CmdAction>> => { const { load_pagefind, pagefind_key = ``, pagefind_path = `/pagefind/pagefind.js`, } = get_options() // Key the cache on the source, not on closure identity: an inline load_pagefind // arrow gets a fresh identity every render, and treating that as a source change // would re-import pagefind and re-run the search on each keystroke. Callers that // swap between custom loaders over different indexes distinguish them with // pagefind_key, since their identity alone can't tell one index from another. const pagefind_source = load_pagefind ? `custom-loader:${pagefind_key}` : pagefind_path if (pagefind_source !== previous_pagefind_source) { pagefind_api_promise = undefined search_cache = undefined previous_pagefind_source = pagefind_source pagefind_generation++ } const generation = pagefind_generation const load_api = load_pagefind ?? (async () => (await import(/* @vite-ignore */ pagefind_path)) as PagefindApi) const query = search.trim() // fallback_actions are handed to CommandMenu as static options, which match them // locally without waiting on Pagefind, so this loader only returns index hits if (!query) return no_results() try { if (offset === 0 || search_cache?.query !== query) { search_cache = { query, results: (async () => { const pagefind_api = await (pagefind_api_promise ??= load_api()) return (await pagefind_api.search(query))?.results ?? [] })(), actions: [], next_result_idx: 0, } } const cache = search_cache const page_results = await cache.results if (page_results.length === 0) return no_results() const target_count = offset + limit while ( cache.actions.length < target_count && cache.next_result_idx < page_results.length ) { const result_batch = page_results.slice( cache.next_result_idx, cache.next_result_idx + limit, ) cache.next_result_idx += result_batch.length const settled_results = await Promise.allSettled( result_batch.map(async (result) => pagefind_result_to_actions( result.id, query, await result.data(), get_options, ), ), ) cache.actions.push( ...settled_results.flatMap((result) => result.status === `fulfilled` ? result.value : [], ), ) } if (cache.actions.length === 0) return no_results() return { options: cache.actions.slice(offset, target_count), hasMore: cache.actions.length > target_count || cache.next_result_idx < page_results.length, } } catch { // only retire our own loader promise: the source may have switched while this // request was in flight, and the newer one has already installed its own if (generation === pagefind_generation) pagefind_api_promise = undefined return no_results() } } } </script> <script lang="ts"> import type { ComponentProps } from 'svelte' import CommandMenu from './CommandMenu.svelte' type Props = Omit< ComponentProps<typeof CommandMenu>, | `actions` | `dialog` | `input` | `loadOptions` | `max_recent` | `open` | `recent_actions_key` > & PagefindLoaderOptions & { batch_size?: number debounce_ms?: number dialog?: HTMLDialogElement | null // matched locally, so these stay searchable while the Pagefind index loads fallback_actions?: CmdAction[] input?: HTMLInputElement | null open?: boolean strip_html_suffix?: boolean } let { fallback_actions = [], load_pagefind, navigate, pagefind_key, pagefind_path, transform_url, strip_html_suffix = false, batch_size = 12, debounce_ms = 120, fuzzy = false, open = $bindable(false), dialog = $bindable(null), input = $bindable(null), ...rest }: Props = $props() const load_options = create_pagefind_loader(() => ({ load_pagefind, navigate, pagefind_key, pagefind_path, transform_url: (url) => { const normalized_url = strip_html_suffix ? strip_html_extension(url) : url return transform_url?.(normalized_url) ?? normalized_url }, })) </script> <CommandMenu actions={fallback_actions} bind:open bind:dialog bind:input {fuzzy} aria_label="Site search" loadOptions={{ fetch: load_options, debounceMs: debounce_ms, batchSize: Number.isFinite(batch_size) ? Math.max(1, Math.floor(batch_size)) : 12, }} placeholder="Search every page..." noMatchingOptionsMsg="No matching pages" {...rest} />