Skip to content

Command menu

The ⌘K palette: search, jump and run commands from the keyboard.

Status
Stable
Category
Navigation
Adoption
Not used yet
import { Command } from "@oration/canon/components/command";
packages/canon/src/components/command.tsx
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

The ⌘K palette opens and closes with no motion: pass data-no-motion on DialogContent. Highlight changes are instant too; only hover color eases.

The Hairline-and-Lift Rule

Inside a palette the input sits over a hairline and the footer under one. Groups split with CommandSeparator hairlines, not boxes.

Anatomy#

Search suppliers and actions

Actions

Ask Copilot⌘J
New payment run

Suppliers

Northwind Freight
↑↓to move↵to open
  1. 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.
  2. Group heading. CommandGroup heading: 12px medium Slate Meta, 8px from the left. Sentence case: Actions, Suppliers.
  3. 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.
  4. Shortcut. CommandShortcut, right-aligned 12px Slate Meta with wide tracking. Turns ink when its item is highlighted.
  5. Separator. CommandSeparator, a hairline that bleeds to the list edges.
  6. 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#

States
StateTreatment
RestItems show their label and icon in ink, details in Slate Meta.
HighlightedThe item under the arrow keys or the pointer fills Well Gray (data-selected). Its icon and shortcut turn ink.
CheckedPass data-checked on an item to show the trailing check, for multi-select pickers. Items with a shortcut never show it.
Disableddisabled items dim to 50%, ignore the pointer and are skipped by the arrow keys.
FilteredTyping hides items that don't match and reorders the rest by score. Groups with no matches hide.
EmptyCommandEmpty 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 any keywords. Give items a value with synonyms, as the palette does: Invite members teammates.
  • ↑ and ↓ move the highlight, Enter calls the item's onSelect, and pointer hover highlights too. loop wraps from the last item to the first.
  • In a dialog: compose Dialog, DialogContent and Command yourself. The palette passes data-no-motion, showCloseButton={false}, top-[16%] translate-y-0 p-0 gap-0 sm:max-w-xl, and a hidden DialogTitle and DialogDescription.
  • 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-0 to both the popover and the Command, and className="rounded-lg!" so the corners match.
  • CommandList scrolls at 18rem by default with its scrollbar hidden. The palette raises it to max-h-[min(60vh,26rem)].
  • shouldFilter={false} turns filtering off for async results you filter on the server.

Do and don't#

Do. Open the palette instantly, with no scale or fade, and keep the input focused.
Don't. Animate it in like a dialog. People hit ⌘K, type and press Enter in under a second; motion only gets in the way.
northwind

Suppliers

Northwind Freightnorthwindfreight.com

Invoices

INV-20931$18,240.00
INV-20940$7,480.00
Do. Group results under short headings, put the most likely group first, and show a trailing detail that tells similar items apart.
northwind
Northwind Freight
Northwind Freight
Northwind Freight
New payment run
Northwind Freight
Don't. Dump everything in one long ungrouped list, with three Northwind Freight rows and nothing to tell them apart.
Do. Write an empty state that says what can be searched: No matches. Try a supplier, invoice number or amount.
Don't. Leave it at No results found.

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 combobox that controls a listbox, with aria-activedescendant pointing at the highlighted option, so screen readers announce the highlight while focus stays in the input.
  • Give the input an accessible name: CommandInput has none of its own beyond the placeholder. Pass aria-label, or set label on Command.
  • In a dialog, render a DialogTitle and DialogDescription, 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.
Keyboard interactions
KeysAction
⌘KOpens the palette (wired by the app shell).
↓Highlights the next item.
↑Highlights the previous item.
HomeHighlights the first item. End goes to the last.
EnterRuns the highlighted item.
EscCloses the palette or popover.

Design tokens#

Design tokens
TokenUsed for
--popoverBackground
--mutedHighlighted item
--muted-foregroundHeadings, details and shortcuts
--inputThe standalone input's 30% fill and stroke
--borderSeparators and the palette's hairlines
--radius-lgItems inside a dialog
--radius-smItems elsewhere

API reference#

Command

The root. Owns the search value, filtering and highlight.

Other props spread onto cmdk Command (<div>).

Props of Command
PropTypeDefaultDescription
labelstringNo defaultAccessible label for the input.
loopbooleanfalseWraps the highlight from the last item to the first.
shouldFilterbooleantrueSet false to filter items yourself.
filter(value: string, search: string, keywords?: string[]) => numberNo defaultCustom ranking. Return 0 to hide, 1 for best.
valuestringNo defaultThe highlighted item's value, when controlled.
onValueChange(value: string) => voidNo defaultCalled when the highlight moves.
disablePointerSelectionbooleanfalseStops hover from moving the highlight.
vimBindingsbooleantrueCtrl+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>).

Props of CommandInput
PropTypeDefaultDescription
valuestringNo defaultThe search text, when controlled.
onValueChange(search: string) => voidNo defaultCalled as the search text changes.
placeholderstringNo defaultWhat 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.

Props of CommandGroup
PropTypeDefaultDescription
headingReact.ReactNodeNo defaultThe group heading.
forceMountbooleanNo defaultKeeps the group visible when nothing in it matches.

CommandItem

One action or record.

Other props spread onto cmdk Command.Item.

Props of CommandItem
PropTypeDefaultDescription
onSelect(value: string) => voidNo defaultRuns on Enter or click.
valuestringNo defaultThe text it's matched on. Defaults to its text content; add synonyms.
keywordsstring[]No defaultExtra terms it matches on.
disabledbooleanNo defaultDims it and skips it.
forceMountbooleanNo defaultKeeps it visible whatever the search.
data-checkedbooleanNo defaultShows 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.

Props of CommandDialog
PropTypeDefaultDescription
titlestring"Command Palette"Hidden dialog title.
descriptionstring"Search for a command to run..."Hidden dialog description.
showCloseButtonbooleanfalseDraws the dialog close button.
classNamestringNo defaultMerged onto DialogContent.
childrenRequiredReact.ReactNodeNo defaultUsually 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.