Command menu
The ⌘K palette: search, jump and run commands from the keyboard.
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandSeparator, CommandShortcut,} from "@oration/canon/components/command";import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@oration/canon/components/dialog";import { Kbd } from "@oration/canon/components/kbd";import { toast } from "@oration/canon/components/toast";import { Building2Icon, CalendarClockIcon, FileTextIcon, KeyboardIcon, LayoutDashboardIcon, MoonIcon, PlusIcon, SearchIcon, SendIcon, SparklesIcon, UserPlusIcon, WalletIcon,} from "lucide-react";import * as React from "react";export function Hero() { const [open, setOpen] = React.useState(false); const run = (title: string, description?: string) => { setOpen(false); toast.add({ title, description }); }; return ( <> <button type="button" onClick={() => setOpen(true)} className="flex h-8 w-64 items-center gap-2 rounded-[10px] bg-background px-2.5 text-13 text-muted-foreground shadow-border outline-none transition-shadow duration-150 ease-out hover:shadow-border-hover focus-visible:ring-3 focus-visible:ring-ring/40" > <SearchIcon aria-hidden="true" className="size-4" /> Search <Kbd className="ml-auto">⌘K</Kbd> </button> <Dialog open={open} onOpenChange={setOpen}> <DialogContent data-no-motion="" showCloseButton={false} className="top-[16%] max-w-[calc(100%-2rem)] translate-y-0 gap-0 overflow-hidden rounded-xl p-0 sm:max-w-xl" > <DialogTitle className="sr-only">Command menu</DialogTitle> <DialogDescription className="sr-only"> Search suppliers and invoices, jump to a page or run an action. </DialogDescription> <Command loop label="Search suppliers, invoices and actions" className="rounded-none! bg-transparent p-0 [&_[data-slot=command-input-wrapper]]:border-b [&_[data-slot=command-input-wrapper]]:border-border [&_[data-slot=command-input-wrapper]]:p-1.5 [&_[data-slot=input-group]]:h-10! [&_[data-slot=input-group]]:border-0! [&_[data-slot=input-group]]:bg-transparent!" > <CommandInput placeholder="Search suppliers, invoices and actions" className="text-sm" /> <CommandList className="max-h-[min(60vh,26rem)] p-1.5"> <CommandEmpty className="py-10 text-center text-sm text-muted-foreground"> No matches. Try a supplier, invoice number or amount. </CommandEmpty> <CommandGroup heading="Actions"> <CommandItem value="Ask Copilot assistant chat" onSelect={() => run("Copilot opened")} > <SparklesIcon aria-hidden="true" /> Ask Copilot <CommandShortcut>⌘J</CommandShortcut> </CommandItem> <CommandItem value="New payment run schedule pay" onSelect={() => run("New payment run started") } > <PlusIcon aria-hidden="true" /> New payment run </CommandItem> <CommandItem value="Request W-9 tax form supplier" onSelect={() => run( "W-9 request ready", "Choose a supplier to send it to.", ) } > <SendIcon aria-hidden="true" /> Request a W-9 </CommandItem> <CommandItem value="Invite members teammates approvers" onSelect={() => run("Opened Members")} > <UserPlusIcon aria-hidden="true" /> Invite members </CommandItem> <CommandItem value="Switch to dark theme appearance mode" onSelect={() => run("Switched to dark theme") } > <MoonIcon aria-hidden="true" /> Switch to dark theme </CommandItem> <CommandItem value="Keyboard shortcuts help" onSelect={() => run("Keyboard shortcuts")} > <KeyboardIcon aria-hidden="true" /> Keyboard shortcuts <CommandShortcut>?</CommandShortcut> </CommandItem> </CommandGroup> <CommandSeparator className="my-1" /> <CommandGroup heading="Pages"> <CommandItem value="Overview dashboard home" onSelect={() => run("Opened Overview")} > <LayoutDashboardIcon aria-hidden="true" /> Overview </CommandItem> <CommandItem value="Payment runs" onSelect={() => run("Opened Payment runs")} > <CalendarClockIcon aria-hidden="true" /> Payment runs </CommandItem> <CommandItem value="Remittances" onSelect={() => run("Opened Remittances")} > <WalletIcon aria-hidden="true" /> Remittances </CommandItem> </CommandGroup> <CommandSeparator className="my-1" /> <CommandGroup heading="Suppliers"> {[ [ "Northwind Freight", "northwindfreight.com", ], ["Halcyon", "halcyonlogistics.com"], ["Orchard Street", "orchardst.co"], ].map(([name, domain]) => ( <CommandItem key={name} value={`${name} ${domain} supplier`} onSelect={() => run(`Opened ${name}`)} > <Building2Icon aria-hidden="true" /> <span className="truncate">{name}</span> <span className="ml-auto text-xs text-muted-foreground"> {domain} </span> </CommandItem> ))} </CommandGroup> <CommandGroup heading="Invoices"> {[ [ "INV-20931", "Northwind Freight", "$18,240.00", ], ["INV-20932", "Halcyon", "$9,612.50"], [ "INV-20935", "Orchard Street", "$4,120.00", ], ].map(([id, supplier, amount]) => ( <CommandItem key={id} value={`${id} ${supplier} ${amount} invoice`} onSelect={() => run(`Opened ${id}`)} > <FileTextIcon aria-hidden="true" /> <span className="truncate"> {id}, {supplier} </span> <span className="ml-auto text-xs text-muted-foreground tabular-nums"> {amount} </span> </CommandItem> ))} </CommandGroup> </CommandList> <div className="flex items-center gap-3 border-t border-border px-3 py-2 text-xs text-muted-foreground"> <span className="inline-flex items-center gap-1"> <Kbd>↑</Kbd> <Kbd>↓</Kbd> to move </span> <span className="inline-flex items-center gap-1"> <Kbd>↵</Kbd> to open </span> <span className="inline-flex items-center gap-1"> <Kbd>esc</Kbd> to close </span> </div> </Command> </DialogContent> </Dialog> </> );}Usage#
Command is a filterable list of actions and records driven from the keyboard, built on cmdk. In Oration it powers the ⌘K palette (search records, jump to a page, run an action) and the searchable pickers inside popovers, such as Filter by. Typing filters and ranks items, the arrow keys move a highlight and Enter runs the item. The palette opens and closes without motion, because it is a keyboard surface people open dozens of times a day. The common mistake is reaching for the ready-made CommandDialog: the product composes Dialog and Command itself to get the right width, position and no-motion behavior.
When to use
- For the ⌘K palette: one input that finds records, pages and actions across the workspace.
- For a searchable list inside a popover: Filter by, Assign to, Move to stage, Add a label.
- For an action menu on selected rows that's driven from the keyboard, such as the ticket action palette.
When not to use
- For choosing one value in a form field. It needs a field's label, value display and validation. Use Combobox
- For five or fewer actions on a button, with no search. Use Dropdown menu
- For a search box that filters a table on the page. Use Search field
- For picking from a short fixed list in a form. Use Select
Keyboard surfaces don't animate
data-no-motion on DialogContent. Highlight changes are instant too; only hover color eases.The Hairline-and-Lift Rule
CommandSeparator hairlines, not boxes.Anatomy#
Actions
Suppliers
- Input.
CommandInput: a search icon and a borderless input. In the palette it is 40px tall over a hairline; on its own it is a 32px tinted field. - Group heading.
CommandGroup heading: 12px medium Slate Meta, 8px from the left. Sentence case: Actions, Suppliers. - Item.
CommandItem: a 16px icon, a 14px label and an optional trailing detail. The highlighted item fills Well Gray. 10px corners inside a dialog, 6px elsewhere. - Shortcut.
CommandShortcut, right-aligned 12px Slate Meta with wide tracking. Turns ink when its item is highlighted. - Separator.
CommandSeparator, a hairline that bleeds to the list edges. - Footer. The palette's own row of key hints over a hairline: ↑ ↓ to move, ↵ to open, esc to close. Not part of the component; compose it with
Kbd.
Examples#
Inline
On its own, Command is a tinted search field over a list that scrolls at 18rem. Disabled items dim and are skipped by the arrow keys; trailing details sit at the right in Slate Meta.
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList,} from "@oration/canon/components/command";import { toast } from "@oration/canon/components/toast";export function Inline() { return ( <Command label="Move invoices to a stage" className="h-auto w-full max-w-sm shadow-border" > <CommandInput placeholder="Move to stage" /> <CommandList> <CommandEmpty className="text-muted-foreground"> No stage by that name. </CommandEmpty> <CommandGroup heading="Stages"> {[ "Received", "Needs approval", "Approved", "Scheduled", "Paid", ].map((stage) => ( <CommandItem key={stage} disabled={stage === "Paid"} onSelect={() => toast.add({ title: `3 invoices moved to ${stage}`, }) } > {stage} {stage === "Paid" ? ( <span className="ml-auto text-xs text-muted-foreground"> Set by the bank </span> ) : null} </CommandItem> ))} </CommandGroup> </CommandList> </Command> );}In a popover
The records toolbar's Filter: pass p-0 to the popover and the Command, swap the list for a field's options, and key the input so the search clears between levels. Selecting doesn't close the popover.
import { Button } from "@oration/canon/components/button";import { Checkbox } from "@oration/canon/components/checkbox";import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList,} from "@oration/canon/components/command";import { Popover, PopoverContent, PopoverTrigger } from "@oration/canon/components/popover";import { cn } from "@oration/canon/lib/utils";import { Building2Icon, CalendarClockIcon, ChevronLeftIcon, ListFilterIcon, UserPlusIcon,} from "lucide-react";import * as React from "react";export function InPopover() { const fields = [ { id: "supplier", label: "Supplier", icon: Building2Icon, options: [ "Northwind Freight", "Halcyon", "Orchard Street", "Pioneer Metals", ], }, { id: "approver", label: "Approver", icon: UserPlusIcon, options: [ "Maya Okafor", "Priya Raman", "Tomás Ferreira", "Jordan Lee", ], }, { id: "run", label: "Payment run", icon: CalendarClockIcon, options: ["PR-0410", "PR-0411", "PR-0412"], }, ]; const [open, setOpen] = React.useState(false); const [fieldId, setFieldId] = React.useState<string | null>(null); const [values, setValues] = React.useState<Record<string, string[]>>({}); const field = fields.find((f) => f.id === fieldId); const count = Object.values(values).reduce((n, v) => n + v.length, 0); return ( <Popover open={open} onOpenChange={(next) => { setOpen(next); if (!next) setFieldId(null); }} > <PopoverTrigger render={ <Button type="button" variant="ghost" size="sm" className={cn(!count && "text-muted-foreground")} /> } > <ListFilterIcon data-icon="inline-start" aria-hidden="true" /> Filter {count ? ( <span className="text-xs text-primary tabular-nums"> {count} </span> ) : null} </PopoverTrigger> <PopoverContent align="start" className="w-64 gap-0 p-0"> <Command className="rounded-lg! p-0"> {field ? ( <div className="flex items-center gap-1 border-b border-border px-1 py-1"> <Button type="button" variant="ghost" size="icon-xs" aria-label="Back to fields" onClick={() => setFieldId(null)} > <ChevronLeftIcon aria-hidden="true" /> </Button> <span className="text-13 font-medium"> {field.label} </span> </div> ) : null} <CommandInput key={fieldId ?? "fields"} aria-label={ field ? `Search ${field.label}` : "Filter by" } placeholder={ field ? `Search ${field.label.toLowerCase()}` : "Filter by" } /> <CommandList className="p-1"> <CommandEmpty className="py-4 text-[13px] text-muted-foreground"> No matches </CommandEmpty> {field ? ( <CommandGroup> {field.options.map((option) => { const checked = values[field.id]?.includes(option) ?? false; return ( <CommandItem key={option} value={option} onSelect={() => setValues((all) => { const current = all[field.id] ?? []; return { ...all, [field.id]: checked ? current.filter( (v) => v !== option, ) : [ ...current, option, ], }; }) } > <Checkbox checked={checked} tabIndex={-1} aria-hidden="true" className="pointer-events-none" /> {option} </CommandItem> ); })} </CommandGroup> ) : ( <CommandGroup> {fields.map((f) => ( <CommandItem key={f.id} value={f.label} onSelect={() => setFieldId(f.id)} > <f.icon aria-hidden="true" className="text-muted-foreground" /> {f.label} {values[f.id]?.length ? ( <span className="ml-auto text-xs text-muted-foreground tabular-nums"> {values[f.id]?.length} </span> ) : null} </CommandItem> ))} </CommandGroup> )} </CommandList> </Command> </PopoverContent> </Popover> );}Multiple selection
data-checked on an item shows the trailing check. The group heading carries the count, so the selection is read as text, not only as checks.
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList,} from "@oration/canon/components/command";import { toast } from "@oration/canon/components/toast";import * as React from "react";export function Checked() { const [approvers, setApprovers] = React.useState(["Priya Raman"]); const people = [ "Maya Okafor", "Priya Raman", "Tomás Ferreira", "Jordan Lee", "Aisha Bello", "Wen Zhou", ]; return ( <Command label="Choose approvers" className="h-auto w-full max-w-xs shadow-border" > <CommandInput placeholder="Choose approvers" /> <CommandList> <CommandEmpty className="text-muted-foreground"> Nobody by that name in Cedarline. </CommandEmpty> <CommandGroup heading={`${approvers.length} selected`}> {people.map((person) => { const on = approvers.includes(person); return ( <CommandItem key={person} data-checked={on} onSelect={() => { setApprovers((all) => on ? all.filter((p) => p !== person) : [...all, person], ); toast.add({ title: on ? `${person} removed as an approver` : `${person} can approve payment runs`, }); }} > {person} </CommandItem> ); })} </CommandGroup> </CommandList> </Command> );}States#
| State | Treatment |
|---|---|
| Rest | Items show their label and icon in ink, details in Slate Meta. |
| Highlighted | The item under the arrow keys or the pointer fills Well Gray (data-selected). Its icon and shortcut turn ink. |
| Checked | Pass data-checked on an item to show the trailing check, for multi-select pickers. Items with a shortcut never show it. |
| Disabled | disabled items dim to 50%, ignore the pointer and are skipped by the arrow keys. |
| Filtered | Typing hides items that don't match and reorders the rest by score. Groups with no matches hide. |
| Empty | CommandEmpty renders only when nothing matches. Say what can be searched. |
Behavior#
- cmdk filters as you type, matching the item's
value(its text by default) and anykeywords. Give items avaluewith synonyms, as the palette does: Invite members teammates. - ↑ and ↓ move the highlight, Enter calls the item's
onSelect, and pointer hover highlights too.loopwraps from the last item to the first. - In a dialog: compose
Dialog,DialogContentandCommandyourself. The palette passesdata-no-motion,showCloseButton={false},top-[16%] translate-y-0 p-0 gap-0 sm:max-w-xl, and a hiddenDialogTitleandDialogDescription. - Selecting an item doesn't close anything. Close the dialog or popover in
onSelect, then navigate or run the action. - In a popover, pass
p-0to both the popover and the Command, andclassName="rounded-lg!"so the corners match. CommandListscrolls at 18rem by default with its scrollbar hidden. The palette raises it tomax-h-[min(60vh,26rem)].shouldFilter={false}turns filtering off for async results you filter on the server.
Do and don't#
Suppliers
Invoices
Content#
- Placeholder says what can be found: Search suppliers, invoices and actions. No ellipsis.
- Action items start with a verb: New payment run, Invite members, Switch to dark theme.
- Record items are the record's name, with one disambiguating detail on the right: a domain, an amount, an invoice number.
- Group headings are plural nouns: Actions, Pages, Suppliers, Invoices.
- Shortcuts use symbols: ⌘J, ?, G then I.
Accessibility#
- cmdk renders the input as a
comboboxthat controls alistbox, witharia-activedescendantpointing at the highlightedoption, so screen readers announce the highlight while focus stays in the input. - Give the input an accessible name:
CommandInputhas none of its own beyond the placeholder. Passaria-label, or setlabelonCommand. - In a dialog, render a
DialogTitleandDialogDescription, visually hidden, so the palette is announced by name. - Icons in items are decorative; the label carries the meaning. Shortcuts are read as text, so write them as people would say them where it matters.
- Opening without motion also serves people who prefer reduced motion.
| Keys | Action |
|---|---|
| ⌘K | Opens the palette (wired by the app shell). |
| ↓ | Highlights the next item. |
| ↑ | Highlights the previous item. |
| Home | Highlights the first item. End goes to the last. |
| Enter | Runs the highlighted item. |
| Esc | Closes the palette or popover. |
Design tokens#
| Token | Used for |
|---|---|
--popover | Background |
--muted | Highlighted item |
--muted-foreground | Headings, details and shortcuts |
--input | The standalone input's 30% fill and stroke |
--border | Separators and the palette's hairlines |
--radius-lg | Items inside a dialog |
--radius-sm | Items elsewhere |
API reference#
Command
The root. Owns the search value, filtering and highlight.
Other props spread onto cmdk Command (<div>).
| Prop | Type | Default | Description |
|---|---|---|---|
label | string | No default | Accessible label for the input. |
loop | boolean | false | Wraps the highlight from the last item to the first. |
shouldFilter | boolean | true | Set false to filter items yourself. |
filter | (value: string, search: string, keywords?: string[]) => number | No default | Custom ranking. Return 0 to hide, 1 for best. |
value | string | No default | The highlighted item's value, when controlled. |
onValueChange | (value: string) => void | No default | Called when the highlight moves. |
disablePointerSelection | boolean | false | Stops hover from moving the highlight. |
vimBindings | boolean | true | Ctrl+N, Ctrl+P, Ctrl+J and Ctrl+K move the highlight. |
CommandInput
The search field, with a search icon.
Other props spread onto cmdk Command.Input (<input>).
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | No default | The search text, when controlled. |
onValueChange | (search: string) => void | No default | Called as the search text changes. |
placeholder | string | No default | What can be searched. |
CommandList
The scrolling list. 18rem tall at most by default.
Other props spread onto cmdk Command.List.
No props of its own.
CommandEmpty
Shown only when nothing matches.
Other props spread onto cmdk Command.Empty.
No props of its own.
CommandGroup
A titled group of items.
Other props spread onto cmdk Command.Group.
| Prop | Type | Default | Description |
|---|---|---|---|
heading | React.ReactNode | No default | The group heading. |
forceMount | boolean | No default | Keeps the group visible when nothing in it matches. |
CommandItem
One action or record.
Other props spread onto cmdk Command.Item.
| Prop | Type | Default | Description |
|---|---|---|---|
onSelect | (value: string) => void | No default | Runs on Enter or click. |
value | string | No default | The text it's matched on. Defaults to its text content; add synonyms. |
keywords | string[] | No default | Extra terms it matches on. |
disabled | boolean | No default | Dims it and skips it. |
forceMount | boolean | No default | Keeps it visible whatever the search. |
data-checked | boolean | No default | Shows the trailing check. |
CommandShortcut
A right-aligned key hint inside an item.
Other props spread onto <span>.
No props of its own.
CommandSeparator
A hairline between groups.
Other props spread onto cmdk Command.Separator.
No props of its own.
CommandDialog
A ready-made Dialog around its children. Used once in the product; the ⌘K palette doesn't use it.
Other props spread onto Dialog root props.
| Prop | Type | Default | Description |
|---|---|---|---|
title | string | "Command Palette" | Hidden dialog title. |
description | string | "Search for a command to run..." | Hidden dialog description. |
showCloseButton | boolean | false | Draws the dialog close button. |
className | string | No default | Merged onto DialogContent. |
childrenRequired | React.ReactNode | No default | Usually a Command. |
Known gaps#
Where the implementation and the system disagree today. Follow the system, not the gap.
CommandDialog renders its hidden title and description outside DialogContent, so they sit in the page whether or not the dialog is open, and it doesn't pass data-no-motion, so it animates. Its defaults, Command Palette and Search for a command to run..., break sentence case and the no-ellipsis rule.
Item corners are 6px outside a dialog and 10px inside one. DESIGN.md gives menu and select items 8px corners.
CommandList hides its scrollbar (no-scrollbar) with no fade at the clipped edge, against the Scroll Edge Rule.
CommandInput has no accessible name of its own; the product's palette relies on the placeholder.
The input group overrides use !important modifiers (h-8!, rounded-lg!, shadow-none!), so the palette has to override them with ! again.