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.
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
| Keys | What happens |
|---|---|
| ⌘K / Ctrl K | Open the menu (the hotkey prop changes the key). |
| ↑ ↓ | Move the highlight over the results. |
| Home End | Jump 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 Tab | Switch scope. |
| ⌫ in an empty field | Remove the last filter token. |
| key:value then space | Turn the text into a filter token (for the keys in filterKeys). |
| esc | Clear 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
| Moment | What happens |
|---|---|
| Open and close | The panel rises and fades in over the backdrop; closing is quicker (160ms). |
| Highlight | One highlight glides between rows as you move, instead of each row lighting up on its own. |
| Results | Groups settle in as you type; the matched letters take the strong weight. |
| Ask AI | Nothing is answered until you ask. Then a Chain of Thought works, folds shut, and the answer card lands. |
| The answer | It 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 light | A soft light travels around the answer card's border and stays until the whole response has arrived, then fades. |
| No results | A magnifier sweeps over a feed of placeholder rows. |
| Reduced motion | Everything 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
| Prop | Type | Default | Description |
|---|---|---|---|
| groups | CommandMenuGroup[] | The results, in groups. The only required prop. | |
| open / defaultOpen | boolean | false | Controlled or initial open state. |
| onOpenChange | (open) => void | Called when it opens (⌘K) or closes (esc, backdrop, choosing an item). | |
| hotkey | string | 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" | default | default has scopes and a workspace footer; simple is the field, the rows and the key hints. |
| query / defaultQuery / onQueryChange | string | The field's text, controlled or initial. | |
| placeholder | string | The field's placeholder. | |
| filter | boolean | (item, query) => boolean | true | Filter 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). |
| highlightMatches | boolean | true | Sets 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) => void | Called when an item is chosen (click or ↵), after the item's own onSelect. | |
| closeOnSelect | boolean | true | Close the menu when an item is chosen. Turn it off for items that start something in the menu, like Ask AI. |
| scopes / scope / defaultScope / onScopeChange | CommandMenuScope[] / string | The tabs under the field (not in the simple variant). Tab and Shift Tab move between them. | |
| filters / onFiltersChange | CommandMenuFilter[] | Filter tokens shown in the field. ⌫ in an empty field removes the last one. | |
| filterKeys | string[] | Typing key:value and a space turns it into a token for these keys, e.g. ["team", "status"]. | |
| onAskAI | (query) => void | Adds ⌘↵ and the Ask AI hint; called with the current query. | |
| answer | ReactNode | Shown above the results: a CommandMenuAnswer, a Chain of Thought, or both. | |
| renderPreview | (item) => ReactNode | Shows a detail pane beside the list for the active item. Return null for no pane. | |
| previewActions | ReactNode | (item) => ReactNode | Buttons pinned to the bottom of the pane. They stay put while the content above cross-fades from row to row. | |
| previewEmpty | ReactNode | Shown in the pane when the active row has nothing to preview. | |
| emptyTitle / emptyDescription | ReactNode | The "No results" message. | |
| suggestions | CommandMenuSuggestion[] | Quick fixes under the empty message, e.g. "Did you mean invoice?". | |
| fallbackGroups | CommandMenuGroup[] | Shown under the empty or error message, and navigable like any group (e.g. "Try instead"). | |
| errorTitle / errorDescription | ReactNode | The error message. | |
| onRetry / onReport | () => void | The "Try again" button, and a quieter "Report" beside it. | |
| retryLabel / reportLabel | ReactNode | Replace the button labels. | |
| workspace | ReactNode | The workspace switcher in the footer (default variant). | |
| hideFooter | boolean | Hide the footer of key hints. | |
| aria-label | string | "Command menu" | The dialog's accessible name. |
CommandMenuItem
| Prop | Type | Default | Description |
|---|---|---|---|
| id | string | Unique within the menu. | |
| label | string | The row's title. The matched letters are set in the strong weight. | |
| description | string | A 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. | |
| scope | string | Overrides the scope the item belongs to (the value of a scope). | |
| icon | IconComponent | The icon in the leading tile. Defaults by type. | |
| avatar | { src?, initials?, alt?, status? } | For a person: an Avatar, with a status dot ("online" | "away"). | |
| meta | string | Small text on the right: "Page", "Edited 2h ago". | |
| shortcut | string[] | Key caps on the right, e.g. ['⌘', 'N']. | |
| keywords | string[] | Extra words the built-in filter matches. | |
| tags | string[] | Matched by filter tokens: a token whose value (or label) is in tags keeps the item. | |
| onSelect | () => void | Called when the item is chosen, in addition to the menu's onSelect. | |
| disabled | boolean | Not selectable. |
CommandMenuGroup
| Prop | Type | Default | Description |
|---|---|---|---|
| id | string | Unique within the menu. | |
| label | string | The small heading above the items. | |
| action | { label, onClick } | A link on the right of the heading, e.g. "See all". | |
| items | CommandMenuItem[] | The rows. |
CommandMenuScope
| Prop | Type | Default | Description |
|---|---|---|---|
| value | string | "all" is the unfiltered scope. | |
| label | string | The tab's text. | |
| count | number | null | The count on the tab. Computed from the items when left out (and filter is on); null shows no count. |
CommandMenuFilter
| Prop | Type | Default | Description |
|---|---|---|---|
| id | string | Unique among the tokens. | |
| label | string | Shown in the token, e.g. "People" or "team: Design". | |
| value | string | What tags are matched against. Defaults to the label. | |
| icon | IconComponent | Drawn before the label. |
CommandMenuSuggestion
| Prop | Type | Default | Description |
|---|---|---|---|
| label | string | The row's text. | |
| onSelect | () => void | Called when it is chosen. | |
| icon | IconComponent | An icon beside the label. | |
| iconPosition | "start" | "end" | start | Before the label, or after it (the × on "Remove the Files filter"). |
| emphasis | string | A word inside label to set in the strong weight ("invoice" in "Did you mean invoice?"). |
CommandMenuTrigger props
| Prop | Type | Default | Description |
|---|---|---|---|
| placeholder | string | "Search or jump to…" | The text in the pill. |
| shortcut | string[] | ["⌘", "K"] | The key caps on the right. |
| onClick | () => void | Open your CommandMenu. It also takes every button prop and a ref. |
CommandMenuAnswer props
| Prop | Type | Default | Description |
|---|---|---|---|
| children | ReactNode | The answer text. A plain string when stream is on. | |
| title | string | "SakaniUI AI" | The header text, beside the SakaniUI mark. |
| status | string | e.g. "Thought for 2.4s". With stream it appears once the text has been written. | |
| stream | boolean | Writes 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 | () => void | Called 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 | () => void | Adds an "Ask a follow-up" button. | |
| onOpenInChat | () => void | Adds an "Open in chat" hint with ⌘↵. | |
| onCopy | () => void | Adds 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.