Command Palette

command-palette.svelte

A modal command palette / fuzzy finder with search filtering and shortcuts.

Demo

or press K
Last Executed: None
Usage
001 <script lang="ts">
002 import CommandPalette, { type CommandItem } from '$lib/components/command-palette.svelte';
003 import Button from '$lib/components/button.svelte';
004 import Kbd from '$lib/components/kbd.svelte';
005
006 let open = $state(false);
007 let lastAction = $state('None');
008
009 const commands: CommandItem[] = [
010 {
011 id: '1',
012 label: 'Format Document',
013 category: 'Editor',
014 shortcut: '⇧⌥F',
015 onselect: () => (lastAction = 'Formatted document')
016 },
017 {
018 id: '2',
019 label: 'Toggle File Tree',
020 category: 'View',
021 shortcut: '⌘B',
022 onselect: () => (lastAction = 'Toggled file tree')
023 },
024 {
025 id: '3',
026 label: 'Git: Push Changes',
027 category: 'Git',
028 shortcut: '⌘P',
029 onselect: () => (lastAction = 'Pushed changes to origin')
030 },
031 {
032 id: '4',
033 label: 'Switch Color Theme',
034 category: 'Preferences',
035 onselect: () => (lastAction = 'Opened theme picker')
036 },
037 {
038 id: '5',
039 label: 'Run Test Suite',
040 category: 'Terminal',
041 shortcut: '⌥T',
042 onselect: () => (lastAction = 'Ran test suite')
043 }
044 ];
045 </script>
046
047 <div class="flex flex-col gap-4">
048 <div class="flex items-center gap-4">
049 <Button onclick={() => (open = true)}>Open Command Palette</Button>
050 <span class="opacity-60 text-xs">
051 or press <Kbd>⌘</Kbd><Kbd>K</Kbd>
052 </span>
053 </div>
054
055 <div class="text-xs">
056 <span class="opacity-50">Last Executed: </span>
057 <span class="font-bold">{lastAction}</span>
058 </div>
059
060 <CommandPalette bind:open items={commands} />
061 </div>
062
command-palette.svelte
001 <script lang="ts" module>
002 import type { HTMLAttributes } from 'svelte/elements';
003
004 export type CommandItem = {
005 id: string;
006 label: string;
007 category?: string;
008 shortcut?: string;
009 onselect?: () => void;
010 };
011
012 export type CommandPaletteProps = Omit<HTMLAttributes<HTMLElement>, 'onselect'> & {
013 ref?: HTMLElement | null;
014 open?: boolean;
015 items?: CommandItem[];
016 placeholder?: string;
017 onselect?: (item: CommandItem) => void;
018 onclose?: () => void;
019 };
020 </script>
021
022 <script lang="ts">
023 let {
024 ref = $bindable(null),
025 open = $bindable(false),
026 items = [],
027 placeholder = 'Type a command or search...',
028 onselect,
029 onclose,
030 class: className = '',
031 ...restProps
032 }: CommandPaletteProps = $props();
033
034 let query = $state('');
035 let selectedIndex = $state(0);
036 let inputEl = $state<HTMLInputElement>();
037
038 const filteredItems = $derived(
039 items.filter(
040 (item) =>
041 item.label.toLowerCase().includes(query.toLowerCase()) ||
042 (item.category && item.category.toLowerCase().includes(query.toLowerCase()))
043 )
044 );
045
046 $effect(() => {
047 if (open) {
048 query = '';
049 selectedIndex = 0;
050 setTimeout(() => inputEl?.focus(), 30);
051 }
052 });
053
054 $effect(() => {
055 if (selectedIndex >= filteredItems.length) {
056 selectedIndex = Math.max(0, filteredItems.length - 1);
057 }
058 });
059
060 function select(item: CommandItem) {
061 open = false;
062 item.onselect?.();
063 onselect?.(item);
064 onclose?.();
065 }
066
067 function handleInputKey(e: KeyboardEvent) {
068 if (e.key === 'ArrowDown') {
069 e.preventDefault();
070 if (filteredItems.length > 0) {
071 selectedIndex = (selectedIndex + 1) % filteredItems.length;
072 }
073 } else if (e.key === 'ArrowUp') {
074 e.preventDefault();
075 if (filteredItems.length > 0) {
076 selectedIndex = (selectedIndex - 1 + filteredItems.length) % filteredItems.length;
077 }
078 } else if (e.key === 'Enter') {
079 e.preventDefault();
080 if (filteredItems[selectedIndex]) {
081 select(filteredItems[selectedIndex]);
082 }
083 } else if (e.key === 'Escape') {
084 e.preventDefault();
085 open = false;
086 onclose?.();
087 }
088 }
089 </script>
090
091 {#if open}
092 <div
093 class="palette-overlay"
094 onclick={() => {
095 open = false;
096 onclose?.();
097 }}
098 role="presentation"
099 >
100 <div
101 bind:this={ref}
102 class="palette-box {className}"
103 onclick={(e) => e.stopPropagation()}
104 role="dialog"
105 aria-modal="true"
106 aria-label="Command Palette"
107 {...restProps}
108 >
109 <div class="search-row">
110 <span class="prompt-glyph">&gt;</span>
111 <input
112 bind:this={inputEl}
113 type="text"
114 class="search-input"
115 {placeholder}
116 bind:value={query}
117 onkeydown={handleInputKey}
118 />
119 <span class="esc-badge">[ESC]</span>
120 </div>
121
122 <div class="results-list" role="listbox">
123 {#if filteredItems.length === 0}
124 <div class="empty-state">No matching commands.</div>
125 {:else}
126 {#each filteredItems as item, idx (item.id)}
127 {@const isSelected = idx === selectedIndex}
128 <button
129 type="button"
130 role="option"
131 aria-selected={isSelected}
132 class="item-row"
133 class:selected={isSelected}
134 onclick={() => select(item)}
135 >
136 <span class="item-icon">{isSelected ? '●' : ' '}</span>
137 <span class="item-label">{item.label}</span>
138 {#if item.category}
139 <span class="item-cat">{item.category}</span>
140 {/if}
141 {#if item.shortcut}
142 <span class="item-shortcut">[{item.shortcut}]</span>
143 {/if}
144 </button>
145 {/each}
146 {/if}
147 </div>
148
149 <div class="palette-footer">
150 <span class="footer-tip">↑↓ navigate</span>
151 <span class="footer-tip">↵ select</span>
152 <span class="footer-tip">esc dismiss</span>
153 </div>
154 </div>
155 </div>
156 {/if}
157
158 <style>
159 .palette-overlay {
160 position: fixed;
161 inset: 0;
162 z-index: 999;
163 background: color-mix(in srgb, var(--surface-base) 60%, transparent);
164 backdrop-filter: blur(2px);
165 display: flex;
166 justify-content: center;
167 align-items: flex-start;
168 padding-top: calc(var(--line) * 4);
169 box-sizing: border-box;
170 }
171
172 .palette-box {
173 width: 100%;
174 max-width: 64ch;
175 background: var(--surface-base);
176 color: var(--text-primary);
177 font-family: var(--font-family-mono);
178 font-size: var(--font-size);
179 line-height: var(--line);
180 box-shadow:
181 inset 0 0 0 1px var(--border-default),
182 0.5ch 0.5ch 0 0 var(--border-muted);
183 display: flex;
184 flex-direction: column;
185 box-sizing: border-box;
186 }
187
188 .search-row {
189 display: flex;
190 align-items: center;
191 height: var(--line);
192 line-height: var(--line);
193 padding: 0 1ch;
194 box-shadow: inset 0 -1px 0 0 var(--border-default);
195 }
196
197 .prompt-glyph {
198 color: var(--focus-ring);
199 font-weight: bold;
200 padding-right: 1ch;
201 }
202
203 .search-input {
204 flex: 1;
205 height: var(--line);
206 line-height: var(--line);
207 background: transparent;
208 color: inherit;
209 border: 0;
210 outline: 0;
211 font-family: inherit;
212 font-size: inherit;
213 }
214
215 .esc-badge {
216 opacity: 0.5;
217 font-size: 0.85em;
218 }
219
220 .results-list {
221 display: flex;
222 flex-direction: column;
223 max-height: calc(var(--line) * 8);
224 overflow-y: auto;
225 }
226
227 .item-row {
228 display: flex;
229 align-items: center;
230 height: var(--line);
231 line-height: var(--line);
232 padding: 0 1ch;
233 background: transparent;
234 color: inherit;
235 border: 0;
236 outline: 0;
237 cursor: pointer;
238 font-family: inherit;
239 font-size: inherit;
240 text-align: left;
241 width: 100%;
242 box-sizing: border-box;
243 }
244
245 .item-row.selected {
246 background: color-mix(in srgb, var(--focus-ring) 25%, transparent);
247 color: var(--text-primary);
248 font-weight: bold;
249 }
250
251 .item-icon {
252 width: 2ch;
253 color: var(--focus-ring);
254 flex-shrink: 0;
255 }
256
257 .item-label {
258 flex: 1;
259 white-space: nowrap;
260 overflow: hidden;
261 text-overflow: ellipsis;
262 }
263
264 .item-cat {
265 opacity: 0.5;
266 margin-right: 1ch;
267 font-size: 0.9em;
268 }
269
270 .item-shortcut {
271 color: var(--button-muted);
272 font-size: 0.9em;
273 }
274
275 .empty-state {
276 height: var(--line);
277 line-height: var(--line);
278 padding: 0 1ch;
279 opacity: 0.5;
280 font-style: italic;
281 }
282
283 .palette-footer {
284 display: flex;
285 gap: 2ch;
286 height: var(--line);
287 line-height: var(--line);
288 padding: 0 1ch;
289 box-shadow: inset 0 1px 0 0 var(--border-default);
290 background: color-mix(in srgb, var(--surface-base) 90%, var(--border-default) 10%);
291 opacity: 0.7;
292 font-size: 0.85em;
293 }
294
295 .footer-tip {
296 white-space: nowrap;
297 }
298 </style>
299