SakaniUI

Command Menu

A ⌘K search and command palette: a field, scope tabs, grouped results, filter tokens and a preview pane, the states a real search needs, and an Ask AI mode that thinks before it answers.

Open it, type to filter, and move with ↑ ↓ over one gliding highlight. You own the data and the actions: pass groups of items and handle onSelect. The menu opens in a layer above the page and follows the theme of the area it is in.

Open it, type to filter, use ↑ ↓ and ↵.

Opening it

It listens for ⌘K (Ctrl K) wherever it is mounted. The CommandMenuTrigger pill is the button that opens it from a top bar or sidebar. Mount one menu per page: two with the same hotkey would open together.

// ⌘K (Ctrl K) opens it by default, wherever the menu is mounted.
<CommandMenu defaultOpen={false} groups={groups} />

// Own the key, or turn it off:
<CommandMenu hotkey="j" groups={groups} />       // ⌘J
<CommandMenu hotkey={false} open={open} onOpenChange={setOpen} groups={groups} />

Simple

variant="simple" drops the scopes and the workspace footer: the field, one group and the key hints.

Scopes and filter tokens

scopes are the tabs under the field; an item belongs to the scope of its type unless it names its own. Filters are tokens in the field. They keep the items whose tags contain the token's value, and typing team:Design and a space makes one for any key in filterKeys.

Preview pane

renderPreview adds a detail pane for the active row; return null for rows with nothing to show and previewEmpty fills the gap. previewActions stay pinned at the bottom while the content above cross-fades from row to row.

Ask AI

The answer slot takes a Chain of Thought and a CommandMenuAnswer. It behaves the way a chat reply does: with an empty field there is no answer, only suggested questions. Pick one, or type your own and press ↵(⌘↵ works from any scope), and it thinks, folds the thinking shut, and writes the answer in. Change the question and the answer goes, because it belongs to the question in the box. The menu displays; your agent supplies the steps and the text (this demo is scripted, and answers questions about dark mode and themes).

Answer card

CommandMenuAnswer can be used on its own. With stream it writes a plain-string answer in word by word and calls onStreamEnd when it is done; the sources and the follow-up arrive after the text. A soft light travels around the card's border until the whole response is there.

SakaniUI AI

Loading, error and no results

A search never ends in a dead end. An empty result can offer a fix (suggestions) and a way on (fallbackGroups, navigable like any group); an error has a retry and a quieter report, and can still show what to open instead.

Results from a server

By default the menu filters the groups you pass. When your server does the searching, set filter={false} so it shows exactly what you give it, and drive state from the request. This demo waits 450ms before it answers.

Trigger

The pill for a top bar or sidebar. It only opens the menu: give it onClick.

Keyboard

KeysWhat happens
⌘K / Ctrl KOpen the menu (the hotkey prop changes the key).
↑ ↓Move the highlight over the results.
Home EndJump to the first or last result while the field is empty.
↵Choose the highlighted row.
⌘↵Ask AI with the text in the field (with onAskAI), from any scope.
Tab / Shift TabSwitch scope.
⌫ in an empty fieldRemove the last filter token.
key:value then spaceTurn the text into a filter token (for the keys in filterKeys).
escClear the query; press again to close.

Dark mode and glass

The menu reads the semantic and Surface tokens, so it follows light and dark by itself. It is drawn in a layer above the page, and takes the theme of the nearest .dark area it is mounted in. Inside a data-surface="glass" area the panel, rows and footer take the glass tokens. The Ask AI mark is the SakaniUI arc in the text colour, so it is white in dark mode.

Motion

MomentWhat happens
Open and closeThe panel rises and fades in over the backdrop; closing is quicker (160ms).
HighlightOne highlight glides between rows as you move, instead of each row lighting up on its own.
ResultsGroups settle in as you type; the matched letters take the strong weight.
Ask AINothing is answered until you ask. Then a Chain of Thought works, folds shut, and the answer card lands.
The answerIt is written in word by word, with a breath at each comma and a longer one at each full stop; then the sources fan out and the follow-up arrives.
The lightA soft light travels around the answer card's border and stays until the whole response has arrived, then fades.
No resultsA magnifier sweeps over a feed of placeholder rows.
Reduced motionEverything is instant: no glide, no streaming, no light.

Accessibility

  • It is a modal dialog with an accessible name (aria-label); focus moves to the field when it opens and goes back to what opened it when it closes.
  • The field is a combobox over a listbox; the highlighted row is announced through aria-activedescendant, so focus never leaves the field.
  • Scopes are a tab list. Loading, no-results and error messages are announced, and the answer card is marked busy while it is being written.
  • Every action has a key: ↑ ↓, ↵, ⌘↵, Tab, esc. Nothing needs a pointer.
  • Reduced motion makes the panel, the highlight, the thinking and the streamed answer instant, and the light does not run.

CommandMenu props

PropTypeDefaultDescription
groupsCommandMenuGroup[]The results, in groups. The only required prop.
open / defaultOpenbooleanfalseControlled or initial open state.
onOpenChange(open) => voidCalled when it opens (⌘K) or closes (esc, backdrop, choosing an item).
hotkeystring | false"k"The key that opens it with ⌘ / Ctrl. false turns the shortcut off, e.g. when you open it from your own button.
variant"default" | "simple"defaultdefault has scopes and a workspace footer; simple is the field, the rows and the key hints.
query / defaultQuery / onQueryChangestringThe field's text, controlled or initial.
placeholderstringThe field's placeholder.
filterboolean | (item, query) => booleantrueFilter the groups by the query, scope and tokens. Pass a function to match your own way, or false to show groups exactly as given (server results).
highlightMatchesbooleantrueSets the matched letters of each label in the strong weight.
state"ready" | "loading" | "error"ready"loading" shows skeleton rows; "error" the error message.
onSelect(item) => voidCalled when an item is chosen (click or ↵), after the item's own onSelect.
closeOnSelectbooleantrueClose the menu when an item is chosen. Turn it off for items that start something in the menu, like Ask AI.
scopes / scope / defaultScope / onScopeChangeCommandMenuScope[] / stringThe tabs under the field (not in the simple variant). Tab and Shift Tab move between them.
filters / onFiltersChangeCommandMenuFilter[]Filter tokens shown in the field. ⌫ in an empty field removes the last one.
filterKeysstring[]Typing key:value and a space turns it into a token for these keys, e.g. ["team", "status"].
onAskAI(query) => voidAdds ⌘↵ and the Ask AI hint; called with the current query.
answerReactNodeShown above the results: a CommandMenuAnswer, a Chain of Thought, or both.
renderPreview(item) => ReactNodeShows a detail pane beside the list for the active item. Return null for no pane.
previewActionsReactNode | (item) => ReactNodeButtons pinned to the bottom of the pane. They stay put while the content above cross-fades from row to row.
previewEmptyReactNodeShown in the pane when the active row has nothing to preview.
emptyTitle / emptyDescriptionReactNodeThe "No results" message.
suggestionsCommandMenuSuggestion[]Quick fixes under the empty message, e.g. "Did you mean invoice?".
fallbackGroupsCommandMenuGroup[]Shown under the empty or error message, and navigable like any group (e.g. "Try instead").
errorTitle / errorDescriptionReactNodeThe error message.
onRetry / onReport() => voidThe "Try again" button, and a quieter "Report" beside it.
retryLabel / reportLabelReactNodeReplace the button labels.
workspaceReactNodeThe workspace switcher in the footer (default variant).
hideFooterbooleanHide the footer of key hints.
aria-labelstring"Command menu"The dialog's accessible name.

CommandMenuItem

PropTypeDefaultDescription
idstringUnique within the menu.
labelstringThe row's title. The matched letters are set in the strong weight.
descriptionstringA second line: a path, a role, a hint.
type"page" | "action" | "file" | "person" | "ai"What kind of result it is. Sets the default icon and the scope it belongs to ("pages", "people", "files", "actions"). "ai" draws the SakaniUI mark.
scopestringOverrides the scope the item belongs to (the value of a scope).
iconIconComponentThe icon in the leading tile. Defaults by type.
avatar{ src?, initials?, alt?, status? }For a person: an Avatar, with a status dot ("online" | "away").
metastringSmall text on the right: "Page", "Edited 2h ago".
shortcutstring[]Key caps on the right, e.g. ['⌘', 'N'].
keywordsstring[]Extra words the built-in filter matches.
tagsstring[]Matched by filter tokens: a token whose value (or label) is in tags keeps the item.
onSelect() => voidCalled when the item is chosen, in addition to the menu's onSelect.
disabledbooleanNot selectable.

CommandMenuGroup

PropTypeDefaultDescription
idstringUnique within the menu.
labelstringThe small heading above the items.
action{ label, onClick }A link on the right of the heading, e.g. "See all".
itemsCommandMenuItem[]The rows.

CommandMenuScope

PropTypeDefaultDescription
valuestring"all" is the unfiltered scope.
labelstringThe tab's text.
countnumber | nullThe count on the tab. Computed from the items when left out (and filter is on); null shows no count.

CommandMenuFilter

PropTypeDefaultDescription
idstringUnique among the tokens.
labelstringShown in the token, e.g. "People" or "team: Design".
valuestringWhat tags are matched against. Defaults to the label.
iconIconComponentDrawn before the label.

CommandMenuSuggestion

PropTypeDefaultDescription
labelstringThe row's text.
onSelect() => voidCalled when it is chosen.
iconIconComponentAn icon beside the label.
iconPosition"start" | "end"startBefore the label, or after it (the × on "Remove the Files filter").
emphasisstringA word inside label to set in the strong weight ("invoice" in "Did you mean invoice?").

CommandMenuTrigger props

PropTypeDefaultDescription
placeholderstring"Search or jump to…"The text in the pill.
shortcutstring[]["⌘", "K"]The key caps on the right.
onClick() => voidOpen your CommandMenu. It also takes every button prop and a ref.

CommandMenuAnswer props

PropTypeDefaultDescription
childrenReactNodeThe answer text. A plain string when stream is on.
titlestring"SakaniUI AI"The header text, beside the SakaniUI mark.
statusstringe.g. "Thought for 2.4s". With stream it appears once the text has been written.
streambooleanWrites the answer in word by word, the way a chat reply arrives, and brings in the sources and the follow-up once it is written. Reduced motion shows it at once.
onStreamEnd() => voidCalled once when the text has been written (or at once when it is not streamed). Use it to show related results.
sources{ label, icon? }[]Chips under the answer.
onFollowUp() => voidAdds an "Ask a follow-up" button.
onOpenInChat() => voidAdds an "Open in chat" hint with ⌘↵.
onCopy() => voidAdds a copy button to the header.

Why it works this way

Why

  • You own the data and the actions: pass groups of items and handle onSelect. It filters as you type, moves over one gliding highlight, and covers the states a real search needs (loading, error, no results with a way out) so you do not rebuild them.
  • Ask AI is built in as a slot, not a service. Hand it a Chain of Thought and a CommandMenuAnswer and it thinks, folds shut and writes the answer in the way a chat reply arrives. Nothing is answered until the person asks.

Don't

  • Don't use it for a form field that picks one value. Use Select or Combobox.
  • Don't use Menu for it. A Menu is a short list of actions on a button; the Command Menu is a searchable palette over the whole app.
  • Don't fake the thinking. Show the Chain of Thought only while your agent really works, and answer only the question in the box.
  • Don't leave a dead end. Give an empty search suggestions or fallbackGroups, and an error a retry.