Command palette
⌘K over your own actions - with recents, per-action shortcuts, and a run that can fail.
pnpm dlx shadcn-svelte@latest add https://coral.imlargo.dev/r/kit-command-palette.jsonnpx shadcn-svelte@latest add https://coral.imlargo.dev/r/kit-command-palette.jsonbunx --bun shadcn-svelte@latest add https://coral.imlargo.dev/r/kit-command-palette.jsonyarn dlx shadcn-svelte@latest add https://coral.imlargo.dev/r/kit-command-palette.jsonshadcn's command gives the list, the filtering and the arrow keys. What every project then writes
by hand is the rest of the palette: the global shortcut that opens it, an open flag, the actions
in groups, what happens while an action is in flight, and the recents that make the second use
faster than the first. The search box at the top of this site is that hand-written version.
Last action: — · recent: 0
<script lang="ts">
import FilePlusIcon from '@lucide/svelte/icons/file-plus';
import SearchIcon from '@lucide/svelte/icons/search';
import CommandPalette from '#lib/components/coral/kit/command-palette/command-palette.svelte';
import Shortcut from '#lib/components/coral/kit/shortcut/shortcut.svelte';
import type { CommandAction } from '#lib/components/coral/kit/command-palette/types.js';
let recent = $state<string[]>([]);
let last = $state('');
const actions: CommandAction[] = [
{
id: 'new-project',
label: 'New project',
description: 'Start from a template',
group: 'Create',
shortcut: 'alt+n',
keywords: ['scaffold', 'repository'],
run: () => (last = 'New project')
},
{
id: 'import',
label: 'Import from CSV',
group: 'Create',
keywords: ['spreadsheet', 'upload'],
run: () => (last = 'Import from CSV')
},
{
id: 'members',
label: 'Invite a teammate',
group: 'Team',
run: () => (last = 'Invite a teammate')
},
{
id: 'billing',
label: 'Billing',
description: 'Requires admin permissions',
group: 'Account',
disabled: true,
run: () => {}
},
{ id: 'theme', label: 'Toggle theme', group: 'Account', run: () => (last = 'Toggle theme') }
];
</script>
<div class="flex flex-col items-center gap-3">
<CommandPalette
{actions}
bind:recent
shortcut="alt+k"
placeholder="Type a command…"
emptyMessage="Nothing matches that."
recentLabel="Recent"
>
{#snippet trigger({ props })}
<button
{...props}
class="flex h-8 w-64 items-center gap-2 rounded-md border px-2.5 text-sm text-muted-foreground"
>
<SearchIcon class="size-4 shrink-0" />
<span>Search actions…</span>
<Shortcut keys="alt+k" class="ms-auto" />
</button>
{/snippet}
{#snippet action({ action })}
{#if action.group === 'Create'}<FilePlusIcon />{/if}
<span class="flex min-w-0 flex-col">
<span class="truncate">{action.label}</span>
{#if action.description}
<span class="truncate text-xs text-muted-foreground">{action.description}</span>
{/if}
</span>
{#if action.shortcut}
<Shortcut keys={action.shortcut} class="ms-auto" />
{/if}
{/snippet}
</CommandPalette>
<p class="text-sm text-muted-foreground">
Last action: {last || '—'} · recent: {recent.length}
</p>
</div>What Coral adds
- The shortcut, both ways: one combo opens and closes it, in
kit/shortcutsyntax, somod+kis Command on a Mac and Control elsewhere. - Actions carry their own shortcut. Drawn beside the action, and bound for as long as the palette is mounted, so the combo is true everywhere and not only inside the list.
- Recents, lifted not reordered: recently run actions move into a group at the top; everything
else stays exactly where it was, because a list that re-sorts itself defeats the muscle memory a
palette exists for.
recentis bindable: where it is stored is the project's to decide. - Running can fail.
runmay be async: the row reports that it is working, and returningfalseor throwing keeps the palette open, which is where the error can still be read. - Search by things you do not show.
keywordsare matched but never rendered.
Waiting, and failing
—
<script lang="ts">
import CommandPalette from '#lib/components/coral/kit/command-palette/command-palette.svelte';
import { Button } from '#lib/components/ui/button/index.js';
import type { CommandAction } from '#lib/components/coral/kit/command-palette/types.js';
let open = $state(false);
let message = $state('');
let attempts = 0;
const actions: CommandAction[] = [
{
id: 'deploy',
label: 'Deploy to production',
description: 'Fails the first time',
group: 'Deploy',
run: async () => {
message = '';
await new Promise((resolve) => setTimeout(resolve, 900));
attempts++;
if (attempts % 2 === 1) {
message = 'The build server refused the deploy. Try again.';
// Exactly `false` keeps the palette open, with the message still on screen.
return false;
}
message = 'Deployed.';
}
},
{
id: 'rollback',
label: 'Roll back to the previous deployment',
group: 'Deploy',
run: () => (message = 'Rolled back.')
}
];
</script>
<div class="flex flex-col items-center gap-3">
<Button variant="outline" onclick={() => (open = true)}>Open the palette</Button>
<CommandPalette {actions} bind:open shortcut="" placeholder="Search…">
{#snippet footer()}
<div class="border-t px-3 py-2 text-xs text-muted-foreground">
{message || 'Enter runs the selected action.'}
</div>
{/snippet}
</CommandPalette>
<p class="text-sm text-muted-foreground" role="status">{message || '—'}</p>
</div>The same rule as confirm dialog and action button: return false, or throw, to stay put. An action is
remembered as recent whether or not it succeeded: the reader reached for it, which is what the list
is a record of.
Server-side search
Pass onsearch and the primitive's own filtering is switched off, because the list becomes the
server's answer and filtering it again locally would hide rows it meant to return. Debounce it with searchDebounce, and show loading while the request is out.
One thing to know about mod+k
Off a Mac, mod+k is Ctrl+K, and the command primitive reads Ctrl+K as "previous item" in its vim
bindings, cancelling the key. A palette bound that way opens and then refuses to close on the same
combo, on every machine that is not a Mac.
So when a combo the palette binds would be swallowed, those bindings give way to it. Set vimBindings to force the question either way.
Import
Composes kit/shortcut, which the install brings with it.
<script lang="ts">
import CommandPalette from '#lib/components/coral/kit/command-palette/command-palette.svelte';
</script>Actions
type CommandAction = {
id: string; // stable: it is what `recent` remembers
label: string;
description?: string;
keywords?: string[]; // matched, never shown
group?: string; // heading to file it under
shortcut?: string; // drawn, and bound while mounted
disabled?: boolean; // stays listed, because hiding it reads as "it does not exist"
run: () => unknown; // async-aware; `false` or throw keeps the palette open
};Props
Everything the shadcn command dialog accepts stays available: title, description, portalProps.
On top of that:
| Prop | Type | Default | Description |
|---|---|---|---|
actions | CommandAction[] | - | Everything the palette can do. Required. |
open | boolean | false | Bindable. |
shortcut | string | mod+k | Opens and closes it. '' binds nothing. |
bindActionShortcuts | boolean | true | Binds each action's own shortcut. |
recent | string[] | [] | Bindable. Ids, most recent first. |
maxRecent | number | 5 | How many are lifted to the top. |
recentLabel | string | Recent | Heading for the lifted group. |
search | string | '' | Bindable. |
onsearch | (term: string) => void | - | Server-side search. Switches off local filtering. |
searchDebounce | number | 0 | Milliseconds of quiet typing before onsearch. |
loading | boolean | false | A loading row in place of the list. |
onrun | (action: CommandAction) => void | - | An action ran and did not refuse. |
onerror | (error, action) => void | - | Receives what an action threw. |
vimBindings | boolean | unless a combo clashes | The primitive's Ctrl+N/P/J/K navigation. |
placeholder | string | Type a command or search... | Shown in the search box. |
emptyMessage | string | No results found. | Shown when nothing matches. |
class, listClass | string | - | Merged onto the dialog, and onto the list. |
trigger | Snippet<[{ props, open }]> | - | The element that opens it. |
action | Snippet<[{ action, pending }]> | - | Replaces each row's body. |
indicator, empty, footer | Snippet | - | Loading row, empty state, footer. |
Accessibility
The dialog, the combobox semantics, the arrow keys and the focus trap are the primitive's, including
the visually hidden title and description every dialog needs. What is added here is the trigger's aria-haspopup, aria-expanded and aria-keyshortcuts, and shortcut glyphs that are read out by
name rather than as "place of interest sign." See shortcut.
The search box also carries aria-controls and aria-activedescendant, which the primitive cannot
derive here, so the highlighted action is announced. While results load the list is hidden and the
indicator sits beside it, since a listbox may only own options, and a disabled action is dimmed.
A disabled action stays in the list. Hiding it is how a reader concludes the feature does not exist, rather than that they cannot use it right now.
actions.ts
group, remember and searchValue are pure and exported, so the same ordering can drive a
different surface (a menu, a toolbar, a mobile sheet):
import { group, remember } from '#lib/components/coral/kit/command-palette/actions.js';
group(actions, { recent: ['settings'], maxRecent: 3 });
remember(['a', 'b'], 'b'); // ['b', 'a'] - moved, not duplicated