Command Palette
The ⌘K palette — one input, a keyboard-driven result list, grouping, and Escape to leave.
The ⌘K palette: a centered overlay with one input, a keyboard-driven result list, and Escape to leave.
Controlled on purpose — query and items live with you — so the same component covers a local fuzzy filter and a debounced server search without knowing which it is.
Import
1import { CommandPalette, useCommandPaletteShortcut } from "@yuhuanowo/yunui";Basic
Arrow keys move the cursor and Enter selects, so the list never needs the mouse. The cursor resets whenever the results change, so it can't point at a row that scrolled out from under it.
Palette
Wiring it up
1const [open, setOpen] = useState(false);2const [query, setQuery] = useState("");3const [items, setItems] = useState<CommandPaletteItem[]>([]);4 5useCommandPaletteShortcut(() => setOpen(true)); // ⌘K / Ctrl-K6 7<CommandPalette8 open={open}9 onClose={() => setOpen(false)}10 query={query}11 onQueryChange={(q) => { setQuery(q); void search(q).then(setItems); }}12 items={items}13 loading={loading}14 empty={t("noResults")}15 initial={t("startTyping")}16 labels={{ placeholder: t("search"), title: t("search"), close: t("close") }}17/>Grouping
Tag items with group and consecutive items sharing one are drawn under a single heading — no nesting, no separate section objects.
1items={[2 { id: "1", title: "Button", group: "Components" },3 { id: "2", title: "Card", group: "Components" },4 { id: "3", title: "Tokens", group: "Docs" },5]}Links vs commands
Give an item href and its row renders as a real anchor through the adapter's Link, so ⌘-click, middle-click and "open in new tab" all work and the destination shows in the status bar. Leave it off and the row is a <button> that runs onSelect.
That distinction matters more than it looks: a search result is a link, and a reader checking three of them should not have to reopen the palette between each one. A command ("Toggle dark mode") is not a link and should stay a button.
The footer bar
footer renders a hint strip under the list — a result count on the left, a keyboard legend on the right, laid out with justify-between. Omit the prop and no bar is rendered.
footer={
<>
<span>{items.length} results</span>
<span className="flex items-center gap-3">
<span><Kbd>↑</Kbd><Kbd>↓</Kbd> Select</span>
<span><Kbd>Enter</Kbd> Open</span>
<span><Kbd>Esc</Kbd> Close</span>
</span>
</>
}
Notes
- The list is a real
role="listbox"ofrole="option"rows, and the input is arole="combobox"carryingaria-activedescendant— so a screen reader announces the highlighted row as the arrow keys move it, without focus ever leaving the input. loadingshows a spinner in the input row while previous results stay on screen; the list only blanks when there is nothing to keep.- The input is
16px; anything smaller and iOS Safari zooms the page when it takes focus. - The body stops scrolling while the palette is open, and the overlay renders in a portal so no ancestor's
overflowcan clip it. useCommandPaletteShortcutis separate, so a host that already owns a shortcut layer can skip it.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| className | string | — | |
| empty | ReactNode | — | Rendered when there is a query but no results. |
| footer | ReactNode | — | A hint bar under the list — result counts, keyboard legends. Omit it and the bar is not rendered at all. |
| initial | ReactNode | — | Rendered before anything is typed — recents, shortcuts, tips. |
| items | CommandPaletteItem[] | [] | Results to show. |
| labels | { placeholder?: string; title?: string; close?: string | undefined; } | undefined | — | Every string this renders. |
| loading | boolean | false | Show a spinner in place of the list. |
| onClose* | () => void | — | |
| onQueryChange* | (query: string) => void | — | |
| open* | boolean | — | |
| query* | string | — | Current query — controlled, so the host can drive a remote search. |