Command palette
PortedA command palette: a dialog with a search field and results in groups, for the keyboard first. The arrow keys move the highlight, Enter chooses, ⌘ or Ctrl with Enter chooses for a new tab, Escape closes. The app opens it, for example on ⌘K. Put a CommandPaletteInput, a CommandPaletteList and a CommandPaletteFooter with key hints in it. Native <div> attributes and ref go to its CommandPalettePanel.
import { ChartLineIcon, GearIcon, HouseIcon, PlusIcon, UserPlusIcon, UsersIcon } from "@phosphor-icons/react";
import { useState, type ReactNode } from "react";
import { Button } from "@etched/monet/button";
import { CommandPaletteEmpty, CommandPaletteInput, CommandPaletteItem, CommandPaletteList, CommandPaletteResults, CommandPaletteRoot } from "@etched/monet/command-palette";
interface Command {
id: string;
label: string;
icon: ReactNode;
disabled?: boolean;
}
interface CommandGroup {
label: string;
items: Command[];
}
const commands: CommandGroup[] = [
{
label: "Actions",
items: [
{ id: "add-candidate", label: "Add candidate", icon: <UserPlusIcon /> },
{ id: "create-trace", label: "Create trace", icon: <PlusIcon /> },
{ id: "settings", label: "Open settings", icon: <GearIcon />, disabled: true },
],
},
{
label: "Pages",
items: [
{ id: "home", label: "Home", icon: <HouseIcon /> },
{ id: "pipeline", label: "Pipeline", icon: <ChartLineIcon /> },
{ id: "people", label: "People", icon: <UsersIcon /> },
],
},
];
export function Preview() {
const [open, setOpen] = useState(false);
return (
<>
<Button onClick={() => setOpen(true)}>Search commands</Button>
<CommandPaletteRoot open={open} onOpenChange={setOpen} items={commands[0]?.items ?? []} onSelect={() => setOpen(false)}>
<CommandPaletteInput placeholder="Type a command" />
<CommandPaletteList>
<CommandPaletteResults>
{(command: Command) => (
<CommandPaletteItem key={command.id} value={command} disabled={command.disabled}>
{command.label}
</CommandPaletteItem>
)}
</CommandPaletteResults>
<CommandPaletteEmpty />
</CommandPaletteList>
</CommandPaletteRoot>
</>
);
}Installation
import { CommandPaletteRoot, CommandPaletteDialog, CommandPaletteEmpty, CommandPaletteFooter, CommandPaletteGroup, CommandPaletteGroupLabel, CommandPaletteHighlightedText, CommandPaletteInput, CommandPaletteItem, CommandPaletteItems, CommandPaletteList, CommandPaletteLoading, CommandPalettePanel, CommandPaletteResultItem, CommandPaletteResults } from "@etched/monet/command-palette";Examples
Commands
Groups of commands. The app binds the shortcut: here ⌘K or Ctrl+K opens and closes it. The first result stays highlighted, so Enter runs the best match; ⌘ or Ctrl with Enter, or with a click, sets newTab. A disabled command shows, but does not run.
import { ChartLineIcon, GearIcon, HouseIcon, PlusIcon, UserPlusIcon, UsersIcon } from "@phosphor-icons/react";
import { useEffect, useState, type ReactNode } from "react";
import { Button } from "@etched/monet/button";
import { Kbd, KbdGroup } from "@etched/monet/kbd";
import { CommandPaletteEmpty, CommandPaletteFooter, CommandPaletteGroup, CommandPaletteGroupLabel, CommandPaletteInput, CommandPaletteItems, CommandPaletteList, CommandPaletteResultItem, CommandPaletteResults, CommandPaletteRoot } from "@etched/monet/command-palette";
interface Command {
id: string;
label: string;
icon: ReactNode;
disabled?: boolean;
}
interface CommandGroup {
label: string;
items: Command[];
}
const commands: CommandGroup[] = [
{
label: "Actions",
items: [
{ id: "add-candidate", label: "Add candidate", icon: <UserPlusIcon /> },
{ id: "create-trace", label: "Create trace", icon: <PlusIcon /> },
{ id: "settings", label: "Open settings", icon: <GearIcon />, disabled: true },
],
},
{
label: "Pages",
items: [
{ id: "home", label: "Home", icon: <HouseIcon /> },
{ id: "pipeline", label: "Pipeline", icon: <ChartLineIcon /> },
{ id: "people", label: "People", icon: <UsersIcon /> },
],
},
];
const hints = (
<>
<span>
<KbdGroup>
<Kbd>↑</Kbd>
<Kbd>↓</Kbd>
</KbdGroup>
Navigate
</span>
<span>
<Kbd>↵</Kbd>
Open
</span>
</>
);
export function Commands() {
const [open, setOpen] = useState(false);
const [last, setLast] = useState("nothing yet");
useEffect(() => {
const toggle = (event: KeyboardEvent) => {
if (event.key.toLowerCase() === "k" && (event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey) {
event.preventDefault();
setOpen((was) => !was);
}
};
window.addEventListener("keydown", toggle);
return () => window.removeEventListener("keydown", toggle);
}, []);
return (
<div className="row">
<Button onClick={() => setOpen(true)} aria-keyshortcuts="Meta+K Control+K">
Search
<KbdGroup>
<Kbd>⌘</Kbd>
<Kbd>K</Kbd>
</KbdGroup>
</Button>
<span>Last run: {last}</span>
<CommandPaletteRoot
open={open}
onOpenChange={setOpen}
items={commands}
onSelect={(command: Command, { newTab }) => {
setLast(newTab ? `${command.label}, in a new tab` : command.label);
setOpen(false);
}}
>
<CommandPaletteInput placeholder="Type a command or search" />
<CommandPaletteList>
<CommandPaletteResults>
{(group: CommandGroup) => (
<CommandPaletteGroup key={group.label} items={group.items}>
<CommandPaletteGroupLabel>{group.label}</CommandPaletteGroupLabel>
<CommandPaletteItems>
{(command: Command) => (
<CommandPaletteResultItem key={command.id} value={command} title={command.label} icon={command.icon} disabled={command.disabled} />
)}
</CommandPaletteItems>
</CommandPaletteGroup>
)}
</CommandPaletteResults>
<CommandPaletteEmpty>No commands match.</CommandPaletteEmpty>
</CommandPaletteList>
<CommandPaletteFooter>
{hints}
<span>
<KbdGroup>
<Kbd>⌘</Kbd>
<Kbd>↵</Kbd>
</KbdGroup>
New tab
</span>
</CommandPaletteFooter>
</CommandPaletteRoot>
</div>
);
}Search results
Results the app finds itself, as Orbit's candidate search does: filter={null} shows them as they come, CommandPaletteLoading shows while it searches, and each result has a description line with the match marked. The footer counts the matches.
import { useEffect, useState } from "react";
import { Button } from "@etched/monet/button";
import { Kbd, KbdGroup } from "@etched/monet/kbd";
import { CommandPaletteEmpty, CommandPaletteFooter, CommandPaletteInput, CommandPaletteList, CommandPaletteLoading, CommandPaletteResultItem, CommandPaletteResults, CommandPaletteRoot, type HighlightRange } from "@etched/monet/command-palette";
interface Person {
id: string;
label: string;
description: string;
}
const people: Person[] = [
{ id: "ana", label: "Ana Ruiz", description: "Design engineer · Etched · linkedin.com/in/anaruiz" },
{ id: "jose", label: "José Núñez", description: "Silicon engineer · Etched" },
{ id: "sam", label: "Sam Lee", description: "Recruiter · Northwind" },
{ id: "kai", label: "Kai Ito", description: "Data scientist · Contoso" },
{ id: "lena", label: "Lena Park", description: "Product manager · Fabrikam" },
];
/** Where the query first appears in a text, as a range to mark. */
const match = (text: string, query: string): HighlightRange[] => {
const at = text.toLowerCase().indexOf(query.trim().toLowerCase());
return query.trim() && at >= 0 ? [[at, at + query.trim().length - 1]] : [];
};
const hints = (
<>
<span>
<KbdGroup>
<Kbd>↑</Kbd>
<Kbd>↓</Kbd>
</KbdGroup>
Navigate
</span>
<span>
<Kbd>↵</Kbd>
Open
</span>
</>
);
export function SearchResults() {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const [found, setFound] = useState<Person[]>([]);
const [searching, setSearching] = useState(false);
const [opened, setOpened] = useState("");
// A stand-in for a server search: results come 400 ms after the last key.
useEffect(() => {
const words = query.trim().toLowerCase();
setSearching(Boolean(words));
setFound([]);
if (!words) return;
const timer = setTimeout(() => {
setFound(people.filter((person) => `${person.label} ${person.description}`.toLowerCase().includes(words)));
setSearching(false);
}, 400);
return () => clearTimeout(timer);
}, [query]);
return (
<div className="row">
<Button
onClick={() => {
setQuery("");
setOpen(true);
}}
>
Find a candidate
</Button>
{opened && <span>Opened {opened}</span>}
<CommandPaletteRoot
open={open}
onOpenChange={setOpen}
aria-label="Find a candidate"
items={found}
filter={null}
value={query}
onValueChange={setQuery}
onSelect={(person: Person) => {
setOpened(person.label);
setOpen(false);
}}
>
<CommandPaletteInput placeholder="Search candidates by name, role or company" />
<CommandPaletteList>
{searching ? (
<CommandPaletteLoading />
) : (
<>
<CommandPaletteResults>
{(person: Person) => (
<CommandPaletteResultItem key={person.id} value={person} title={person.label} titleHighlights={match(person.label, query)} description={person.description} />
)}
</CommandPaletteResults>
<CommandPaletteEmpty>{query.trim() ? "No candidates match." : "Type a name, a role or a company."}</CommandPaletteEmpty>
</>
)}
</CommandPaletteList>
<CommandPaletteFooter end={query.trim() && !searching ? `${found.length} ${found.length === 1 ? "match" : "matches"}` : undefined}>{hints}</CommandPaletteFooter>
</CommandPaletteRoot>
</div>
);
}Two steps
CommandPaletteDialog with a CommandPalettePanel that changes: choose a person, then the trace to add them to. The dialog stays open, and the second step's search takes focus.
import { useState } from "react";
import { Button } from "@etched/monet/button";
import { Kbd } from "@etched/monet/kbd";
import { CommandPaletteDialog, CommandPaletteEmpty, CommandPaletteFooter, CommandPaletteInput, CommandPaletteList, CommandPalettePanel, CommandPaletteResultItem, CommandPaletteResults } from "@etched/monet/command-palette";
interface Person {
id: string;
label: string;
description: string;
}
const people: Person[] = [
{ id: "ana", label: "Ana Ruiz", description: "Design engineer · Etched · linkedin.com/in/anaruiz" },
{ id: "jose", label: "José Núñez", description: "Silicon engineer · Etched" },
{ id: "sam", label: "Sam Lee", description: "Recruiter · Northwind" },
{ id: "kai", label: "Kai Ito", description: "Data scientist · Contoso" },
{ id: "lena", label: "Lena Park", description: "Product manager · Fabrikam" },
];
const traces = [
{ id: "design", label: "Design engineering", description: "Hiring · 3 open roles" },
{ id: "silicon", label: "Silicon", description: "Hiring · 5 open roles" },
{ id: "research", label: "Research", description: "Uncategorized · 1 open role" },
];
export function TwoSteps() {
const [open, setOpen] = useState(false);
const [person, setPerson] = useState<Person | null>(null);
const [added, setAdded] = useState("");
return (
<div className="row">
<Button
onClick={() => {
setPerson(null);
setOpen(true);
}}
>
Add to a trace
</Button>
{added && <span>{added}</span>}
<CommandPaletteDialog open={open} onOpenChange={setOpen} aria-label="Add to a trace">
{person ? (
<CommandPalettePanel
key="trace"
items={traces}
onSelect={(trace) => {
setAdded(`Added ${person.label} to ${trace.label}`);
setOpen(false);
}}
>
<CommandPaletteInput placeholder={`Choose a trace for ${person.label}`} />
<CommandPaletteList>
<CommandPaletteResults>
{(trace: (typeof traces)[number]) => <CommandPaletteResultItem key={trace.id} value={trace} title={trace.label} description={trace.description} />}
</CommandPaletteResults>
<CommandPaletteEmpty>No traces match.</CommandPaletteEmpty>
</CommandPaletteList>
<CommandPaletteFooter end="Esc to cancel">
<span>
<Kbd>↵</Kbd>
Add to trace
</span>
</CommandPaletteFooter>
</CommandPalettePanel>
) : (
<CommandPalettePanel key="person" items={people} onSelect={setPerson}>
<CommandPaletteInput placeholder="Choose a person" />
<CommandPaletteList>
<CommandPaletteResults>
{(candidate: Person) => <CommandPaletteResultItem key={candidate.id} value={candidate} title={candidate.label} description={candidate.description} />}
</CommandPaletteResults>
<CommandPaletteEmpty>No people match.</CommandPaletteEmpty>
</CommandPaletteList>
<CommandPaletteFooter>
<span>
<Kbd>↵</Kbd>
Choose a trace next
</span>
</CommandPaletteFooter>
</CommandPalettePanel>
)}
</CommandPaletteDialog>
</div>
);
}In place
CommandPalettePanel without a dialog, in a card; it does not take focus on its own. Each result shows where it is (breadcrumbs), with the search's matches marked in the path and the title.
import { useState } from "react";
import { Kbd, KbdGroup } from "@etched/monet/kbd";
import { CommandPaletteEmpty, CommandPaletteFooter, CommandPaletteInput, CommandPaletteList, CommandPalettePanel, CommandPaletteResultItem, CommandPaletteResults, type HighlightRange } from "@etched/monet/command-palette";
interface Page {
id: string;
label: string;
path: string[];
}
const pages: Page[] = [
{ id: "notifications", label: "Notifications", path: ["Settings"] },
{ id: "members", label: "Members", path: ["Settings", "Team"] },
{ id: "design", label: "Design engineering", path: ["Tracer", "Pipeline"] },
{ id: "silicon", label: "Silicon", path: ["Tracer", "Pipeline"] },
{ id: "warm-paths", label: "Warm paths", path: ["Network"] },
];
/** Where the query first appears in a text, as a range to mark. */
const match = (text: string, query: string): HighlightRange[] => {
const at = text.toLowerCase().indexOf(query.trim().toLowerCase());
return query.trim() && at >= 0 ? [[at, at + query.trim().length - 1]] : [];
};
const hints = (
<>
<span>
<KbdGroup>
<Kbd>↑</Kbd>
<Kbd>↓</Kbd>
</KbdGroup>
Navigate
</span>
<span>
<Kbd>↵</Kbd>
Open
</span>
</>
);
export function InPlace() {
const [query, setQuery] = useState("");
const [last, setLast] = useState("nothing yet");
return (
<div className="card">
<CommandPalettePanel
items={pages}
value={query}
onValueChange={setQuery}
filter={(page, words) => [...page.path, page.label].join(" ").toLowerCase().includes(words.trim().toLowerCase())}
onSelect={(page) => setLast(page.label)}
>
<CommandPaletteInput placeholder="Go to a page" />
<CommandPaletteList>
<CommandPaletteResults aria-label="Pages">
{(page: Page) => (
<CommandPaletteResultItem
key={page.id}
value={page}
title={page.label}
breadcrumbs={page.path}
titleHighlights={match(page.label, query)}
breadcrumbHighlights={page.path.map((part) => match(part, query))}
/>
)}
</CommandPaletteResults>
<CommandPaletteEmpty />
</CommandPaletteList>
<CommandPaletteFooter end={`Last: ${last}`}>{hints}</CommandPaletteFooter>
</CommandPalettePanel>
</div>
);
}Accessibility
ArrowUp/ArrowDownmove the highlight. The first result stays highlighted, soEnterchooses the best match;⌘orCtrlwithEnterchooses for a new tab.Escapeor the esc key closes it, and focus goes back to what had it before.Tabstays in the dialog.- A
dialognamed "Command palette" or byaria-label. The search is acombobox; results areoptions in alistboxnamed "Results", ingroups named by their labels. - Focus stays in the search;
aria-activedescendantpoints to the highlight.CommandPaletteEmptyis a politestatus, andCommandPaletteLoadingannounces "Loading". - The arrows reach a disabled command, so screen readers hear it, but
Enterdoes not run it. - Open it yourself, for example on
⌘KorCtrl+K; it has no shortcut of its own.
API reference
CommandPaletteDialog
| Prop | Type | Default | Description |
|---|---|---|---|
| open * | boolean | – | |
| onOpenChange * | (open: boolean) => void | – | |
| aria-label | string | "Command palette" | Names the dialog. Pass translated text. |
| container | DialogProps["container"] | document.body | The element the dialog renders into, such as a shadow root. |
| children * | ReactNode | – | A CommandPalettePanel. Swap it for another to change steps without closing the dialog. |
CommandPaletteEmpty
| Prop | Type | Default | Description |
|---|---|---|---|
| className | string | – |
CommandPaletteGroupLabel
| Prop | Type | Default | Description |
|---|---|---|---|
| className | string | – |
CommandPaletteGroup
| Prop | Type | Default | Description |
|---|---|---|---|
| className | string | – |
CommandPaletteHighlightedText
| Prop | Type | Default | Description |
|---|---|---|---|
| text * | string | – | |
| highlights | readonly HighlightRange[] | – | Matches to mark, such as Fuse.js match indices. They may overlap. |
CommandPaletteInput
| Prop | Type | Default | Description |
|---|---|---|---|
| leading | ReactNode | a search icon | Before the field. |
| trailing | ReactNode | in a dialog, an esc key that closes it | After the field. |
| closeLabel | string | "Close (esc)" | Names the esc key. Pass translated text. |
CommandPaletteItem
| Prop | Type | Default | Description |
|---|---|---|---|
| className | string | – | |
| value * | T | – | The item, as it is in items. onSelect gets it. |
| children | ReactNode | – |
CommandPalettePanel
| Prop | Type | Default | Description |
|---|---|---|---|
| children * | ReactNode | – | CommandPaletteInput, CommandPaletteList and an optional CommandPaletteFooter. |
| items * | readonly TItem[] | readonly { items: readonly TItem[] }[] | – | The results: items, or groups that each have an items array (and anything else, such as a label). |
| value | string | – | The search text, when the app holds it (for example to search on a server). |
| onValueChange | (value: string) => void | – | Called with the search text on every change. |
| onItemHighlighted | (item: TItem | undefined) => void | – | Called when the highlight moves to an item, by the arrow keys or the pointer. |
| itemToStringValue | (item: TItem) => string | – | An item's text, to match the search. Items with a label need none. |
| filter | ((item: TItem, query: string) => boolean) | null | the item's text contains the search | Which items match the search text. null shows every item, when the app finds the results itself. |
| onSelect | (item: TItem, details: { newTab: boolean }) => void | – | Called when an item is chosen, with Enter or a click. With ⌘ or Ctrl held, newTab is true. |
CommandPaletteResultItem
| Prop | Type | Default | Description |
|---|---|---|---|
| className | string | – | |
| value * | T | – | The item, as it is in items. onSelect gets it. |
| title * | string | – | |
| description | ReactNode | – | A second line under the title, such as a role and company. |
| icon | ReactNode | – | Before the title, such as a Phosphor icon. |
| breadcrumbs | readonly string[] | – | Where the result is, shown before the title: ["Tracer", "Pipeline"]. |
| titleHighlights | readonly HighlightRange[] | – | Parts of the title that match the search. |
| breadcrumbHighlights | readonly (readonly HighlightRange[])[] | – | Parts of each breadcrumb that match the search, in the same order as breadcrumbs. |
| external | boolean | – | Marks a result that opens another site. |
| showArrow | boolean | true | An arrow at the end of the highlighted result. |
CommandPaletteResults
| Prop | Type | Default | Description |
|---|---|---|---|
| className | string | – |
CommandPaletteRoot
| Prop | Type | Default | Description |
|---|---|---|---|
| children * | ReactNode | – | CommandPaletteInput, CommandPaletteList and an optional CommandPaletteFooter. |
| items * | readonly TItem[] | readonly { items: readonly TItem[] }[] | – | The results: items, or groups that each have an items array (and anything else, such as a label). |
| value | string | – | The search text, when the app holds it (for example to search on a server). |
| onValueChange | (value: string) => void | – | Called with the search text on every change. |
| onItemHighlighted | (item: TItem | undefined) => void | – | Called when the highlight moves to an item, by the arrow keys or the pointer. |
| itemToStringValue | (item: TItem) => string | – | An item's text, to match the search. Items with a label need none. |
| filter | ((item: TItem, query: string) => boolean) | null | the item's text contains the search | Which items match the search text. null shows every item, when the app finds the results itself. |
| onSelect | (item: TItem, details: { newTab: boolean }) => void | – | Called when an item is chosen, with Enter or a click. With ⌘ or Ctrl held, newTab is true. |
| open * | boolean | – | |
| onOpenChange * | (open: boolean) => void | – | |
| aria-label | string | "Command palette" | Names the dialog. Pass translated text. |
| container | DialogProps["container"] | document.body | The element the dialog renders into, such as a shadow root. |