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-monoreponpx shadcn@latest add ourostack/ouro-ui/ouro-base#v1.3.10Then 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.10Or, with the @ouro namespace set up:
npx shadcn@latest add @ouro/ouro-base @ouro/commandIts 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.10import { 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
Menuinstead for the few actions on one thing, andComboboxto 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" />}. Whileloading, 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
loadingLabelbeside 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 filteringitemsitself and shows them as they are, so keep the last results initemsuntil new ones arrive.signalaborts when a newer query replaces this one or the palette closes: pass it tofetch, or drop the response whensignal.aborted, so a slow earlier response never overwrites a newer one. The app owns the request, and must setloadingback 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
unreadcommand'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
falsewhen 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 withopen. RemoveopenandonOpenChangeto 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 withtrigger. Removetriggerto control the palette withopenandonOpenChange.open: Not withtrigger. Removetriggerto control the palette withopenandonOpenChange.
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 areitems:items={[{ label: "New recipe", onSelect: create }]}.commands: Not a prop. The commands areitems:items={[{ label: "New recipe", onSelect: create }]}.isOpen: Not a prop. Ouro UI usesopenwithonOpenChange:<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 };
}