Command palette
FreeA searchable list of destinations and actions opened from the keyboard.
pnpm dlx shadcn@latest add https://caliperui.site/r/command-palette.jsonPress the button, then try “inv”, “#ostra”, “@ra” or “>dark”.
When to use #
Use it for
- Apps with many places and commands.
- Power users who prefer typing to clicking.
Skip it when
- The app has five screens; plain navigation is faster.
Decisions #
Why it is built this way, so you know what to keep when you change your copy.
Behaviour
- Escape steps back before it closes
- A typed prefix becomes a scope chip; Backspace or Escape removes the scope first, and only then does Escape close the palette.
- The preview gives way on small screens
- The preview pane for the highlighted item hides under 768px, so the list keeps its full width on phones.
Usage
- Offer a way out when nothing matches
- Pass an emptyAction, such as creating the thing that was searched for, so an empty result is a next step rather than a dead end.
Installation #
pnpm dlx shadcn@latest add https://caliperui.site/r/command-palette.json
Adds its 5 files to components/ui/, each with its own stylesheet, and installs their packages. No account needed. The first part also adds Caliper UI’s tokens; import them once as in Installation.
Usage #
This is the code behind the preview above.
import { FileText, FolderOpen, Home, Inbox, Search as SearchIcon, Settings } from "lucide-react" import { useRef, useState } from "react" import { Button } from "@/components/ui/button" import { CommandPalette } from "@/components/ui/command-palette" import { Search } from "@/components/ui/search" export function CommandDemo() { const [open, setOpen] = useState(false); const [result, setResult] = useState("Press the button, then try “inv”, “#ostra”, “@ra” or “>dark”."); const trigger = useRef<HTMLButtonElement>(null); const run = (text: string) => () => setResult(text); const items = [ { id: "billing", label: "Billing", group: "Go to", meta: "G B", icon: <Home aria-hidden="true" />, preview: { kind: "Page", title: "Billing", rows: [["Plan", "Team · 14 seats"], ["Next bill", "$1,284.00 · 1 Nov"]] as [string, string][], hint: "Opens Settings → Billing." }, onSelect: run("Opened Billing.") }, { id: "inv-0417", label: "INV-0417 · Halden Analytics", group: "Invoices", meta: "$4,280.00", icon: <FileText aria-hidden="true" />, preview: { kind: "Invoice", title: "INV-0417", rows: [["Customer", "Halden Analytics"], ["Amount", "$4,280.00"], ["Status", "Paid 2 Oct"]] as [string, string][], hint: "Opens the invoice." }, onSelect: run("Opened INV-0417.") }, { id: "inv-0416", label: "INV-0416 · Ostra Freight", group: "Invoices", meta: "$12,960.50", icon: <FileText aria-hidden="true" />, preview: { kind: "Invoice", title: "INV-0416", rows: [["Customer", "Ostra Freight"], ["Amount", "$12,960.50"], ["Status", "Due 14 Oct"]] as [string, string][], hint: "Reminder set for 11 Oct." }, onSelect: run("Opened INV-0416.") }, { id: "priya", label: "Priya Raman", group: "People", meta: "Admin", icon: <Inbox aria-hidden="true" />, preview: { kind: "Person", title: "Priya Raman", rows: [["Role", "Admin"], ["Local time", "14:32 Lisbon"]] as [string, string][], hint: "Opens the profile." }, onSelect: run("Opened Priya Raman.") }, { id: "rafael", label: "Rafael Abreu", group: "People", meta: "Finance", icon: <Inbox aria-hidden="true" />, preview: { kind: "Person", title: "Rafael Abreu", rows: [["Role", "Finance"], ["Local time", "10:32 São Paulo"]] as [string, string][] }, onSelect: run("Opened Rafael Abreu.") }, { id: "create", label: "Create invoice", group: "Actions", meta: "⌘I", icon: <FolderOpen aria-hidden="true" />, preview: { kind: "Action", title: "Create invoice", rows: [["Opens", "New invoice draft"], ["Numbering", "INV-0418 next"]] as [string, string][], hint: "Nothing is sent until you choose Send." }, onSelect: run("Started INV-0418.") }, { id: "dark", label: "Switch to dark mode", group: "Actions", meta: "⌘⇧L", icon: <Settings aria-hidden="true" />, keywords: ["theme"], preview: { kind: "Action", title: "Switch to dark mode", rows: [["Now", "Light"], ["After", "Dark, saved on this device"]] as [string, string][] }, onSelect: run("Switched to dark mode.") }, ]; return ( <div className="grid justify-items-center gap-3"> <Button ref={trigger} variant="secondary" onClick={() => setOpen(true)}><SearchIcon aria-hidden="true" />Search or run a command</Button> <p role="status" className="max-w-sm text-center text-sm text-muted-foreground">{result}</p> <CommandPalette open={open} onOpenChange={setOpen} items={items} scopes={[{ prefix: ">", label: "Actions", group: "Actions" }, { prefix: "@", label: "People", group: "People" }, { prefix: "#", label: "Invoices", group: "Invoices" }]} emptyAction={{ label: (query) => `Create invoice for “${query}”`, onSelect: (query) => { setOpen(false); setResult(`Started a draft invoice for “${query}”.`); } }} onCloseAutoFocus={(event) => { event.preventDefault(); trigger.current?.focus(); }} /> </div> ); }
API reference #
CommandPalette
component
Accessibility #
It exposes combobox, listbox, group, option and status semantics, with aria-hidden, aria-autocomplete, aria-expanded, aria-controls, aria-activedescendant and aria-label kept in step with state. Changes that matter are announced through a live region.
Motion #
It declares no animation in its component source. Any transition comes from its stylesheet, which uses the duration tokens that collapse to instant under prefers-reduced-motion.