Command
A search field over a list of actions and places: type a few letters, press Enter, and the matching command runs.
- Type to filter: each word you type must appear in an item's name or its keywords. Try “python” or “publish”.
- ↑ and ↓ move the active item, wrap at the ends and pass over the disabled one; Enter runs it.
- Escape clears what you typed. In a CommandDialog, a second Escape closes the palette.
Field marks
- Import
import { CommandDialog, CommandGroup, CommandItem } from "cennge-ui";- Size
- 4.3 KB gzip for this import, its dependencies aside
- Pattern
- WAI-ARIA combobox with a listbox; focus stays in the search field
- Keyboard
- Arrow up Arrow down Enter EscCommand KCtrlK
- State
- Query with
queryandonQueryChange, the dialog withisOpenandonOpenChange, or both uncontrolled - SSR
- Rendered on the server in the Next.js check; hydrates without a warning
- Matching
- Every typed word, in the text or the keywords; case and accents ignored
- Shortcut
- ⌘K or Ctrl+K opens and closes the dialog;
shortcutsets another key, or none withfalse - Tests
- Unit tests; end-to-end in Chromium, Firefox and WebKit, on desktop and phone viewports: command
- Source
- src/components/command
When to use#
A Command palette is the fast way around an app for people who know what they want: open a repository, start a release, jump to settings, without a mouse. Put it in a CommandDialog that opens from anywhere with a shortcut, or place a Command in the page where a searchable list of actions belongs, like the plate above.
Keep everything in it reachable another way as well. A palette is quick for people who remember the words; menus and buttons show the rest what there is.
Similar#
- Combobox picks a value for a form field and keeps it; a Command runs the item and forgets it.
- Dropdown lists a few actions behind a button with nothing to type; a Command searches many of them.
A ⌘K palette#
CommandDialog puts the Command in a dialog that opens and closes with ⌘K on a Mac and Ctrl+K elsewhere, and closes after an item runs. On this site ⌘K already opens the search, which is a CommandDialog as well, so this palette listens for ⌘J with shortcut="mod+j"; shortcut={false} turns the shortcut off. When the palette closes, focus goes back to where it was before it opened, whether a button or the shortcut opened it.
or press Command J or CtrlJ
import { useState } from "react";
import { Button, CommandDialog, CommandGroup, CommandItem, Kbd, toast } from "cennge-ui";
const repositories = ["cennge-ui", "ai-niche-scout", "payload-cache", "spredo-crypto-screener", "playwright-msi-scraper"];
export default function CommandPalette() {
const [isOpen, setOpen] = useState(false);
return (
<>
<Button variant="flat" onClick={() => setOpen(true)}>
Open the palette
</Button>
<p className="text-sm text-muted">
or press <Kbd keys="command">J</Kbd> or <Kbd keys="ctrl">J</Kbd>
</p>
<CommandDialog
isOpen={isOpen}
onOpenChange={setOpen}
shortcut="mod+j"
label="Command palette"
placeholder="Search repositories and actions…"
>
<CommandGroup heading="Repositories">
{repositories.map((name) => (
<CommandItem key={name} onAction={() => toast(`Opened ${name}`)}>
{name}
</CommandItem>
))}
</CommandGroup>
<CommandGroup heading="Actions">
<CommandItem keywords={["create"]} onAction={() => toast("New repository")}>
New repository
</CommandItem>
<CommandItem keywords={["publish", "npm"]} onAction={() => toast("New release")}>
New release
</CommandItem>
</CommandGroup>
</CommandDialog>
</>
);
}Keywords and second lines#
An item is found by its text and by its keywords: words people might type that are not in the name. When its children are not a plain string, give the text to search as value. Here “ssh” finds Deploy keys and “invite” finds Collaborators. description adds a second line, which is shown but not searched.
import { Command, CommandItem, toast } from "cennge-ui";
const settings = [
{ name: "Branch protection", description: "Rules for main", keywords: ["main", "review", "merge"] },
{ name: "Deploy keys", description: "Read-only access for servers", keywords: ["ssh"] },
{ name: "Webhooks", description: "Events sent to other services", keywords: ["http", "events"] },
{ name: "Collaborators", description: "People with access", keywords: ["invite", "team", "access"] },
{ name: "Secrets", description: "Values for CI runs", keywords: ["token", "env", "npm"] },
];
export default function SettingsSearch() {
return (
<Command
label="Repository settings"
placeholder="Try ssh, invite or npm token"
className="w-full max-w-sm rounded-large border border-border bg-surface-3"
>
{settings.map((setting) => (
<CommandItem
key={setting.name}
description={setting.description}
keywords={setting.keywords}
onAction={() => toast(setting.name)}
>
{setting.name}
</CommandItem>
))}
</Command>
);
}When nothing matches#
emptyContent replaces “No results”, and with the query controlled it can repeat what was typed. Screen readers hear how many items match after each change, as “2 results”; labels.results words that announcement, here in releases.
import { useState } from "react";
import { Command, CommandItem, toast } from "cennge-ui";
const releases = ["0.3.1", "0.3.0", "0.2.0", "0.1.1", "0.1.0"];
export default function ReleaseSearch() {
const [query, setQuery] = useState("");
return (
<Command
label="Releases"
placeholder="Find a cennge-ui release"
query={query}
onQueryChange={setQuery}
emptyContent={`No release of cennge-ui matches “${query}”.`}
labels={{ results: (count) => (count === 1 ? "1 release" : `${count} releases`) }}
className="w-full max-w-sm rounded-large border border-border bg-surface-3"
>
{releases.map((version) => (
<CommandItem key={version} onAction={() => toast(`cennge-ui@${version}`)}>
{`cennge-ui@${version}`}
</CommandItem>
))}
</Command>
);
}Accessibility#
The search field is a combobox that controls a listbox, and the active item is marked with aria-activedescendant, so focus never leaves the field while the arrows move. A group is named by its heading and hides itself while none of its items match. After each change to the query, a polite live region says how many items are left.
Styling#
classNames reaches the parts: base, inputWrapper, input, list and empty. The plate gives the list a max-h-80, so it scrolls past a few items; className styles the outer element, here with a border and surface-3.
Props#
CommandDialog#
Command#
Also takes every attribute of <div>, except onChange, which mean something else here.
CommandItem#
Also takes every attribute of <div>, except onSelect, which mean something else here.
CommandGroup#
Also takes every attribute of <div>.