Command
A keyboard-first command menu built on cmdk —
a filterable list of actions driven from a single input. Compose it inline
(Command.Root) or as a ⌘K-style modal palette (Command.Dialog), which wraps
the menu in Pho’s Dialog overlay.
Import
import { Command } from "@photon-ai/pho-ui/components/command";
Inline
Command.Root renders the menu in place. cmdk filters and ranks items against
the input automatically; Command.Empty shows when nothing matches. Give each
Command.Item a stable value (and optional keywords) to control matching.
<Command.Root label="Search">
<Command.InputGroup>
<IconSearch className="text-pho-secondary pointer-events-none absolute left-3 size-4" />
<Command.Input className="pl-9" placeholder="Search" />
</Command.InputGroup>
<Command.List>
<Command.Empty>No matching destinations.</Command.Empty>
<Command.Group heading="Pages">
<Command.Item value="Overview">Overview</Command.Item>
<Command.Item value="Members">Members</Command.Item>
</Command.Group>
</Command.List>
</Command.Root>
Dialog
Command.Dialog is the modal palette: Pho’s Dialog overlay (backdrop +
panel raised above center) around a Command.Root. Control it with
open / onOpenChange and wire your own ⌘K listener — the component doesn’t
register global shortcuts for you.
const [open, setOpen] = useState(false);
<Command.Dialog open={open} onOpenChange={setOpen} label="Search">
<Command.InputGroup>
<Command.Input placeholder="Search" />
</Command.InputGroup>
<Command.List>
<Command.Empty>No matching destinations.</Command.Empty>
<Command.Item value="Overview" onSelect={() => navigate("/")}>
Overview
</Command.Item>
</Command.List>
</Command.Dialog>;
Item hierarchy
Command.Item ships one shared layout for every palette. Pass level plus
icon / title — do not hand-roll indent or title weight. There is no
description line; use keywords / aliases for search matching later.
item— top destination (icon + title)subitem— under an item; label aligns with the item title (icon column reserved)subsubitem— under a subitem; one step deeper
<Command.Item
level="item"
value="imessage"
icon={<IconMessageCircle />}
title="iMessage"
/>
<Command.Item level="subitem" value="imessage-add" title="Add someone to the line" />
<Command.Item level="subsubitem" value="agent-avatar" title="Avatar" />
Give each item a unique value — cmdk matches selection by value, so Recent
duplicates of the same destination need distinct ids.
Search results
While the query is non-empty, drop groups and hierarchy. Render a flat list of
level="result" rows: leaf title, ancestor crumbs joined with a chevron-left,
and a trailing scope (Project or Personal) — not the idle nav group.
Resolve icon from the hit, then its parent, then grandparent. When any result
in the list has an icon, pass iconSlot on every row so labels stay aligned;
skip the column entirely when the whole set is icon-less.
<Command.Item
level="result"
value="imessage-add"
icon={<IconMessageCircle />}
iconSlot
title="Add someone to the line"
path={["iMessage"]}
scope="Project"
/>
// → [icon] Add someone to the line ‹ iMessage …… Project
List motion
Rows animate automatically, keyed by value. A row that stays visible while the
list changes glides to its new slot, and a row that remounts elsewhere with the
same value — an idle group entry becoming a flat search result — slides from
its old position instead of popping. Rows without a match simply appear or
disappear, and the list height follows on the same spring. Give the same target
the same value in both idle and search renders to opt in; motion is skipped
under prefers-reduced-motion.
The selection highlight is its own overlay, not a row background, and it sits exactly on the selected row at every moment. When typing reflows the list, the re-anchored row skips its travel animation and appears in place with the highlight already on it — no riding a moving row, no highlight hovering over empty space. Only selection moves within a stable list (arrow keys, hover) glide between rows.
Custom filtering
By default cmdk sorts results by match score, which reorders items. To keep a
fixed order (say, a page → section hierarchy), pass shouldFilter={false} and
render only the items that match your own query state — or pass a custom
filter that returns 1 / 0 to keep cmdk’s filtering without its ranking.
<Command.Root label="Search" shouldFilter={false}>
<Command.InputGroup>
<Command.Input value={query} onValueChange={setQuery} />
</Command.InputGroup>
<Command.List>
{targets
.filter((t) => matches(t, query))
.map((t) => (
<Command.Item key={t.id} value={t.id} onSelect={() => open(t)}>
{t.label}
</Command.Item>
))}
</Command.List>
</Command.Root>
Loading
Render Command.Loading while resolving async items.
<Command.List>
{loading && <Command.Loading>Fetching results…</Command.Loading>}
{results.map((result) => (
<Command.Item key={result.id} value={result.id}>
{result.label}
</Command.Item>
))}
</Command.List>