Navigation
Command
Command palette: a filter input driving a keyboard-navigable listbox of grouped items, with empty and loading states and an optional modal dialog wrapper. Supports async sources (useCommandAsync + shouldFilter=false), custom scoring filters, nested pages (useCommandPages + CommandPage) and persistent recent searches (useCommandHistory).
Import
import { Command, CommandInput, CommandList, CommandGroup, CommandItem, CommandEmpty, CommandLoading, CommandDialog, CommandPage, CommandPagesProvider, useCommandAsync, useCommandHistory, useCommandPages } from "@zephora/react";Examples
Inline palette
Type to filter; ArrowUp/Down move the active item and Enter picks it.
Picked: none
const [last, setLast] = React.useState("none");
<Command>
<CommandInput placeholder="Type a command…" />
<CommandList>
<CommandEmpty>No results.</CommandEmpty>
<CommandGroup heading="Navigate">
<CommandItem value="home" onSelect={setLast}>Go home</CommandItem>
<CommandItem value="settings" onSelect={setLast}>Open settings</CommandItem>
</CommandGroup>
<CommandGroup heading="Actions">
<CommandItem value="new-file" keywords={["create"]} onSelect={setLast}>
New file
</CommandItem>
<CommandItem value="delete" disabled>Delete workspace</CommandItem>
</CommandGroup>
</CommandList>
</Command>
<p>Picked: {last}</p>Command dialog
CommandDialog hosts the palette in a centered modal overlay.
const [open, setOpen] = React.useState(false);
<Button onClick={() => setOpen(true)}>Open palette</Button>
<CommandDialog open={open} onOpenChange={setOpen}>
<Command>
<CommandInput placeholder="Search…" />
<CommandList>
<CommandEmpty>No results.</CommandEmpty>
<CommandItem value="docs" onSelect={() => setOpen(false)}>Open docs</CommandItem>
<CommandItem value="theme" onSelect={() => setOpen(false)}>Toggle theme</CommandItem>
</CommandList>
</Command>
</CommandDialog>Async search
useCommandAsync debounces the query and aborts stale requests. The server (here a 600ms fake endpoint) already filtered, so `shouldFilter={false}` keeps every returned item visible; CommandLoading shows while a search is in flight and suppresses CommandEmpty.
Picked: none
function searchProjects(query: string, signal: AbortSignal): Promise<string[]> {
// Call your API here and pass `signal` to fetch().
return fakeEndpoint(query, signal);
}
const [query, setQuery] = React.useState("");
const { items, loading } = useCommandAsync({ query, load: searchProjects });
<Command shouldFilter={false} query={query} onQueryChange={setQuery}>
<CommandInput placeholder="Search projects…" />
<CommandList>
{loading && <CommandLoading>Searching…</CommandLoading>}
<CommandEmpty>No project found.</CommandEmpty>
{items.map((name) => (
<CommandItem key={name} value={name}>{name}</CommandItem>
))}
</CommandList>
</Command>Nested pages
useCommandPages keeps a page stack; CommandPage renders its children only while its name is on top. Picking “Assign to…” pushes the people page, Backspace on an empty query pops back.
Assigned to: nobody
const [query, setQuery] = React.useState("");
const pages = useCommandPages();
<CommandPagesProvider value={pages}>
<Command query={query} onQueryChange={setQuery}>
<CommandInput
placeholder={pages.page === "root" ? "Type a command…" : "Assign to…"}
onKeyDown={(event) => {
if (event.key === "Backspace" && query === "" && pages.page !== "root") {
event.preventDefault();
pages.pop();
}
}}
/>
<CommandList>
<CommandEmpty>No results.</CommandEmpty>
<CommandPage name="root">
<CommandItem value="assign" onSelect={() => { pages.push("assign"); setQuery(""); }}>
Assign to…
</CommandItem>
<CommandItem value="rename">Rename issue</CommandItem>
</CommandPage>
<CommandPage name="assign">
<CommandItem value="ada" onSelect={() => { pages.reset(); setQuery(""); }}>
Ada Lovelace
</CommandItem>
<CommandItem value="grace" onSelect={() => { pages.reset(); setQuery(""); }}>
Grace Hopper
</CommandItem>
</CommandPage>
</CommandList>
</Command>
</CommandPagesProvider>API
Command props
| Prop | Type | Default | Description |
|---|---|---|---|
shouldFilter | boolean | true | Set to false to disable built-in filtering entirely (e.g. when a server performs the search) — every item stays visible in its render order. |
filter | (value: string, query: string, keywords?: string[]) => number | — | Custom scoring filter: return 0 to hide an item, any higher number to show it. Matched items are re-ranked by score (highest first) both visually and for keyboard navigation. When omitted, the default case-insensitive substring match is used and item order is preserved. |
loop | boolean | false | Wraps keyboard navigation from the last item to the first and back. |
query | string | — | Controlled query value (the CommandInput reads from it). |
onQueryChange | (query: string) => void | — | Called when the query changes. |
unstyled | boolean | false | Headless mode — inherited by all parts. |
CommandInput props
| Prop | Type | Default | Description |
|---|---|---|---|
…rest | Omit<InputHTMLAttributes<HTMLInputElement>, "value" | "onChange" | "size"> | — | Native input props (placeholder, autoFocus…). Value and onChange are managed by the Command root. |
CommandItem props
| Prop | Type | Default | Description |
|---|---|---|---|
value * | string | — | Unique value used for filtering, selection and identity. |
keywords | string[] | — | Extra strings matched by the filter. |
onSelect | (value: string) => void | — | Called when the item is picked (click or Enter). |
disabled | boolean | false | Disables selection and keyboard navigation. |
icon | ReactNode | — | Leading icon. |
shortcut | ReactNode | — | Trailing keyboard shortcut hint. |
CommandGroup props
| Prop | Type | Default | Description |
|---|---|---|---|
heading | ReactNode | — | Optional heading rendered above the group's items. |
CommandDialog props
| Prop | Type | Default | Description |
|---|---|---|---|
open | boolean | — | Controlled open state. |
defaultOpen | boolean | false | Initial open state when uncontrolled. |
onOpenChange | (open: boolean) => void | — | Called when the open state changes. |
aria-label | string | "Command palette" | Accessible name of the dialog. |
useCommandAsync(options) props
| Prop | Type | Default | Description |
|---|---|---|---|
options.query * | string | — | Current query — usually mirrored from the controlled <Command query> state. |
options.load * | (query: string, signal: AbortSignal) => Promise<T[]> | — | Fetches results for a query. The signal aborts when the query changes, when the hook unmounts, or when a pending debounce is superseded — pass it to fetch() and results from stale requests are dropped. |
options.debounceMs | number | 200 | Debounce before calling load. |
→ returns | { items: T[]; loading: boolean; error: unknown } | — | Latest results, in-flight flag (render <CommandLoading> while true) and the last load error, if any. |
useCommandHistory(key, max?) props
| Prop | Type | Default | Description |
|---|---|---|---|
key * | string | — | Storage namespace — the list persists in localStorage under zephora-cmd-<key>. SSR-safe: degrades to in-memory state when storage is unavailable. |
max | number | 5 | Maximum number of entries kept. |
→ returns | { recent: string[]; push: (value: string) => void; clear: () => void } | — | recent is most-recent-first and deduplicated; push records a search (moving an existing entry to the front); clear empties the history and its localStorage entry. |
useCommandPages(root?) props
| Prop | Type | Default | Description |
|---|---|---|---|
root | string | "root" | Name of the root page the stack starts (and resets) at. |
→ returns | CommandPagesState | — | { page, pages, push, pop, reset }: the active (topmost) page, the full stack (root first), and actions to enter a nested page, leave the current one (no-op at the root) or jump back to the root. Pass it to <CommandPagesProvider value> so <CommandPage name> parts can render only while active. |
Keyboard
| Key | Action |
|---|---|
ArrowDown / ArrowUp | Move the active item through matching results. |
Home / End | Jump to the first / last matching item. |
Enter | Picks the active item. |
Escape | Closes the CommandDialog. |