Skip to content
ShineUI
Install

CommandPalette

Every action, one keystroke away.

<sui-command-palette>CompositionsStable

About

Searchable command launcher: a search field over grouped rows on the navy scrim, opened by the host on Ctrl or Cmd K. Typing filters by label and meta, the arrow keys walk the rows and Enter picks one. Reach for it when an app has more actions and records than a menu can hold.

Built from

Usage

<sui-command-palette placeholder="Search actions, records, reports" brand="Falls City Books"></sui-command-palette> <script type="module"> import { defineCustomElements } from "@shineui/core/loader"; import "@shineui/core/shineui.css"; defineCustomElements(); const el = document.querySelector("sui-command-palette"); el.groups = [ { heading: "Create", items: [ { label: "New invoice", icon: "fa-regular fa-file-lines", kbd: "N", meta: "Bill a customer" }, { label: "Receive payment", icon: "fa-solid fa-circle-dollar-to-slot", kbd: "R", meta: "Apply to open invoices" }, { label: "New journal entry", icon: "fa-solid fa-pen-nib", meta: "Post to the general ledger" }, ], }, { heading: "Go to", items: [ { label: "Chart of accounts", icon: "fa-regular fa-rectangle-list", meta: "184 accounts" }, { label: "Reconciliation", icon: "fa-solid fa-scale-balanced", meta: "Operating 4402" }, { label: "Reports", icon: "fa-solid fa-chart-column", meta: "12 saved" }, ], }, /* ... 1 more */ ]; /* SuiCommandGroup[] */ el.addEventListener("suiPick", (e) => console.log(e.detail)); </script>
import { SuiCommandPalette } from "@shineui/react"; import "@shineui/core/shineui.css"; export function Example() { const groups = [ { heading: "Create", items: [ { label: "New invoice", icon: "fa-regular fa-file-lines", kbd: "N", meta: "Bill a customer" }, { label: "Receive payment", icon: "fa-solid fa-circle-dollar-to-slot", kbd: "R", meta: "Apply to open invoices" }, { label: "New journal entry", icon: "fa-solid fa-pen-nib", meta: "Post to the general ledger" }, ], }, { heading: "Go to", items: [ { label: "Chart of accounts", icon: "fa-regular fa-rectangle-list", meta: "184 accounts" }, { label: "Reconciliation", icon: "fa-solid fa-scale-balanced", meta: "Operating 4402" }, { label: "Reports", icon: "fa-solid fa-chart-column", meta: "12 saved" }, ], }, /* ... 1 more */ ]; /* SuiCommandGroup[] */ return <SuiCommandPalette placeholder="Search actions, records, reports" brand="Falls City Books" groups={groups} onSuiPick={(e) => console.log(e.detail)} />; }
<script setup> import { SuiCommandPalette } from "@shineui/vue"; import "@shineui/core/shineui.css"; const groups = [ { heading: "Create", items: [ { label: "New invoice", icon: "fa-regular fa-file-lines", kbd: "N", meta: "Bill a customer" }, { label: "Receive payment", icon: "fa-solid fa-circle-dollar-to-slot", kbd: "R", meta: "Apply to open invoices" }, { label: "New journal entry", icon: "fa-solid fa-pen-nib", meta: "Post to the general ledger" }, ], }, { heading: "Go to", items: [ { label: "Chart of accounts", icon: "fa-regular fa-rectangle-list", meta: "184 accounts" }, { label: "Reconciliation", icon: "fa-solid fa-scale-balanced", meta: "Operating 4402" }, { label: "Reports", icon: "fa-solid fa-chart-column", meta: "12 saved" }, ], }, /* ... 1 more */ ]; // SuiCommandGroup[] const onChange = (e) => console.log(e.detail); </script> <template> <SuiCommandPalette placeholder="Search actions, records, reports" brand="Falls City Books" :groups="groups" @sui-pick="onChange" /> </template>
import { Component } from "@angular/core"; import { SuiCommandPalette } from "@shineui/angular"; @Component({ standalone: true, imports: [SuiCommandPalette], template: `<sui-command-palette placeholder="Search actions, records, reports" brand="Falls City Books" [(open)]="open" [groups]="groups" (suiPick)="onChange($event)"></sui-command-palette>`, }) export class Example { open = false; groups = [ { heading: "Create", items: [ { label: "New invoice", icon: "fa-regular fa-file-lines", kbd: "N", meta: "Bill a customer" }, { label: "Receive payment", icon: "fa-solid fa-circle-dollar-to-slot", kbd: "R", meta: "Apply to open invoices" }, { label: "New journal entry", icon: "fa-solid fa-pen-nib", meta: "Post to the general ledger" }, ], }, { heading: "Go to", items: [ { label: "Chart of accounts", icon: "fa-regular fa-rectangle-list", meta: "184 accounts" }, { label: "Reconciliation", icon: "fa-solid fa-scale-balanced", meta: "Operating 4402" }, { label: "Reports", icon: "fa-solid fa-chart-column", meta: "12 saved" }, ], }, /* ... 1 more */ ]; // SuiCommandGroup[] onChange(e: CustomEvent) { console.log(e.detail); } }

Basic

Opening moves focus to the search field, a combobox that points at the active row with aria-activedescendant, and the result count is announced through a polite region. The palette does not bind a shortcut itself: the host listens for Ctrl or Cmd K and sets open.

<sui-button id="cp-basic-open" variant="outline" label="Open palette" icon="fa-solid fa-magnifying-glass"></sui-button> <sui-command-palette id="cp-basic" placeholder="Search actions, records, reports" brand="Falls City Books"></sui-command-palette>
// setup(root): properties and events setup(root) { const palette = root.querySelector('#cp-basic'); palette.groups = commands(); root.querySelector('#cp-basic-open').addEventListener('click', () => { palette.open = true; }); }

Seeded query

query seeds the search field and is mutable, so typing writes it back. The built-in match reads the label and the meta line, and a group with no surviving rows drops out.

<sui-button id="cp-query-open" variant="outline" label="Open palette on a query"></sui-button> <sui-command-palette id="cp-query" query="rec" placeholder="Search actions, records, reports"></sui-command-palette>
// setup(root): properties and events setup(root) { const palette = root.querySelector('#cp-query'); palette.groups = commands(); root.querySelector('#cp-query-open').addEventListener('click', () => { palette.open = true; }); }

No matches

empty-filter-message replaces the row list and is the text the polite region announces when the query empties the palette. A palette holding no commands at all takes empty-message instead, which says Nothing here yet.

<sui-button id="cp-empty-open" variant="outline" label="Open palette with no matches"></sui-button> <sui-command-palette id="cp-empty" query="payroll" empty-filter-message="No matches. Try an invoice number or a customer."></sui-command-palette>
// setup(root): properties and events setup(root) { const palette = root.querySelector('#cp-empty'); palette.groups = commands(); root.querySelector('#cp-empty-open').addEventListener('click', () => { palette.open = true; }); }

external-filter renders groups as given, so answers the server matched loosely survive. Type "rcn" to see Reconciliation come back on a match no substring test would keep.

Idle.
<div style="display:flex;gap:14px;align-items:center;flex-wrap:wrap"> <sui-button id="cp-remote-open" variant="outline" label="Search the server"></sui-button> <span id="cp-remote-log" style="font:460 13px/1.6 var(--font-sans);color:var(--ink-muted)">Idle.</span> </div> <sui-command-palette id="cp-remote" external-filter placeholder="Search the server" empty-message="The server found nothing."></sui-command-palette>
// setup(root): properties and events setup(root) { const log = root.querySelector('#cp-remote-log'); const palette = root.querySelector('#cp-remote'); root.querySelector('#cp-remote-open').addEventListener('click', () => { palette.open = true; }); const catalog = commands().reduce((all, g) => all.concat(g.items), []); const loose = (q, label) => { let i = 0; for (const ch of label) if (ch === q[i]) i += 1; return i === q.length; }; palette.groups = commands(); palette.addEventListener('suiQueryChange', (e) => { const q = e.detail.trim().toLowerCase(); log.textContent = q ? `Server queried with "${q}".` : 'Idle.'; if (!q) { palette.groups = commands(); return; } const hits = catalog.filter((it) => loose(q, it.label.toLowerCase())); palette.groups = hits.length ? [{ heading: 'Server results', items: hits }] : []; }); }

Events

suiQueryChange fires on every keystroke with the new query, suiPick carries the chosen row, and suiClose carries the reason: pick, escape or scrim. Assigning query from code does not emit suiQueryChange, so a host can feed the field back without a loop.

Idle.
<div style="display:flex;gap:14px;align-items:center;flex-wrap:wrap"> <sui-button id="cp-events-open" variant="outline" label="Open palette" icon="fa-solid fa-magnifying-glass"></sui-button> <span id="cp-log" style="font:460 13px/1.6 var(--font-sans);color:var(--ink-muted)">Idle.</span> </div> <sui-command-palette id="cp-events" placeholder="Search actions, records, reports"></sui-command-palette>
// setup(root): properties and events setup(root) { const log = root.querySelector('#cp-log'); const palette = root.querySelector('#cp-events'); root.querySelector('#cp-events-open').addEventListener('click', () => { palette.open = true; }); palette.groups = commands(); palette.addEventListener('suiQueryChange', (e) => { log.textContent = `Typed "${e.detail}".`; }); palette.addEventListener('suiPick', (e) => { log.textContent = `Picked ${e.detail.label}.`; }); palette.addEventListener('suiClose', (e) => { log.textContent = `Closed (${e.detail}).`; }); }

Staying open

suiClose is cancelable. Vetoing the pick reason keeps the palette up for a second action, and Escape still closes it.

Pick a row, then pick another.
<div style="display:flex;gap:14px;align-items:center;flex-wrap:wrap"> <sui-button id="cp-stay-open" variant="outline" label="Open a sticky palette"></sui-button> <span id="cp-stay-log" style="font:460 13px/1.6 var(--font-sans);color:var(--ink-muted)">Pick a row, then pick another.</span> </div> <sui-command-palette id="cp-stay" placeholder="Add lines to the journal entry"></sui-command-palette>
// setup(root): properties and events setup(root) { const log = root.querySelector('#cp-stay-log'); const palette = root.querySelector('#cp-stay'); root.querySelector('#cp-stay-open').addEventListener('click', () => { palette.open = true; }); const picked = []; palette.groups = commands(); palette.addEventListener('suiPick', (e) => { picked.push(e.detail.label); }); palette.addEventListener('suiClose', (e) => { if (e.detail === 'pick') { e.preventDefault(); log.textContent = `Still open. Picked so far: ${picked.join(', ')}.`; return; } log.textContent = `Closed (${e.detail}). Picked: ${picked.length ? picked.join(', ') : 'nothing'}.`; }); }

Data contract

Groups are { heading, items: [{ label, icon, kbd, meta }] }. suiPick carries the chosen item; the host owns open and hears a cancelable suiClose before the palette closes itself.

Keyboard

KeyAction
ArrowDownMoves the active row down, across group headings, wrapping to the first.
ArrowUpMoves the active row up, wrapping to the last.
HomeMoves the active row to the first row and scrolls it into view, as every move does.
EndMoves the active row to the last row and scrolls it into view.
EnterPicks the active row: suiPick fires, then suiClose with reason pick.
EscapeCloses the palette with reason escape, when it is the topmost open modal.

API

<sui-command-palette>

Properties

PropertyAttributeTypeDefaultDescription
brandbrandstring''Small brand text in the footer, left of the key hints. Empty by default.
emptyFilterMessageempty-filter-messagestringstring | undefinedLine shown when the query matched no command. An empty palette and a search that found nothing are different states and one sentence cannot be right about both.
emptyMessageempty-messagestringstring | undefinedLine shown when there are no commands at all. Also the text the polite region announces.
externalFilterexternal-filterbooleanfalseThe host filters: groups renders as given, with no match test of its own, and only empty groups are dropped. Set it when the host answers suiQueryChange with the server's own answer for that query: a remote result is often a fuzzy or semantic match, and filtering it again on the client would drop rows the server returned. Off by default, so the palette filters groups by query, matching the label and the meta line.
groupsproperty onlySuiCommandGroup[][]Groups: { heading, items: [{ label, icon, kbd, meta }] }[]. Property, not attribute.
openopenbooleanfalseShows the palette as a fixed modal layer with the search field focused. Mutable; the element closes itself unless the close event is canceled. Only the topmost open modal answers Escape. The element never opens itself, so there is no suiOpen; watch open. While open, and while it fades out, the layer paints in the browser's top layer, so a transformed, clipping or stacked ancestor cannot trap or bury it; everything outside it is inert and the page does not scroll until it closes, and a node the page adds outside it while it is open goes inert too. The card is role="dialog" with no aria-modal, so VoiceOver keeps reading the toast region; Tab goes on from the palette into the toast stack and back. An element the page marks data-sui-keep-live stays usable with everything in it, and focus may move into it and back. On a short viewport the list gives up height so the search field and every row stay in reach.
placeholderplaceholderstringstring | undefinedSearch field placeholder.
queryquerystring''Search text (the reference defaultQuery). Mutable; typing updates it.

Events

EventDetailReactVueDescription
suiCloseSuiCloseReason"cancel" | "close-button" | "escape" | "outside" | "pick" | "scrim" | "trigger"onSuiClose@sui-closeFires before the palette closes itself; detail is 'escape' | 'scrim' | 'pick'. Cancelable: preventDefault() keeps it open. It does not bubble.
suiPickSuiCommandItemonSuiPick@sui-pickFires when a row is chosen (click or Enter); detail is the item. suiClose follows with reason 'pick'.
suiQueryChangestringonSuiQueryChange@sui-query-changeFires on every keystroke in the search field; detail is the new query, so a host can run a remote or fuzzy search. Assigning query from code does not emit, so a host can feed the field back without a loop. Does not bubble.

Types

SuiCloseReason typeWhy a dismissible surface is closing. Detail of the cancelable suiClose. A consumer expresses "a stray backdrop click must not close this" by calling preventDefault() on the reason it does not accept, which is why none of these needs a prop of its own.
No members.
SuiCommandGroup interfaceA heading with its rows.
headingstring
itemsSuiCommandItem[]
SuiCommandItem interfaceOne launcher row. kbd is a single-key hint, meta a short secondary line.
labelstring
icon?string
kbd?string
meta?string

Theming

Every design token this element can render with, read from its own stylesheets and the ones it imports. Set a token on :root or on any ancestor and every use below follows it. The values shown are the ones in the theme you are viewing. The list is read per stylesheet, so an element that imports a shared drawing module lists that module's tokens too, whether or not it paints with all of them.

See also