Command Palette
The <x-wirekit::command-palette> component creates a spotlight-style search modal activated by a keyboard shortcut (Cmd+K / Ctrl+K by default). It implements the WAI-ARIA Combobox pattern with grouped results and keyboard navigation.
Usage
Basic Setup
<x-wirekit::command-palette>
<x-wirekit::command-palette.group heading="Navigation">
<x-wirekit::command-palette.item href="/dashboard" icon="user">
Dashboard
</x-wirekit::command-palette.item>
<x-wirekit::command-palette.item href="/projects" icon="edit">
Projects
</x-wirekit::command-palette.item>
</x-wirekit::command-palette.group>
<x-wirekit::command-palette.group heading="Settings">
<x-wirekit::command-palette.item href="/settings/profile" icon="user">
Profile
</x-wirekit::command-palette.item>
<x-wirekit::command-palette.item href="/settings/appearance" icon="eye">
Appearance
</x-wirekit::command-palette.item>
</x-wirekit::command-palette.group>
<x-wirekit::command-palette.empty>
No results found.
</x-wirekit::command-palette.empty>
<x-slot:footer>
<span>Navigate with arrow keys</span>
</x-slot:footer>
</x-wirekit::command-palette>
Custom Hotkey
Change the keyboard shortcut that opens the palette:
{{-- Default: Cmd+K (macOS) / Ctrl+K (Windows/Linux) --}}
<x-wirekit::command-palette hotkey="cmd+k">
{{-- Alternative: Ctrl+/ --}}
<x-wirekit::command-palette hotkey="ctrl+/">
{{-- Forward slash only (like GitHub) --}}
<x-wirekit::command-palette hotkey="/">
Items with Shortcuts
Display keyboard shortcuts alongside items for discoverability:
<x-wirekit::command-palette.item href="/new" icon="plus" shortcut="Ctrl+N">
New Project
</x-wirekit::command-palette.item>
<x-wirekit::command-palette.item href="/search" icon="search" shortcut="Ctrl+F">
Search Files
</x-wirekit::command-palette.item>
Disabled Items
<x-wirekit::command-palette.item :disabled="true" icon="download">
Export (coming soon)
</x-wirekit::command-palette.item>
Disabled items are visually muted, skipped by keyboard navigation, and have aria-disabled="true".
Empty State
The command-palette.empty sub-component is shown when the search query matches no items:
<x-wirekit::command-palette.empty>
No results found. Try a different search term.
</x-wirekit::command-palette.empty>
Programmatic Open/Close
<!-- Alpine -->
<button x-on:click="$dispatch('wirekit-command-palette-show')">Open Search</button>
<button x-on:click="$dispatch('wirekit-command-palette-close')">Close Search</button>
<!-- Livewire (server-side) -->
$this->dispatch('wirekit-command-palette-show');
Width & Layout
The command palette has a fixed max-width of 32rem (512px) and is centered near the top of the viewport — following the established macOS Spotlight pattern.
The results list grows with content up to 18rem (288px) and scrolls beyond that. Two variables move it, and neither needs a build step:
| Variable | Default | What it moves |
|---|---|---|
--wk-command-palette-list-max-height |
18rem |
How tall the results list gets before it scrolls |
--wk-command-palette-offset-top |
— | How far below the top of the viewport the panel sits |
{{-- 1. A palette showing ranked search results wants more room than a short
command list does — roughly half the viewport reads well. --}}
<div style="--wk-command-palette-list-max-height: 50vh;">
<x-wirekit::command-palette>
{{-- items --}}
</x-wirekit::command-palette>
</div>
Set it on any ancestor; it inherits. For a whole-application change, put it on :root in your stylesheet.
Behavior
- Keyboard shortcut listener is registered globally on
documentand respects modifier keys - Focus trap (bundled ~3.8 KB) keeps keyboard navigation within the palette
- Scroll lock hides body scrollbar while the palette is open
- Teleport renders the palette at
<body>level for correct stacking - You own the result set -- the palette renders exactly the items you pass and does not filter them itself. Wire the search query to your own filtering (server-side or client-side) via the query event below
- Livewire SPA navigation (
wire:navigate) automatically closes open palettes - Transitions use scale + opacity for smooth open/close animation
Server-Side Search
The palette renders whatever items you give it — it never filters them itself.
To drive a live, server-backed search, listen for the wirekit-command-palette-query
event: it fires on every keystroke (debounced 300 ms) and carries the current
query in event.detail.query. Feed that query to Livewire, re-query your data,
and re-render the item list:
{{-- 1. Listen on the palette itself — the event is dispatched from its root. --}}
<x-wirekit::command-palette x-on:wirekit-command-palette-query="$wire.set('search', $event.detail.query)">
{{-- 2. Render the server-filtered results. Livewire swaps this list as `search` changes.
Give each item a stable id so the list keeps its identity across re-renders. --}}
@foreach ($results as $result)
<x-wirekit::command-palette.item :id="'cmd-' . $result->id" :href="$result->url">
{{ $result->title }}
</x-wirekit::command-palette.item>
@endforeach
</x-wirekit::command-palette>
// 3. In the Livewire component: re-query whenever `search` updates.
public string $search = '';
public function getResultsProperty(): Collection
{
// Debounced upstream by the event, so this runs at most ~3×/second while typing.
return Command::search($this->search)->take(20)->get();
}
From a Livewire component, use the full event name — Livewire rewrites
wire:<event> to x-on:<event> verbatim, so the wirekit- prefix has to be
there:
<x-wirekit::command-palette wire:wirekit-command-palette-query="search($event.detail.query)" />
The event bubbles, so a listener on any ancestor works too — but the palette itself is the natural place, and it is where it now fires from.
Keeping the list stable while it re-renders
Server-side search rebuilds the item list on every keystroke, which puts two things at risk. Both are handled, and one needs something from you:
- Item identity — pass an
id. The item id anchors the combobox'saria-activedescendant. Derived automatically from the item'shref, or its text when there is no href; pass:idexplicitly when you have a real key. Without a stable id the screen reader's announcement target moves on every keystroke and Livewire's morph patches every row. - Keyboard selection. When the results change, the highlight is cleared rather than left pointing at whatever now occupies that position — so Enter can never activate a row the reader never saw. When the same results merely re-render, the highlight is restored (morph strips it, because the highlight lives only in the browser).
Props
<x-wirekit::command-palette>
| Prop | Type | Default | Description |
|---|---|---|---|
hotkey |
string |
'cmd+k' |
Keyboard shortcut to open the palette |
placeholder |
string |
'Search commands...' |
Placeholder text for the search input |
teleport |
bool |
true |
Teleport the overlay to <body> so it sits above every stacking context. Set to false to render the overlay inline — required when embedding the palette inside a scoped container (e.g. docs preview cards with contain: layout) |
lockScroll |
bool |
true |
Lock document.body scroll while the palette is open (standard modal behavior). Set to false when the palette is embedded inside a local container and you want the rest of the page to stay scrollable and interactive |
scope |
string|null |
null |
Scoped personalization key |
<x-wirekit::command-palette.group>
| Prop | Type | Default | Description |
|---|---|---|---|
heading |
string |
required | Group label displayed above items |
<x-wirekit::command-palette.item>
| Prop | Type | Default | Description |
|---|---|---|---|
href |
string|null |
null |
URL (renders <a> instead of <button>) |
icon |
string|null |
null |
WireKit icon alias (e.g. 'search', 'user') |
shortcut |
string|null |
null |
Keyboard shortcut hint text (display only) |
disabled |
bool |
false |
Disabled state |
id |
string|null |
null |
Stable DOM id anchoring aria-activedescendant. Derived from href, else the item text, else random — pass one when you have a real key and the list re-renders. |
Slots
| Slot | Purpose |
|---|---|
| default | Groups and items |
footer |
Optional footer with keyboard hints or actions |
Sub-Components
| Component | Purpose |
|---|---|
command-palette.group |
Groups related items under a heading |
command-palette.item |
A single command entry (link or action) |
command-palette.empty |
Shown when no items match the search query |
Accessibility
- ARIA pattern: Implements the WAI-ARIA Combobox with
role="combobox"on the search input androle="listbox"on the results list - Input:
aria-autocomplete="list",aria-expanded,aria-controls(linked to listbox ID) - Active descendant:
aria-activedescendanton the input tracks the currently highlighted option - Groups:
role="group"witharia-labelfrom theheadingprop - Items:
role="option",aria-selectedon the active item - Disabled items:
aria-disabled="true", skipped by arrow key navigation - Focus trap active while open -- Tab cycles within the palette
- Focus returns to the previously focused element on close
- Overlay:
role="dialog",aria-modal="true",aria-label="Command palette" - Live region:
aria-live="polite"announces result count changes to screen readers - Icons:
aria-hidden="true"(decorative)
Keyboard Interaction
| Key | Action |
|---|---|
Cmd+K / Ctrl+K (configurable via hotkey) |
Open the palette |
| Type to search | Filter commands |
ArrowUp / ArrowDown |
Move between results |
Home / End |
Jump to first / last result |
Enter |
Run the focused command (navigate or trigger its action) |
Escape |
Close the palette, return focus to the previously focused element |
Pitfalls
- A palette item is a plain link or button — there is no
:on-selectcallback prop. Each item renders as an<a href>(when you passhref) or a<button>. Selecting an item is the native activation: a click, or Enter on the focused item. Navigate by giving the item anhref; for an in-app action, make the item a<button>and drive the behavior from its own native activation rather than layering awire:clickon top, which can race the palette's close-on-select Alpine handler and leave it open. - Don't put more than ~50 items in the default view. Filter to <10 results via the search field, or paginate by category. WAI-ARIA Listbox Pattern requires every item be in the keyboard tab order — long lists become unusable.
Design Tokens
| Element | Token |
|---|---|
| Overlay background | --color-wk-overlay |
| Panel background | --color-wk-bg-elevated |
| Panel border | --color-wk-border / --border-wk-width |
| Panel radius | --radius-wk-xl |
| Panel shadow | --shadow-wk-lg |
| Search input text | --color-wk-text |
| Search placeholder | --color-wk-text-placeholder |
| Group heading color | --color-wk-text-muted |
| Item text | --color-wk-text |
| Item hover/active bg | --color-wk-bg-subtle |
| Item icon color | --color-wk-text-muted |
| Shortcut badge border | --color-wk-border |
| Shortcut badge radius | --radius-wk-sm |
| Footer border | --color-wk-border-subtle |
| Font family | --font-wk-sans |
| Font size | --text-wk-md |
| Transition | --transition-wk-duration |
| Disabled opacity | --opacity-wk-disabled |
| Results list max height | --wk-command-palette-list-max-height (default 18rem) |
| Panel offset from top | --wk-command-palette-offset-top |
Personalization
Override defaults in config/wirekit.php:
'components' => [
'command-palette' => [
'hotkey' => 'ctrl+/',
'placeholder' => 'Type a command...',
],
],
Scoped Personalization
<x-wirekit::command-palette scope="admin-search">
...
</x-wirekit::command-palette>
'personalizations' => [
'command-palette' => [
'admin-search' => [
'base' => 'max-w-2xl',
],
],
],
Further Reading
- WAI-ARIA Combobox Pattern -- the authoring pattern this component implements
- WAI-ARIA Listbox Pattern -- used for the results list
- MDN:
role="combobox" - MDN:
aria-activedescendant - focus-trap -- focus management library (bundled, ~3.8 KB)