Command palette
ProSearch or jump: pages, threads, places and actions from one box (⌘K). Local groups filter as you type; remote groups are the service's results for the query, with a "Searching…" state while they're on the way and the failure said in words if the search failed. Closing it puts focus back where it was when it opened, even when a shortcut opened it. Choosing an item closes the palette, waits for focus to return, then runs it, so an action that moves focus (to a tab, a field) keeps it there.
Preview
Install
First time? Get started sets up the registry and theme. Or with shadcn directly: npx shadcn@latest add @candor/command-palette
Brings in: dialog, kbd, locale, use-shortcut, utils · npm: cmdk@^1.1.1, lucide-react@^1.67.0
Usage
"use client"
import * as React from "react"
import { Flag, GitBranch, MessageSquare, Plus, Settings, Table2 } from "lucide-react"
import { CommandPalette, CommandTrigger, matchEveryWord, type PaletteGroup } from "@/components/command-palette"
const recentThreads = [
{ id: "t1", label: "Add a region column to orders", detail: "Storefront · staging" },
{ id: "t2", label: "Why is checkout slow on Staging?", detail: "Storefront · staging" },
{ id: "t3", label: "Clean up stale feature flags", detail: "Storefront · Production" },
]
export default function Example() {
const [open, setOpen] = React.useState(false)
const [query, setQuery] = React.useState("")
const groups: PaletteGroup[] = [
{
heading: "Actions",
items: [
{ id: "new", label: "New thread", Icon: Plus, shortcut: "mod+shift+o", onSelect: () => {} },
{ id: "settings", label: "Open settings", Icon: Settings, onSelect: () => {} },
{ id: "deploy", label: "Promote Staging to Production", Icon: GitBranch, locked: "Only owners can promote to Production", onSelect: () => {} },
],
},
{
heading: "Places",
items: [
{ id: "flags", label: "Feature flags", Icon: Flag, keywords: ["toggles"], onSelect: () => {} },
{ id: "tables", label: "Tables", Icon: Table2, keywords: ["schema", "database"], onSelect: () => {} },
],
},
{
// A real app fetches these from its search service as the query changes; here they're filtered locally.
heading: "Threads",
remote: true,
items: query ? recentThreads.filter((t) => t.label.toLowerCase().includes(query.toLowerCase())).map((t) => ({ ...t, Icon: MessageSquare, onSelect: () => {} })) : [],
},
]
return (
<div className="max-w-64">
<CommandTrigger onClick={() => setOpen(true)} />
{/* matchEveryWord: every word typed must start a word of the item, in any order ("flags feature"). */}
<CommandPalette open={open} onOpenChange={setOpen} groups={groups} onQueryChange={setQuery} filter={matchEveryWord} />
</div>
)
}<CommandPalette>
| Prop | Type | Default | Description |
|---|---|---|---|
| openrequired | boolean | — | Whether the palette is showing. |
| onOpenChangerequired | (open: boolean) => void | — | Called when the palette opens or closes, including after an item is chosen. |
| groupsrequired | PaletteGroup[] | — | The items, under their headings; local groups filter as the person types. |
| onQueryChange | ((query: string) => void) | undefined | — | Called as the query changes, to fetch remote groups. |
| searching | boolean | undefined | false | Remote results are on the way; shows a spinner and "Searching…". Defaults to false. |
| searchError | string | undefined | — | Why the remote search failed, shown in place of "No results". |
| placeholder | string | undefined | — | Shown in the empty search box. Defaults to "Search or jump to…". |
| filter | ((value: string, search: string, keywords?: string[]) => number) | undefined | — | How local items match the query, passed to cmdk: `(value, search, keywords) => score`, 0 hiding the item. `keywords` holds the item's label, detail and keywords; `value` holds ids, so match on `keywords`. `matchEveryWord` is a strict "every word, any order" matcher. Defaults to cmdk's fuzzy match. |
| onCloseAutoFocus | ((event: Event) => void) | undefined | — | Called as the palette closes, just before focus returns to the element that had it when the palette opened. Call `event.preventDefault()` to put focus somewhere else yourself. |
<CommandTrigger>
The button that opens the palette, for a sidebar or top bar.
| Prop | Type | Default | Description |
|---|---|---|---|
| onClickrequired | () => void | — | Called when the button is pressed, usually to open the palette. |
| label | string | undefined | — | The button's text. Defaults to "Search or jump". |
| combo | string | undefined | "mod+k" | The shortcut printed on the button, e.g. "mod+k" (the default). Printed, not bound. |
| className | string | undefined | — | Extra classes for the outer element. |
Functions
matchEveryWord(_value: string, search: string, keywords?: string[])A strict match for CommandPalette's filter: every word typed must start a word of the item's label, detail or keywords, in any order ("plan open" finds "Open the plan"). Case and accents are ignored. It reads cmdk's keywords only, never its value, which holds the group and item ids. Returns 1 for a match, 0 otherwise, so cmdk keeps the items in the order you gave them.
Strings
The fixed words, in English by default. Change any of them under commandPalette in <LocaleProvider strings={{ … }}>; the table is exported as commandPaletteStrings.
| Key | English |
|---|---|
| title | Search or jump |
| description | Type to find pages, threads, places and actions. |
| placeholder | Search or jump to… |
| search | Search |
| results | Results |
| searchingStatus | Searching |
| searching | Searching… |
| nothingMatches | Nothing matches “{query}”. |
| nothingToShow | Nothing to show. |
| searchFailed | Search failed: {error} |
| noResults | No results. |
| move | move |
| open | open |
| close | close |
| trigger | Search or jump |
Types
- PaletteItem
{ id: string label: string /** Shown after the label: a place, a date, a path. */ detail?: string | undefined Icon?: LucideIcon | undefined /** Extra words that should find this item. */ keywords?: string[] | undefined /** A combo that also does this, e.g. "mod+shift+o". Printed, not bound. */ shortcut?: string | undefined /** Why it can't be done now; the item stays listed but can't be chosen. */- PaletteGroup
{ heading: string items: PaletteItem[] /** * Results the service already matched against the query (search across * threads, Know, the audit log). They're shown as given, not filtered again. */ remote?: boolean | undefined }