Command Palette
The <x-wirekit:: 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-slot:empty>
<x-wirekit::command-palette.empty>
No results found.
</x-wirekit::command-palette.empty>
</x-slot: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+/">
{{-- One character key, which stays a character in a text field: see below --}}
<x-wirekit::command-palette hotkey="/">
{{-- No shortcut: the page opens it by event, or handles the key itself --}}
<x-wirekit::command-palette hotkey="">
An empty hotkey, or :hotkey="false", binds no key at all.
A hotkey names its modifiers and ends with the key, joined by +: cmd (or meta, which also
answers Ctrl on Windows and Linux), ctrl, alt (or option) and shift.
A hotkey of one character key without cmd, ctrl or alt, such as /, stays a character
wherever the reader types: in a text field, a textarea, a select or an editable element the key
types, and the palette opens only from elsewhere on the page.
WCAG 2.1.4 asks for
more from such a shortcut: a way for the reader to turn it off. Bind the prop to a setting of
your own, :hotkey="$user->wants, or choose a hotkey with a modifier.
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>
The hint is hidden from screen readers. Where your application binds the key, put the same shortcut on the item as aria-keyshortcuts, such as aria-keyshortcuts="Control+N", which reaches the item's option.
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. sub-component goes in the empty slot, not in the default one. The palette shows it while its list holds no option, and hides it while a remote search is still loading or has failed (see Filters and a Remote Source):
<x-slot:empty>
<x-wirekit::command-palette.empty>
No results found. Try a different search term.
</x-wirekit::command-palette.empty>
</x-slot:empty>
The default slot is the inside of the role="listbox", and the children of a listbox are options. A line of prose among them is a non-option child of a role that requires them — the same finding axe reports as aria-required-children. The empty slot renders as a sibling of the list instead, so the message sits outside it and still appears in the same place on screen.
The sub-component carries role="status", which is what makes the message announce itself when it appears: the search input keeps aria-expanded="true" and reports no active descendant either way, so without the live region a search that matched nothing sounded exactly like a list nobody had touched yet.
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');
Two palettes on one page
Without a payload, these events reach every palette on the page. Give each palette a name and put the same name in the event, and only that palette answers. The shortcut is not addressed by name, so leave it off on all but one:
{{-- 1. A site search on the default shortcut, and a commands palette with none. --}}
<x-wirekit::command-palette name="search">…</x-wirekit::command-palette>
<x-wirekit::command-palette name="commands" hotkey="">…</x-wirekit::command-palette>
{{-- 2. Each button opens its own palette. --}}
<button x-on:click="$dispatch('wirekit-command-palette-show', { name: 'commands' })">Commands</button>
{{-- 3. From Livewire, the name travels as a named argument. --}}
$this->dispatch('wirekit-command-palette-show', name: 'search');
wirekit-command-palette-close and wirekit:command-palette-state read the same name. An event without one still reaches every palette, as it always has.
A palette that must not answer an event without a name, such as a site search on a page whose demos open their own palettes with the unnamed event, takes named-only. It then answers only the events that carry its name, and the unnamed ones pass it by:
{{-- 1. Opens for events named "search", and for no event without a name. --}}
<x-wirekit::command-palette name="search" named-only>…</x-wirekit::command-palette>
Opening with text
query in the show event opens the palette with that text in its field and the cursor after it, and the query goes out as wirekit-command-palette-query at once, so a server-side search answers it straight away. Sent again while the palette is open and its field does not have the focus yet, the event hands over what was typed in the meantime; once the field has the focus, the reader's typing there wins.
{{-- 1. Opens the palette searching for "invoice". --}}
<button x-on:click="$dispatch('wirekit-command-palette-show', { query: 'invoice' })">Find invoices</button>
A Search Field That Opens It
command-palette. is a search field for a page's header that opens the palette and hands it what is typed, the keys typed while it opens included. It has a page of its own, Command Palette with a Search Field, because a page shows one palette: the hotkey and the show and close events reach every palette on it.
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.
While an on-screen keyboard covers part of the screen, the palette caps the list at the room between its top and the top of the keyboard, so the last option can still be scrolled into view. Without a keyboard the list is as tall as the variable says.
Behavior
- Keyboard shortcut listener is registered globally on
documentand respects modifier keys - Focus trap (bundled) keeps keyboard navigation within the palette
- Scroll lock holds the page still at its scroll position while the palette is open, also on iOS, with the same counted lock modal and drawer use: opened over a dialog, the page stays still until both have closed
- No room in place: the palette's own element draws no box where you write it, so it adds no gap to a row it stands in
- 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
- Choosing an item closes the palette, by click or by Enter, after the item's own click handler has run. A disabled item and a click beside the options leave it open
- 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.. 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.
Submitting a query with Enter
Enter runs the highlighted result. With nothing highlighted the palette runs
nothing, because it never picks a row the reader did not choose, and sends
wirekit:command-palette-submit from its root instead, with the query in
event.. A search answers it the way it sees fit: the full results
page, the only hit, or a jump the query names. The palette stays open; close it
or navigate away from your handler.
{{-- 1. The query event feeds the search, the submit event answers Enter with nothing highlighted. --}}
<x-wirekit::command-palette
x-on:wirekit-command-palette-query="$wire.set('search', $event.detail.query)"
x-on:wirekit:command-palette-submit="$wire.submitSearch($event.detail.query)"
>
{{-- 2. The results, as above. --}}
</x-wirekit::command-palette>
// 3. In the Livewire component: the full results page for the query.
public function submitSearch(string $query): void
{
if ($query !== '') {
$this->redirectRoute('search', ['q' => $query], navigate: true);
}
}
The event also fires when the list is empty, so a query that matched nothing can still reach a results page. Without a listener nothing changes.
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).
Filters and a Remote Source
A search that asks a server needs a place for filters between the input and the results, and two
states the empty state cannot give: the answer is not here yet, or the request failed. The
filters, loading and error slots cover them, and the wirekit:command-palette-state event
tells the palette where your request stands.
Command Palette with a Remote Search walks through
it with a live demo, the markup of a real search and the factory it calls.
Props
<x-wirekit::command-palette>
| Prop | Type | Default | Description |
|---|---|---|---|
hotkey |
string |
'cmd+k' |
Keyboard shortcut to open the palette, such as cmd+k, ctrl+/ or alt+shift+p. An empty value or false binds no key. A single character key without cmd, ctrl or alt does nothing while the reader types in a field |
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 |
Hold the page still while the palette is open, with the same lock modal and drawer use: the page keeps its scroll position, also on iOS, and stays still until the last open overlay closes. 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 |
label |
string|null |
null |
The dialog's accessible name, announced first when the palette opens. Empty keeps the translated default, "Command palette" |
name |
string|null |
null |
Addresses this palette: the show, close and state events reach only the palette whose name they carry in detail.name. See Two palettes on one page |
namedOnly |
bool |
false |
With a name, answer only the events that carry it: an event without a name passes this palette by. Without a name it changes nothing. See Two palettes on one page |
scope |
string|null |
null |
Scoped personalization key |
<x-wirekit::command-palette.group>
| Prop | Type | Default | Description |
|---|---|---|---|
heading |
string |
required | Group label displayed above items |
scope |
string|null |
null |
Scoped personalization name |
<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). Hidden from screen readers: state a key your application binds with aria-keyshortcuts on the item, such as Control+N. |
disabled |
bool |
false |
Disabled state |
id |
string|null |
null |
Stable DOM id anchoring aria-activedescendant. Derived from href, else the item text, else a counter — pass one when you have a real key and the list re-renders. |
scope |
string|null |
null |
Scoped personalization name |
<x-wirekit::command-palette.trigger>
| Prop | Type | Default | Description |
|---|---|---|---|
for |
string|null |
null |
The name of the palette to open. Without one the field opens every palette that answers an event without a name |
label |
string|null |
null |
The field's accessible name, as a visually hidden label. Defaults to "Search commands" in the reader's language |
placeholder |
string|null |
null |
Defaults to "Search…" in the reader's language |
shortcut |
string|null |
null |
A shortcut shown at the end of the field, such as ⌘K. Shown only: the palette binds its own hotkey |
hotkey |
string|null |
'cmd+k' |
The palette's hotkey, written as the palette takes it. The field states it to screen readers as aria-keyshortcuts (Meta+K Control+K for the default). Give it the same value as a palette with its own hotkey; an empty value states none |
scope |
string|null |
null |
Scoped personalization name |
Slots
| Slot | Purpose |
|---|---|
| default | Groups and items — the inside of the role="listbox", so only options belong here |
filters |
A row of controls between the input and the list, for narrowing the results |
empty |
The "no results" message, rendered as a sibling of the list and shown while the list holds no option |
loading |
Shown while a remote search is out — see Command Palette with a Remote Search |
error |
Shown when a remote search failed |
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 while the list holds no option — belongs in the empty slot |
command-palette.trigger |
A search field that opens the palette and hands it what is typed — for a page's header |
Accessibility
- ARIA pattern: Implements the WAI-ARIA Combobox with
role="combobox"on the search input androle="listbox"on the results list - Name of the search: an
aria-labeloraria-labelledbyof yours names the search input and the results list; without one both are named "Search commands" - Input:
aria-autocomplete="list",aria-expanded,aria-controls(linked to listbox ID) - Active descendant:
aria-activedescendanton the input tracks the currently highlighted option. On screen it is a tint and an inset ring; under forced colors, which paints neither, it is framed with a dashed line in the system's selection color - Groups:
role="group"witharia-labelfrom theheadingprop - Items:
role="option",aria-selectedon the active item - Empty state:
role="status"oncommand-palette., rendered outside the listbox so the list keeps only options as childrenempty - Remote source: the
loadinganderrorslots are read out through arole="status"region the palette keeps in the page, and the list carriesaria-busy="true"while a request is out - List keys: the arrow keys, Home, End and Enter act on the list only while the input has focus, so a control in the filter row or the footer keeps its own keys
- 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
- The page behind is
inertwhile the palette is open, so neither a pointer nor a screen reader that ignoresaria-modalreaches it. A toast region stays reachable, and a palette rendered in place with:teleport="false"leaves the page alone - Overlay:
role="dialog",aria-modal="true", andaria-labelfrom thelabelprop,"Command palette"by default. A search announces itself as a search withlabel="Search documentation" - Result changes are conveyed by the combobox itself rather than by a separate live region:
aria-activedescendantmoves to the first match as you type, and a screen reader announces the option it lands on. There is no result-count announcement — if you need one, add anaria-live="polite"element of your own beside the palette. - Trigger field:
command-palette.is a search field with a visually hidden label, "Search commands" by default, andtrigger aria-haspopup="dialog". Its shortcut hint is hidden from screen readers, and the field states the palette'shotkeyasaria-keyshortcutsinstead,Meta+K Control+Kby default - 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 the first / last result once a result is highlighted. Before that they move the caret in the query |
Enter |
Run the highlighted command (navigate or trigger its action) and close the palette. With nothing highlighted, send wirekit:command-palette-submit with the query and leave the palette open |
Tab |
Move from the input to the filter row and the footer, when they hold controls |
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 highlighted item. Navigate by giving the item anhref; for an in-app action, put your ownwire:clickorx-on:clickon the item. The palette closes once that handler has run, so the action sees the click first. - Don't put more than ~50 items in the default view. Filter to <10 results via the search field, or paginate by category. The reason is the reading, not a specification: every option is announced as you arrow past it, so a long list is a long walk. (The APG Listbox pattern does not require each option to be in the tab order — it puts one stop on the control and moves an active option with
aria-activedescendant, which is exactly what this component does. The sentence here said the opposite, and contradicted the component it describes.)
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-hover |
| Item icon color | --color-wk-text-muted |
| Shortcut badge border | --color-wk-border |
| Shortcut badge radius | --radius-wk-sm |
| Filter row border | --color-wk-border |
| Loading and error text | --color-wk-text-muted |
| Footer border | --color-wk-border |
| 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/:
'components' => [
'command-palette' => [
'hotkey' => 'ctrl+/',
'placeholder' => 'Type a command...',
],
],
Scoped Personalization
<x-wirekit::command-palette scope="admin-search">
...
</x-wirekit::command-palette>
// In a service provider's boot() method. A closure receives the shipped classes, so the
// component keeps them; a plain string would replace them outright.
WireKit::scope('admin-search', [
'command-palette' => ['classes' => [
'panel' => fn (string $vendor): string => str_replace('max-w-[var(--size-wk-modal-md)]', 'max-w-[var(--size-wk-modal-lg)]', $vendor),
]],
]);
Further Reading
- Overlay Events — the canonical event vocabulary for showing, closing and reacting to every overlay, with payload shapes and Livewire-side dispatch examples.
- 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)