YuhuanStudioYunUIDocs
Feedback

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

tsx
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

tsx
1const [open, setOpen] = useState(false);
2const [query, setQuery] = useState("");
3const [items, setItems] = useState<CommandPaletteItem[]>([]);
4 
5useCommandPaletteShortcut(() => setOpen(true)); // ⌘K / Ctrl-K
6 
7<CommandPalette
8 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.

tsx
1items={[
2 { id: "1", title: "Button", group: "Components" },
3 { id: "2", title: "Card", group: "Components" },
4 { id: "3", title: "Tokens", group: "Docs" },
5]}

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.

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" of role="option" rows, and the input is a role="combobox" carrying aria-activedescendant — so a screen reader announces the highlighted row as the arrow keys move it, without focus ever leaving the input.
  • loading shows 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 overflow can clip it.
  • useCommandPaletteShortcut is separate, so a host that already owns a shortcut layer can skip it.

Props

PropTypeDefaultDescription
classNamestring
emptyReactNodeRendered when there is a query but no results.
footerReactNodeA hint bar under the list — result counts, keyboard legends. Omit it and the bar is not rendered at all.
initialReactNodeRendered before anything is typed — recents, shortcuts, tips.
itemsCommandPaletteItem[][]Results to show.
labels{ placeholder?: string; title?: string; close?: string | undefined; } | undefinedEvery string this renders.
loadingbooleanfalseShow a spinner in place of the list.
onClose*() => void
onQueryChange*(query: string) => void
open*boolean
query*stringCurrent query — controlled, so the host can drive a remote search.

On this page