Skip to main content

Command

A command palette that allows users to search and execute commands. Built on top of cmdk.

Command.DialogRoot owns the palette: its open state, its query text, and the ⌘K shortcut. Command.SearchTrigger wraps whatever control opens it and makes that control behave like a search field: typing or pasting into it opens the palette with your text already in the query. It renders no DOM of its own, so the appearance is always the child's: in a sidebar that child is Sidebar.SearchTrigger, which owns how the row looks in both panel states.

For a text field that filters options in place rather than opening a modal, the component is Combobox, not this one.

Search trigger example

Command.SearchTrigger gives its child the dialog wiring and the typing behavior. Give the button focus and start typing: the palette opens seeded with what you typed. Pasting works too.

import { Command } from "@ngrok/mantle/command";import {	CalculatorIcon,	CalendarIcon,	MagnifyingGlassIcon,	SmileyIcon,	UserIcon,} from "@phosphor-icons/react"; function SearchPalette() {	return (		<Command.DialogRoot>			<Command.SearchTrigger>				<button					type="button"					className="bg-form border-form text-strong flex h-9 w-full max-w-80 items-center gap-1.5 rounded-md border px-2.5 text-sm"				>					<MagnifyingGlassIcon className="text-muted size-5 shrink-0" />					<span className="text-placeholder min-w-0 flex-1 truncate text-left">						Search commands…					</span>				</button>			</Command.SearchTrigger>			<Command.DialogContent>				<Command.Input placeholder="Type a command or search..." />				<Command.List>					<Command.Empty>No results found.</Command.Empty>					<Command.Group heading="Suggestions">						<Command.Item>							<CalendarIcon />							<span>Calendar</span>						</Command.Item>						<Command.Item>							<SmileyIcon />							<span>Search Emoji</span>						</Command.Item>						<Command.Item>							<CalculatorIcon />							<span>Calculator</span>						</Command.Item>					</Command.Group>					<Command.Separator />					<Command.Group heading="Settings">						<Command.Item>							<UserIcon />							<span>Profile</span>						</Command.Item>					</Command.Group>				</Command.List>			</Command.DialogContent>		</Command.DialogRoot>	);}

note

The demos on this page pass keyboardShortcut={false} because this documentation site binds ⌘K to its own search palette and two palettes must not answer one chord. Leave it at its default in your app.

Inline example

Command.Root is the always-visible palette: the same list and filtering without a dialog around it.

import { Command } from "@ngrok/mantle/command";import { CalculatorIcon, CalendarIcon, SmileyIcon, UserIcon } from "@phosphor-icons/react"; <Command.Root className="rounded-lg border border-dialog shadow-lg bg-dialog md:min-w-112.5">	<Command.Input placeholder="Type a command or search..." />	<Command.List>		<Command.Empty>No results found.</Command.Empty>		<Command.Group heading="Suggestions">			<Command.Item>				<CalendarIcon />				<span>Calendar</span>			</Command.Item>			<Command.Item>				<SmileyIcon />				<span>Search Emoji</span>			</Command.Item>			<Command.Item>				<CalculatorIcon />				<span>Calculator</span>			</Command.Item>		</Command.Group>		<Command.Separator />		<Command.Group heading="Settings">			<Command.Item>				<UserIcon />				<span>Profile</span>			</Command.Item>		</Command.Group>	</Command.List></Command.Root>;

Button trigger example

When the trigger should not behave like a search field at all, use Command.DialogTrigger with your own element. It carries no typing behavior; wrap the element in Command.SearchTrigger instead when it should.

import { Button } from "@ngrok/mantle/button";import { Command } from "@ngrok/mantle/command";import { CalculatorIcon, CalendarIcon, SmileyIcon, UserIcon } from "@phosphor-icons/react"; function ButtonTriggerPalette() {	return (		<Command.DialogRoot>			<Command.DialogTrigger asChild>				<Button type="button" appearance="outlined" intent="neutral">					Open Command Dialog				</Button>			</Command.DialogTrigger>			<Command.DialogContent>				<Command.Input placeholder="Type a command or search..." />				<Command.List>					<Command.Empty>No results found.</Command.Empty>					<Command.Group heading="Suggestions">						<Command.Item>							<CalendarIcon />							<span>Calendar</span>						</Command.Item>						<Command.Item>							<SmileyIcon />							<span>Search Emoji</span>						</Command.Item>						<Command.Item>							<CalculatorIcon />							<span>Calculator</span>						</Command.Item>					</Command.Group>					<Command.Separator />					<Command.Group heading="Settings">						<Command.Item>							<UserIcon />							<span>Profile</span>						</Command.Item>					</Command.Group>				</Command.List>			</Command.DialogContent>		</Command.DialogRoot>	);}

Composition

Compose the parts of a Command together to build your own. Command.SearchTrigger and Command.DialogTrigger are alternatives; use one or the other, not both:

Command.DialogRoot├── Command.SearchTrigger├── Command.DialogTrigger└── Command.DialogContent    ├── Command.Input    └── Command.List        ├── Command.Empty        ├── Command.Group        │   └── Command.Item        │       └── Command.Shortcut        └── Command.Separator

Polymorphism

Command.Input, Command.Item, Command.Separator, and Command.Empty accept asChild, forwarded to the underlying cmdk primitive. Command.DialogTrigger accepts it too, forwarded to its Radix dialog trigger.

The common case is Command.Item asChild with an <a> or a router Link, so selecting a result navigates while the item keeps its filtering, keyboard selection, and highlight behavior. Command.DialogTrigger asChild is the other frequent one: it lets any element, usually a Button, open the command dialog.

Command.DialogRoot and Command.DialogContent own the dialog's state and panel, and Command.Shortcut is a plain <span> for key hints, so none of those take asChild. Command.SearchTrigger has no asChild prop for the opposite reason: cloning is all it does, so its single-element children is always the trigger and there is nothing to opt into.

warning

Command.Root, Command.List, and Command.Group advertise asChild through their cmdk props but throw at runtime (Cannot use 'in' operator to search for 'render' in div): cmdk's own slot handling does not accept a plain element child for these three. Style them with className instead.

The keyboard shortcut

Command.DialogRoot binds ⌘K on macOS and Ctrl+K everywhere else, and toggles the palette. You do not need a hotkey library, and you should not add one.

It lives on the root rather than on the trigger because the trigger can be unmounted while the shortcut must still work: a sidebar rendered as a mobile sheet unmounts its whole tree when the sheet is closed.

What it deliberately does:

  • Requires exactly the platform modifier. ⌘ on Apple platforms, Ctrl elsewhere; the two never substitute for each other, so macOS's native Ctrl+K ("kill to end of line") keeps working. ⌘⇧K and friends pass through to the browser.
  • Claims the event. Firefox binds ⌘K to its own search bar, so a shortcut that does not preventDefault() runs both actions.
  • Has exactly one owner per window. The first mounted root that wants the shortcut gets it; later roots queue and take over only when that one unmounts. Two palettes never open on one keypress.
  • Yields inside editors. The chord is ignored while focus is in a contenteditable host or a <textarea>, where ⌘K is already "insert link" or a chord prefix, and where Monaco and CodeMirror attach. It still fires from a plain <input>, including the palette's own Command.Input, so the chord closes what it opened.
  • Yields to a handler that already ran. Inside an open palette on Windows and Linux, cmdk's own vim bindings map Ctrl+K to "previous item" and claim the event, so the chord moves the selection instead of closing the palette. Escape, the close button, and clicking away all still dismiss it.

Pass keyboardShortcut={false} to bind the chord yourself with useCommandDialog(). Command.SearchTrigger then adds no aria-keyshortcuts, and the hook reports the same flag as keyboardShortcut so your trigger can drop its visible ⌘K hint too. The UI never advertises a binding mantle does not own.

Who owns the query

By default the palette owns its query text, which is what makes seeding work: the seed and the open state land in one state update, so Command.Input mounts with the character already in it rather than racing the dialog's mount. Every open starts from a known query (the seed, or empty), so dismissing and reopening never resurrects the previous search.

Read it with useCommandDialog() when your palette does its own matching:

import { Command, useCommandDialog } from "@ngrok/mantle/command"; function EndpointResults() {	const { query } = useCommandDialog();	const endpoints = useEndpointSearch(query); 	return (		<Command.List>			<Command.Empty>No results found.</Command.Empty>			{endpoints.map((endpoint) => (				<Command.Item key={endpoint.id}>{endpoint.hostport}</Command.Item>			))}		</Command.List>	);} function EndpointPalette() {	return (		<Command.DialogRoot>			<Command.SearchTrigger>				<button type="button">Search endpoints…</button>			</Command.SearchTrigger>			<Command.DialogContent shouldFilter={false}>				<Command.Input placeholder="Search endpoints…" />				<EndpointResults />			</Command.DialogContent>		</Command.DialogRoot>	);}

Passing value to Command.Input takes the query over completely: your value wins, the hook stops tracking the field, and seeding becomes yours to apply. Prefer the hook.

In a sidebar

In a sidebar the search row is a Sidebar.SearchTrigger: the same row chrome as a Sidebar.ItemButton, with a ⌘K hint that appears on hover or keyboard focus and is otherwise invisible. It is deliberately a row and not a text field (a search entry point in a sidebar is one of the rows, not a form control wedged among them), which is also why it becomes the same icon chip as its neighbors once the panel collapses to the rail. All of that is owned by Sidebar, where the rail lives, and Command.SearchTrigger supplies only the behavior. Wrap the pair in Sidebar.Tooltip so the row still has a visible label in the rail.

Collapse the panel with the toggle to watch the row become a chip. The preview is its own document, so ⌘K works inside it.

The row goes in Sidebar.Body rather than Sidebar.Header so it shares the item rows' column and collapses into the same icon stack. Sidebar.SearchTrigger marks the shortcut hint aria-hidden (Command.SearchTrigger's aria-keyshortcuts already announces the chord) and drops it entirely in the rail, where the label is clipped rather than removed so the button keeps its accessible name as a chip.

Both searchActionFromKeyDown and searchActionFromPaste are exported for a trigger that cannot use Command.SearchTrigger at all; they take the native event (event.nativeEvent), because React's synthetic keyboard event does not carry isComposing.

warning

Render Command.DialogRoot above Sidebar.Nav, not inside it. Below the sidebar's mobileBreakpoint the panel is a Sheet: it unmounts its subtree when it closes, and a root inside it takes the ⌘K binding with it. The trigger can stay inside the sheet; it opens the palette and not the sheet. A palette opened over the open sheet still stacks a dialog on a dialog: two focus traps, and two scroll locks that unwind in an order neither owns. Close the sheet as the palette opens (const { setOpenMobile } = useSidebar() and call setOpenMobile(false) from the root's onOpenChange), or render the trigger outside the sheet at those widths. When the sheet closes, the row inside it unmounts, so focus does not return to the row when the palette closes.

Common mistakes

Do not bind ⌘K yourself. Command.DialogRoot does it, and it handles the parts a hand-rolled listener usually misses: claiming the event, keeping the two platform modifiers distinct, one owner per window, and the editor carve-out. If you must own the chord, turn mantle's off explicitly:

// ❌ Two owners for one chord — mantle's shortcut is still bounduseHotkeys("mod+k", () => setOpen(true), { preventDefault: true });<Command.DialogRoot open={open} onOpenChange={setOpen}> // ✅ One owneruseHotkeys("mod+k", () => setOpen(true), { preventDefault: true });<Command.DialogRoot keyboardShortcut={false} open={open} onOpenChange={setOpen}>

Do not make the trigger a real text field. A trigger that opens a modal palette is a <button>; that is why Command.SearchTrigger's child stays one and announces "has popup dialog". A <input readonly> that looks editable, or a role="combobox" without the combobox keyboard contract, both promise an interaction the palette does not implement. If you want results to filter in place under a real field, the component is Combobox.

API Reference

The Command component is built on top of cmdk and ships a complete set of sub-components for command palettes.

Command.Root

The inline palette, wrapping Command.Input and Command.List. It owns the filtering and the active item for every part below it — Command.DialogContent renders one itself, so a modal palette does not add it.

All props from cmdk's Command Root.

Command.DialogRoot

The state owner for the Command dialog. Owns the open state, the query text, and the ⌘K shortcut, and renders no DOM of its own.

PropTypeDefaultDescription
children?ReactNodeThe palette's parts, and any consumer of useCommandDialog().
defaultOpen?booleanfalseThe initial open state for the uncontrolled case.
keyboardShortcut?booleantrueBind ⌘K / Ctrl+K to toggle the palette. See The keyboard shortcut.
open?booleanControlled open state. Pair with onOpenChange.
onOpenChange?(open: boolean) => voidCalled with the next open state whenever it changes.
modal?booleantrueForwarded to Radix's Dialog.Root.

Command.SearchTrigger

Makes any control behave like a search field that opens the palette. It renders no DOM of its own — it clones its child — so presentation belongs entirely to the child, and this part never has an opinion about how the trigger looks or how it behaves in a collapsed sidebar rail.

PropTypeDefaultDescription
childrenReactElementThe control that opens the palette. A single element, required — there is nothing else to render. In a sidebar, a Sidebar.SearchTrigger.

What it contributes to that child:

  • aria-haspopup="dialog", aria-expanded, aria-controls, and focus restoration on close, from the Dialog.Trigger it composes. Inside a Sheet (a Sidebar.Nav below its mobileBreakpoint) the trigger still binds to Command.DialogRoot, because Sheet parts read a dialog scope of their own.
  • data-state="open" | "closed", stamped from the palette's own state rather than inherited, so wrapping this in another cloning trigger cannot replace it.
  • aria-keyshortcuts for the ⌘K binding — present only when Command.DialogRoot owns the shortcut, so it can never advertise a binding that is not bound. The server cannot know the platform, so the server HTML and the hydration render carry Control+K. On an Apple host, React corrects it to Meta+K once after hydration. A trigger that mounts on the client after hydration renders the right chord in its first render, like MetaKey. Pass your own aria-keyshortcuts to announce a chord you bound yourself.
  • onKeyDown and onPaste, composed with the child's own handlers — the child's run first, and a preventDefault() there vetoes the open.
  • data-slot="command-search-trigger", joined ahead of the child's own slot.

It adds no accessible name: the child's visible label is the name, so the two cannot drift apart. Render the ⌘K hint inside the child as aria-hidden, since aria-keyshortcuts already announces the chord.

Data AttributeValueDescription
data-slot"command-search-trigger"Stable styling hook, joined ahead of the child's own slot — a sidebar row reads data-slot="command-search-trigger sidebar-search-trigger".
data-state"open" | "closed"The palette's open state. Mirrors aria-expanded; style the open trigger with data-state-open:. Stamped from the dialog's own state after the forwarded props, so composing this inside another Radix trigger (a Sidebar.Tooltip, say) cannot substitute that layer's data-state.

Command.DialogTrigger

A bare button that opens the Command dialog when clicked — the escape hatch for a trigger that should not look like a search field. Supports asChild for custom trigger elements.

It carries none of Command.SearchTrigger's behavior: no aria-keyshortcuts, and typing or pasting into it does nothing. Wrap your element in Command.SearchTrigger instead when it should behave like a search field.

All props from Radix UI's Dialog.Trigger.

Command.DialogContent

The visible content of the Command dialog. Renders inside the dialog portal and wraps the command palette UI.

PropTypeDefaultDescription
title?string"Command Palette"Accessible title for the dialog (visually hidden).
description?string"Search for a command to run..."Accessible description for the dialog (visually hidden).
className?stringClass name(s) to apply to the dialog content.
showCloseButton?booleantrueWhether to show the close button.
filter?(value: string, search: string) => numberCustom filter function for the command list.
shouldFilter?booleanWhether to enable built-in filtering of command items.

When the palette opens, this part takes over the dialog's initial focus so the caret lands at the end of a seeded query. Radix's own open-autofocus selects the focused field's whole value, which would make the first keystroke after a seeded open replace the seed instead of extending it. It finds the field by Command.Input's data-slot="command-input", and leaves Radix's default in place when there is no input or the input is disabled.

Command.Input

The palette's query field.

Inside a Command.DialogRoot the palette owns the query by default — see Who owns the query. Under a bare Command.Root it stays uncontrolled and cmdk owns the text. ref lands on the wrapper; className and rest props land on the <input>.

All other props from cmdk's Command Input.

Data AttributeValueDescription
data-slot"command-input-wrapper"The row holding the magnifier and the field.
data-slot"command-input"The <input> itself. Load-bearing beyond styling: Command.DialogContent finds the field by this slot to place the caret after a seeded query.

Command.List

The scrolling container for the palette's groups and items.

All props from cmdk's Command List.

Command.Empty

The empty-state message; it renders only when no item matches the query.

It renders nothing in the server HTML and in the hydration pass. Items register with cmdk in a layout effect, which never runs on the server, so the server counts zero matches for every list. A palette with no items shows its empty state one render after hydration.

All props from cmdk's Command Empty.

Command.Group

A labeled section of items; the heading prop names it.

All props from cmdk's Command Group.

Command.Item

One selectable row in the palette's list.

All props from cmdk's Command Item.

Command.Separator

A visual separator between command groups or items. Automatically hidden when there is an active search query.

All props from cmdk's Command Separator.

Command.Shortcut

Displays a keyboard shortcut hint within a command item. Renders as a styled span element.

All props from the HTML span element.

useCommandDialog

Reads the nearest Command.DialogRoot state. Throws when called outside one, so a trigger that opens nothing fails loudly instead of silently.

Reach for it to open the palette from somewhere other than its trigger — a sidebar row, an empty state, a "search everything for this" item — or to read the query for your own matching.

PropertyTypeDescription
openbooleanWhether the palette is open.
setOpen(open: boolean) => voidSet the open state. Opening through this resets the query to empty.
openWithQuery(query?: string) => voidOpen the palette with query already in Command.Input, or empty when called with no argument.
toggle() => voidToggle the palette. Opening resets the query; closing leaves it alone.
querystringThe current text of Command.Input. Only meaningful while the input is uncontrolled.
setQuery(query: string) => voidSet the query without touching the open state.
keyboardShortcutbooleanWhether this root owns ⌘K. Command.SearchTrigger reads it to decide whether to add aria-keyshortcuts; read it yourself to decide whether to draw a visible hint.
import { Command, useCommandDialog } from "@ngrok/mantle/command"; function SearchEverythingItem({ query }: { query: string }) {	const { openWithQuery } = useCommandDialog(); 	return (		<Command.Item onSelect={() => openWithQuery(query)}>			{`Search everything for “${query}”`}		</Command.Item>	);}

The label is one template literal rather than text around a {query} expression. Command.DialogRoot resets the query every time the palette opens, so a bare interpolation between two literal siblings makes React remove a text node — and a browser translation engine has reparented it first, so the removal throws and the page goes blank. One interpolated string is a lone child, which React writes through textContent instead. Browser translation covers the mechanism.

searchActionFromKeyDown

Classifies a keydown on a search trigger into a SearchAction: { type: "seed", query } for text, { type: "open" } for text intent that cannot be replayed, { type: "ignore" } for everything else. Pure, and takes the native event.

KeystrokeActionWhy
A single code point (e, 7, /, あ, 😀)"seed"Printable text. Counted by code point, so astral characters are one character rather than two and are not dropped.
An IME composition"open"A composition belongs to the element that started it and cannot be forwarded, so the palette opens empty rather than swallowing the key. Engines that route dead keys through the IME path (Chrome on macOS reports ⌥e as key: "Dead", keyCode: 229) land here too.
⌘, Ctrl, or Alt held"ignore"Shortcuts, not text. Alt is excluded even though macOS ⌥ produces characters, because seeding one would swallow Windows Alt accelerators.
Enter, Space"ignore"The button's own activation keys; the click that follows opens the palette with an empty query.
Any named key (Tab, ArrowDown, Dead, …)"ignore"Navigation and non-text keys, unless the engine also flags the key as IME processing — that row wins.

searchActionFromPaste

Classifies a paste on a search trigger. A paste is unambiguous search intent, so it always opens the palette, and seeds it whenever the clipboard holds text — never returns "ignore". Multi-line text is collapsed to one space-separated line, because the query is a one-line field. Pure, and takes the native event.

MetaKey

Renders the platform-appropriate meta key glyph (⌘ for macOS/iOS, ⌃ for other platforms) with a visually hidden "Command" or "Control" label. It detects the platform on mount and is SSR-safe, defaulting to ⌃ to avoid hydration mismatches. A MetaKey that mounts after hydration, such as inside a palette that opens later, renders the host's glyph in its first render.

Use it in keyboard shortcut hints and Command.Shortcut labels so each platform shows its own modifier key.

Control K
import { MetaKey } from "@ngrok/mantle/command"; <kbd className="bg-muted text-muted inline-flex h-5 items-center gap-1 rounded border px-1.5 font-mono text-[10px] font-medium">	<MetaKey /> K</kbd>;

useCommandState

A hook for accessing the command palette state.

All props from cmdk's useCommandState.