Skip to main content
Copy for LLM

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

Command Palette

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->wantsShortcuts ? '/' : ''", 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.empty 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.trigger 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 document and 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

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.

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.detail.query. 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's aria-activedescendant. Derived automatically from the item's href, or its text when there is no href; pass :id explicitly 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 and role="listbox" on the results list
  • Name of the search: an aria-label or aria-labelledby of 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-activedescendant on 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" with aria-label from the heading prop
  • Items: role="option", aria-selected on the active item
  • Empty state: role="status" on command-palette.empty, rendered outside the listbox so the list keeps only options as children
  • Remote source: the loading and error slots are read out through a role="status" region the palette keeps in the page, and the list carries aria-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 inert while the palette is open, so neither a pointer nor a screen reader that ignores aria-modal reaches 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", and aria-label from the label prop, "Command palette" by default. A search announces itself as a search with label="Search documentation"
  • Result changes are conveyed by the combobox itself rather than by a separate live region: aria-activedescendant moves 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 an aria-live="polite" element of your own beside the palette.
  • Trigger field: command-palette.trigger is a search field with a visually hidden label, "Search commands" by default, and aria-haspopup="dialog". Its shortcut hint is hidden from screen readers, and the field states the palette's hotkey as aria-keyshortcuts instead, Meta+K Control+K by 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-select callback prop. Each item renders as an <a href> (when you pass href) or a <button>. Selecting an item is the native activation: a click, or Enter on the highlighted item. Navigate by giving the item an href; for an in-app action, put your own wire:click or x-on:click on 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/wirekit.php:

'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

Updated in WireKit v2.66.0 (2026-10-13)

Was this page helpful?

Thank you for your feedback!

Voting requires cookies or local storage. What we store