CommandPalette
Keyboard-driven command palette with search, grouped results, and arrow-key navigation. Built on the Dialog primitive.
Playground
The global hotkey (Cmd+K / Ctrl+K by default) is turned off in this demo, because the docs site already uses that key for its search.
<script lang="ts">
import { CommandPalette, FilePlusIcon, SaveIcon, SearchIcon, SettingsIcon, LogOutIcon } from '@urbicon-ui/blocks';
let open = $state(false);
const items = [
{ label: 'New File', shortcut: 'Ctrl+N', category: 'File', icon: FilePlusIcon },
{ label: 'Save', shortcut: 'Ctrl+S', category: 'File', icon: SaveIcon },
{ label: 'Search', shortcut: 'Ctrl+F', category: 'Edit', icon: SearchIcon },
{ label: 'Settings', category: 'Navigation', icon: SettingsIcon },
{ label: 'Sign Out', category: 'Account', icon: LogOutIcon }
];
</script>
<CommandPalette
bind:open
{items}
placeholder="Type a command or search…"
/>01 Examples
Basic
<Button variant="outlined" intent="neutral" onclick={() => (basicOpen = true)}>
Open Command Palette
</Button>
<CommandPalette
bind:open={basicOpen}
items={fileCommands}
onSelect={(item) => (selectedAction = item.label)}
placeholder="Type a command or search..."
shortcut={false}
/>
{#if selectedAction}
<p class="text-text-secondary mt-3 text-sm">
Last action: <strong class="text-text-primary">{selectedAction}</strong>
</p>
{/if}Minimal (no icons, no shortcuts)
<Button variant="outlined" intent="neutral" onclick={() => (minimalOpen = true)}>
Quick Navigation
</Button>
<CommandPalette
bind:open={minimalOpen}
items={simpleCommands}
placeholder="Where do you want to go?"
shortcut={false}
/>With Disabled Items
<Button variant="outlined" intent="neutral" onclick={() => (iconsOpen = true)}>
Edit Commands
</Button>
<CommandPalette
bind:open={iconsOpen}
items={disabledCommands}
placeholder="Search edit commands..."
emptyText="No matching commands."
shortcut={false}
/>Trigger with Shortcut Badge
<button
class="border-border-default bg-surface-base text-text-tertiary hover:border-border-emphasis hover:text-text-secondary rounded-modify flex items-center gap-2 border px-4 py-2.5 text-sm shadow-[var(--blocks-shadow-sm)] transition-all"
onclick={() => (customOpen = true)}
>
<SearchIcon class="h-4 w-4" />
Search commands...
<Badge variant="outlined" intent="neutral" size="sm" class="ml-8">
<kbd class="text-3xs font-mono">Ctrl+K</kbd>
</Badge>
</button>
<CommandPalette
bind:open={customOpen}
items={fileCommands}
placeholder="Type a command..."
shortcut={false}
/>02 Customization
Styled via slotClasses
<Button variant="outlined" intent="neutral" onclick={() => (brandedOpen = true)}>
Branded Palette
</Button>
<CommandPalette
bind:open={brandedOpen}
items={simpleCommands}
placeholder="Search..."
shortcut={false}
slotClasses={{
wrapper: 'border-violet-500/30',
itemHighlighted: 'bg-violet-100 text-violet-700',
groupLabel: 'text-violet-500'
}}
/>Custom Rows
<Button variant="outlined" intent="neutral" onclick={() => (customRowOpen = true)}>
Custom Rows
</Button>
<CommandPalette
bind:open={customRowOpen}
items={simpleCommands}
placeholder="Search..."
shortcut={false}
>
{#snippet customItem(item, highlighted)}
<span class="flex min-w-0 flex-1 items-center justify-between gap-2">
<span class="truncate">{item.label}</span>
{#if highlighted}
<Badge purpose="tag" variant="soft" size="xs">Enter</Badge>
{/if}
</span>
{/snippet}
</CommandPalette>No Footer
<Button variant="outlined" intent="neutral" onclick={() => (compactOpen = true)}>
Compact Palette
</Button>
<CommandPalette
bind:open={compactOpen}
items={disabledCommands}
placeholder="Quick search..."
showFooter={false}
shortcut={false}
/>03 Accessibility
ARIA Combobox Pattern
The search input uses role="combobox" with aria-expanded, aria-controls, and aria-activedescendant linking to the highlighted
option. Results use role="listbox" with role="option" on each item.
Keyboard
Cmd+K / Ctrl+K to open (configurable). ↑ ↓ to navigate — the list wraps at both ends — Home / End for the first and last item, Enter to select, Escape to close. The highlighted item scrolls into view automatically.
Focus Management
Inherits focus trap from Dialog. The search input receives focus automatically when the
palette opens. Disabled items are skipped during keyboard navigation and have aria-disabled="true".
04 API Reference
17 propsProp | Type | Default | Description | |
|---|---|---|---|---|
items required | CommandPaletteItem[] | — | Items to display. Grouped automatically by category. | |
class | string | — | Additional CSS classes on the root wrapper. | |
customEmpty | Snippet<[query: string]> | — | Custom empty-state renderer. Receives the current query string. | |
customItem | Snippet<[item: CommandPaletteItem, highlighted: boolean, index: number, select: () => void]> | — | Replace the default per-row rendering. Receives the item, whether its row
is highlighted, the item's flat index into the filtered list, and a
select callback (call it to run the same selection the row's click runs;
it is a no-op on a disabled item).
**Render visible content only — nothing focusable, nothing interactive.**
A <button> or <a> inside the row is nested-interactive HTML on the
role="option" container, a tab stop inside the Dialog's focus trap that
the input's keyboard handling never reaches, and a click that bubbles into
the row's own, so onSelect fires twice. select is for a snippet that
dispatches from its own non-interactive logic — a pointer gesture on the
content, say — and for parity with Select's toggle.
The outer <div role="option"> container is still owned by CommandPalette:
its id is what the input's aria-activedescendant points at, its
data-command-palette-selected attribute is what the scroll-into-view
query finds, and it carries the row's click and hover-highlight.
Styling the row is slotClasses.item plus the state slot
(itemHighlighted, itemDisabled or itemDefault), which reach the
container here as well; the itemIcon, itemText, itemLabel,
itemExcerpt and itemShortcut slots style the default contents and go
unused under customItem.
Positional args: (item, highlighted, index, select). | |
emptyText | string | 'No results found.' | Message shown when the filter returns no results. | |
filter | (item: CommandPaletteItem, query: string) => boolean | — | Custom filter function. Receives each item and the current query.
Return true to keep the item. When omitted, a case-insensitive
label + category substring match is used. | |
onOpenChange | (open: boolean) => void | — | Fired when the open state changes (close via Escape, backdrop, or selection). | |
onSelect | (item: CommandPaletteItem) => void | — | Fired when an item is selected via click or Enter. | |
open | boolean | false | Controls visibility. Supports bind:open. | |
placeholder | string | 'Search...' | Placeholder text for the search input. | |
preset | string | — | Apply a named preset registered via <BlocksProvider presets={{ CommandPalette: {...} }}>.
Prefer this over class overrides when the requested look falls outside the
semantic intent palette — presets keep hover/active/dark-mode logic coherent
and make the custom look reusable across the project. | |
query | string | '' | Current search text. Supports bind:query. Reset to '' whenever the
palette opens.
For async or remote search, watch the bound query, fetch your own
results, and pass them back via items with filter={() => true} so the
built-in label match does not filter them a second time. | |
shortcut | false | 'mod+k' | Register a global keyboard shortcut that toggles the palette. mod is
Cmd on macOS and Ctrl elsewhere. Set to false to disable. | |
showFooter | boolean | true | Show keyboard-shortcut hints in the footer. | |
size | CommandPaletteVariants['size'] | 'md' | Maximum width of the palette panel. | |
slotClasses | Partial<Record<CommandPaletteSlots, string>> | — | Per-slot class overrides. An option row is built from four sources in
order — the library's item classes, the library's classes for the row's
state (itemHighlighted when selected, itemDisabled when the item is
disabled, itemDefault otherwise), then your item entry, then your entry
for that state slot. Each source displaces the earlier ones per Tailwind
bucket, so an item entry that collides with a state class *removes* it:
slotClasses={{ item: 'bg-white' }} also drops the selected row's
bg-primary-subtle. Write the state slot as well to keep both. | |
unstyled | boolean | false | Strip all default styles, the modal Dialog's included. |
05 Types
Local type definitions used by this component.
Name | Kind | Category | Used by | Description | |
|---|---|---|---|---|---|
CommandPaletteItem | interface | helper | 1 | A single item in the command palette. | |
CommandPaletteProps | interface | props | 0 | Props for the CommandPalette component. | |
IconComponent | type | helper | 0 | — | |
CommandPaletteSlots | type | variant | 0 | Slot names derived from the tv() config above — single source of truth for slotClasses. | |
CommandPaletteVariants | type | variant | 0 | — | |
IconProps | interface | props | 0 | — | |
SlotNames | type | helper | 0 | Extracts the slot-name union from a slotted tv() config function — the
companion to VariantProps. The slot-mode overload returns
(props?) => { [K in keyof S]: SlotFn }, so keyof ReturnType<T> is exactly
the set of slot names a component declares in tv({ slots: … }).
Use it to type a component's slotClasses prop from the single source of
truth (its *.variants.ts) instead of hand-maintaining a parallel union
that silently drifts when a slot is added or renamed: | |
VariantProps | type | helper | 0 | — |
06 Installation
Import
import { CommandPalette } from '@urbicon-ui/blocks';
import type { CommandPaletteItem } from '@urbicon-ui/blocks';