Skip to content
cennge-ui
GitHubv0.3.1, changelog

Command

A search field over a list of actions and places: type a few letters, press Enter, and the matching command runs.

cennge-uiReact UI library
ai-niche-scoutFinds niches worth building for
payload-cacheFastAPI cache service
spredo-crypto-screenerCrypto screener
playwright-msi-scraperScraper on Playwright
New repositoryN
New release
Archive repositoryNeeds admin rights
Settings
  1. Type to filter: each word you type must appear in an item's name or its keywords. Try “python” or “publish”.
  2. ↑ and ↓ move the active item, wrap at the ends and pass over the disabled one; Enter runs it.
  3. Escape clears what you typed. In a CommandDialog, a second Escape closes the palette.
Live: click the field and type.

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 query and onQueryChange, the dialog with isOpen and onOpenChange, 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; shortcut sets another key, or none with false
Tests
Unit tests; end-to-end in Chromium, Firefox and WebKit, on desktop and phone viewports: 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

dialog.tsx
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.

Branch protectionRules for main
Deploy keysRead-only access for servers
WebhooksEvents sent to other services
CollaboratorsPeople with access
SecretsValues for CI runs
keywords.tsx
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.

cennge-ui@0.3.1
cennge-ui@0.3.0
cennge-ui@0.2.0
cennge-ui@0.1.1
cennge-ui@0.1.0
empty.tsx
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#

PropTypeDefault
isOpen
boolean
–
defaultOpen
boolean
–
onOpenChange
(isOpen: boolean) => void
–
shortcut
string | falseKeyboard shortcut that opens and closes the palette anywhere on the page; "mod" is ⌘ on a Mac and Ctrl elsewhere. false turns it off.
"mod+k"
closeOnAction
booleanClose after an item runs.
true
label
stringNames the search field and the list.
"Commands"
placeholder
string
"Type a command or search…"
query
string
–
defaultQuery
string
–
onQueryChange
(query: string) => void
–
emptyContent
React.ReactNodeShown when nothing matches.
"No results"
loop
booleanArrow keys wrap from the last item to the first.
true
labels
Partial<CommandLabels>
–
autoFocus
boolean
–
classNames
Partial<Record<CommandSlots, string>>
–

Command#

PropTypeDefault
label
stringNames the search field and the list.
"Commands"
placeholder
string
"Type a command or search…"
query
string
–
defaultQuery
string
–
onQueryChange
(query: string) => void
–
emptyContent
React.ReactNodeShown when nothing matches.
"No results"
loop
booleanArrow keys wrap from the last item to the first.
true
labels
Partial<CommandLabels>
–
autoFocus
boolean
–
classNames
Partial<Record<CommandSlots, string>>
–

Also takes every attribute of <div>, except onChange, which mean something else here.

CommandItem#

PropTypeDefault
value
stringText to search; defaults to the children when they are a string.
–
keywords
string[]More words that find this item, e.g. synonyms.
–
onAction
() => void
–
isDisabled
boolean
–
icon
React.ReactNode
–
shortcut
React.ReactNodeShown on the right, e.g. <Kbd keys="command">N</Kbd>.
–
description
React.ReactNode
–

Also takes every attribute of <div>, except onSelect, which mean something else here.

CommandGroup#

PropTypeDefault
heading
React.ReactNode
–

Also takes every attribute of <div>.