Pho Design System

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.

<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>