Skip to main content

Menus

Command

A command palette: a searchable list of every action in the app, in a dialog. Typing filters the commands by label and keywords (ignoring case and accents), the arrow keys move the highlight, and Enter runs the highlighted command. Commands come in labelled groups and show their shortcuts with Kbd; when nothing matches it says so. Below 640px it is a full-height bottom sheet, whose search input stays put while the results filter. Above it, the dialog fits the commands it opens with, from 32rem up to 40rem, and keeps that width while they filter and while a server's results arrive, so nothing in it moves sideways; a longer result is cut with an ellipsis. A command can lead with an avatar or an icon and end with an unread dot or a mark, so a palette that jumps to any chat shows who each one is with and what is new.

Install

Once per app, with Tailwind CSS v4 and an @/* path alias (Ouro HX is built on Base UI, so --base base; skip -t in an existing app):

npx shadcn@latest init --base base --preset nova --yes --no-monorepo
npx shadcn@latest add ourostack/ouro-ui/ouro-base#v1.3.10

Then the component. From v0.8.0 it installs only the helpers it imports and never touches your stylesheet (Installing):

npx shadcn@latest add ourostack/ouro-ui/command#v1.3.10

Or, with the @ouro namespace set up:

npx shadcn@latest add @ouro/ouro-base @ouro/command

Its tests, stories and Playwright spec are optional. They need Vitest 4.1.11 or later, and from v0.8.0 never change a version your app has (Testing):

npx shadcn@latest add ourostack/ouro-ui/command#v1.3.10 ourostack/ouro-ui/command-tests#v1.3.10
import { Command } from "@/components/ouro/command/command";

Paths use shadcn's default @/ alias; the CLI rewrites them to yours.

Example

const [open, setOpen] = useCommandShortcut("mod+k");
<Command
  title="Command palette"
  open={open}
  onOpenChange={setOpen}
  items={[
    { label: "Recipes", items: [{ label: "New recipe", onSelect: create, shortcut: ["mod", "n"] }] },
    { label: "Switch theme", onSelect: toggleTheme, keywords: ["dark mode"] },
  ]}
/>

// A palette that jumps to any chat, with each person's avatar and what is unread.
<Command
  title="Go to a chat"
  open={open}
  onOpenChange={setOpen}
  items={chats.map((chat) => ({
    label: chat.name,
    onSelect: () => openChat(chat.id),
    avatar: <Avatar name={chat.name} src={chat.picture} alt="" size="xs" idForColor={chat.id} />,
    unread: chat.unread > 0,
    mark: chat.unread > 0 && <Badge>{chat.unread}</Badge>,
  }))}
/>

Usage

Open it with trigger, or control it with open and onOpenChange, usually from useCommandShortcut, which opens it on ⌘K (Ctrl+K elsewhere): import { useCommandShortcut } from "@/lib/ouro/use-command-shortcut".

useCommandShortcut(shortcut = "mod+k") returns [open, setOpen] and toggles open on the shortcut anywhere on the page. It takes the keys as "mod+k" or ["mod", "k"], with Kbd's names: mod (⌘ on Apple platforms, Ctrl elsewhere), cmd, ctrl, alt and shift. It needs exactly those modifiers, ignores a held key's repeats and prevents the browser's action for that combination. It never fires while an input method is composing text, and in a text field it fires only when the shortcut includes mod, cmd or ctrl, so a shortcut like "/" never takes a typed character.

Use instead when

  • Use Menu instead for the few actions on one thing, and Combobox to pick a value for a form.

Props

The props the modes share. Type: CommandProps.

className
Type: string
Layout only, applied to the dialog panel: margins (mt-8). Appearance classes warn in development.
closeLabel
Type: string
Default: "Close"
The label of the close button, hidden until it has keyboard focus, so screen-reader users on phones (who have no Escape key) can always close the palette.
empty
Type: ReactNode
Content for the empty state in place of emptyLabel, such as a picture with words: empty={<Empty icon={<SearchIcon />} title="Nothing matches" />}. While loading, the loading line still shows under it.
emptyLabel
Type: string
Default: "No results"
What shows when nothing matches the search.
highlightMatches
Type: boolean
Marks the words of the search in each command's label and description, with the theme's colours (the system's mark colours under forced colours). The commands' names stay their plain text.
items (required)
Type: readonly CommandEntry[]
The commands: groups ({ label: "Recipes", items: [...] }), single commands, or both.
loading
Type: boolean
Whether results are on their way: shows loadingLabel beside the input, or under the empty state while nothing matches yet ("No chats by that name." over "Searching the directory…"), and announces it politely for the first load of each opening (not again while typing goes on). Set it back to false when the request fails or aborts. While it is true, rows in a result's shape, as tall as a result, hold the place of the results on their way under the ones already shown (none while nothing matches), and screen readers skip them.
loadingLabel
Type: string
Default: "Searching…"
What shows, and is announced, while loading.
onQueryChange
Type: ((query: string, context: { signal: AbortSignal }) => void)
For results from a server: called with the search text on every change, and with the current text ("" at each opening) whenever the palette opens or swaps between dialog and sheet. The palette then stops filtering items itself and shows them as they are, so keep the last results in items until new ones arrive. signal aborts when a newer query replaces this one or the palette closes: pass it to fetch, or drop the response when signal.aborted, so a slow earlier response never overwrites a newer one. The app owns the request, and must set loading back to false when the request fails or its signal aborts, or "Searching…" stays up.
placeholder
Type: string
Default: "Type a command or search…"
The search input's placeholder.
resultsLabel
Type: ((count: number) => string)
Default: (count) => count === 1 ? "1 result" : `${count} results`
What screen readers hear, politely and once, when the results for what was typed have settled (not while loading, and not for an empty search, whose list is the palette's own).
searchLabel
Type: string
Default: "Search commands"
The search input's label for screen readers.
title (required)
Type: string
Names the palette for screen readers; it is not shown. It is always required.
unreadLabel
Type: string
Default: "Unread"
What screen readers hear after an unread command's label, for other languages.

Modes

CommandProps is a union: pass the props of exactly one mode. Mixing them is a type error.

Controlled: CommandControlledProps

A controlled Command palette, usually opened by useCommandShortcut.

onOpenChange (required)
Type: (open: boolean) => void
Called with false when the user closes the palette (Escape, outside press, swipe), and when a command runs.
open (required)
Type: boolean
Whether the palette is open. After a key press it focuses the search input; after a touch or pen tap it focuses itself, so no software keyboard pops up.

Not in this mode (typed never):

  • trigger: Not with open. Remove open and onOpenChange to let a trigger open the palette.

With trigger: CommandWithTriggerProps

An uncontrolled Command palette: trigger opens it.

trigger (required)
Type: ReactElement
The element that opens the palette, usually a Button: trigger={<Button>Search</Button>}.

Not in this mode (typed never):

  • onOpenChange: Not with trigger. Remove trigger to control the palette with open and onOpenChange.
  • open: Not with trigger. Remove trigger to control the palette with open and onOpenChange.

Don't use

These are not props. Passing one fails the type check with the fix in the error, and in development a console message names the fix.

  • children: Not a prop. The commands are items: items={[{ label: "New recipe", onSelect: create }]}.
  • commands: Not a prop. The commands are items: items={[{ label: "New recipe", onSelect: create }]}.
  • isOpen: Not a prop. Ouro UI uses open with onOpenChange: <Command open={open} onOpenChange={setOpen}>.
  • onClose: Not a prop. onOpenChange(false) fires on close: <Command open={open} onOpenChange={setOpen}>.

className is for layout only

Pass margins, width and grid placement (mt-4, w-full, col-span-2).

Appearance comes from props, not classes. In development, a class that sets appearance logs one warning per component instance, naming the prop to use instead. A class sets appearance when, after its variants (hover:, sm:) and any ! are removed, it starts with bg-, text-, border-, rounded-, shadow-, font-, ring-, from-, via-, to-, fill- or stroke-, or is border, rounded, shadow or ring, or has a dark: variant. Text wrapping, overflow and table layout classes (text-wrap, text-ellipsis, border-collapse) are layout and do not warn.

Alignment classes (text-center) warn too: set alignment on a parent element.

Types

CommandEntry

One entry in a Command palette's items: a command, or a group of them.

type CommandEntry = CommandItem | CommandGroup;

CommandGroup

A labelled group of commands in a Command palette's items.

interface CommandGroup {
  /** The group's heading: "Recipes". */
  label: string;
  /** The group's commands. */
  items: readonly CommandItem[];
}

CommandItem

One command in a Command palette's items.

interface CommandItem {
  /** The command's text, which the search matches and screen readers hear. */
  label: string;
  /**
   * What the row shows in place of `label`, when plain text cannot draw it: a chat's name with
   * its emoji as pictures (`<EmojiText text={chat.name} catalogue={catalogue} />`). `label`
   * stays the text the search matches and the row's name for screen readers.
   */
  labelContent?: ReactNode;
  /** Runs the command. The palette closes first. */
  onSelect: () => void;
  /**
   * What tells this command apart from the others, when results change around it: the highlight
   * stays on the command with the same value. Give one when two commands can share a label.
   * @default its label
   */
  value?: string | undefined;
  /**
   * Whether closing the palette after this command runs puts focus back where it was (the
   * trigger). `false` leaves focus where `onSelect` puts it, as a command that opens a
   * conversation and focuses its messages does. @default true, or false for a command that `navigates`
   */
  restoreFocus?: boolean | undefined;
  /**
   * This command takes the person somewhere: it opens a conversation, a page or a record. Closing the
   * palette then leaves focus where the destination put it, never back on the trigger, which may be
   * gone or covered by then (`restoreFocus` defaults to false). Give `restoreFocus` to say otherwise.
   * @default false
   */
  navigates?: boolean | undefined;
  /**
   * A second line under the label, such as a snippet of a message, which the search matches too.
   */
  description?: string | undefined;
  /**
   * Where to mark the label and the description, as `[start, end]` ranges of each, such as the
   * matches a server found. With these, `highlightMatches` marks nothing else on this command.
   */
  highlight?: { label?: readonly TextRange[] | undefined; description?: readonly TextRange[] | undefined } | undefined;
  /**
   * More words the search matches, which never show: `keywords: ["dark mode"]`.
   */
  keywords?: readonly string[] | undefined;
  /** The keys of the command's shortcut, shown at its end with `Kbd`: `["mod", "n"]`. It does not bind them. */
  shortcut?: readonly string[] | undefined;
  /**
   * A picture before the label, for a person, a chat or a team: a URL, shown in an `<Avatar>`
   * named by the label, or an element to show instead, such as
   * `<Avatar name={person.name} alt="" size="xs" idForColor={person.id} />` for initials in the
   * person's color or a presence dot. Give the element `alt=""`, since the label names it.
   */
  avatar?: string | ReactElement | undefined;
  /**
   * A small icon before the label, in the muted color: a gear for settings, a page for a document.
   * It takes an extra-small avatar's width, so labels line up in a palette that mixes the two.
   * Screen readers skip it; the label names the command. With `avatar`, the avatar shows instead.
   */
  icon?: ReactNode;
  /**
   * Something new waits there, as in a chat with unread messages: the label is bold, a dot
   * ends the row, and screen readers hear `unreadLabel` after the label.
   */
  unread?: boolean | undefined;
  /**
   * Something small at the end of the row, before the shortcut: an unread count's `<Badge>`, or
   * words such as "Draft", which show small and muted. It takes the unread dot's place; screen
   * readers still hear `unreadLabel`.
   */
  mark?: ReactNode;
  /** Listed, but cannot be run. */
  disabled?: boolean | undefined;

  /** Not a prop. A command runs `onSelect`: `{ label: "New recipe", onSelect: create }`. (A `MenuItem` is the one that takes `onClick`.) */
  onClick?: { 'Use onSelect: { label: "New recipe", onSelect: create }': never };
}