Command palette
The ⌘K dialog: grouped commands, fuzzy search, and the shortcut for each one.
A client component built on the upstream CommandDialog, which owns the focus trap, the listbox semantics, and the dialog title. The hotkey is a window keydown listener in an effect, matched on metaKey || ctrlKey so "mod" is ⌘ on mac and Ctrl elsewhere; it reads the current open state through a ref, so an inline onOpenChange never resubscribes it. Leave open and onOpenChange off to let the palette manage itself, or pass both to drive it from your own state. Each item's keywords are folded into its cmdk value, so a command is findable by words that never appear on screen. An id listed in recentIds is moved into a "Recent" group at the top rather than copied there — cmdk keys a row by its value, and the same value twice would highlight both rows at once — and a group left empty by the move is dropped. A shortcut may be a chord ("mod+k") or a sequence of them ("g d"), which renders as one keycap per chord. The matcher itself is the shared hotkeys lib, which is also what binds Compare to c on a chart toolbar: a held chord is ignored while it repeats, so leaning on the keys does not strobe the dialog, and an unmodified key never fires while the reader is typing into a field. className and any other props land on the command root inside the dialog, which is the element carrying data-slot="command-palette"; the dialog shell keeps its own sizing. The upstream dialog keeps its visually hidden title in the page even while closed, so a page that mounts a palette always carries that one heading.
Install
npx shadcn@latest add @vibra/command-paletteNeeds the @vibra registry in your components.json — set it up once.
Examples
"use client"
import * as React from "react"
import {
CreditCardIcon,
FileTextIcon,
LayoutGridIcon,
PlusIcon,
SearchIcon,
SettingsIcon,
UserPlusIcon,
UsersIcon,
} from "lucide-react"
import { Button } from "@/components/ui/button"
import { CommandPalette, type CommandGroup } from "@/components/ui/command-palette"
import { KbdShortcut } from "@/components/ui/kbd-shortcut"
export default function CommandPaletteDemo() {
const [open, setOpen] = React.useState(false)
const [ran, setRan] = React.useState<string | null>(null)
const groups: CommandGroup[] = React.useMemo(
() => [
{
heading: "Navigation",
items: [
{
id: "overview",
label: "Overview",
icon: <LayoutGridIcon />,
shortcut: "g o",
onSelect: () => setRan("Overview"),
},
{
id: "reports",
label: "Reports",
icon: <FileTextIcon />,
shortcut: "g r",
keywords: ["exports", "scheduled"],
onSelect: () => setRan("Reports"),
},
{
id: "customers",
label: "Customers",
icon: <UsersIcon />,
shortcut: "g c",
onSelect: () => setRan("Customers"),
},
{
id: "billing",
label: "Billing",
icon: <CreditCardIcon />,
description: "Plan, invoices, and payment method",
keywords: ["invoice", "plan", "card"],
onSelect: () => setRan("Billing"),
},
],
},
{
heading: "Actions",
items: [
{
id: "new-report",
label: "New report",
icon: <PlusIcon />,
shortcut: "mod+n",
onSelect: () => setRan("New report"),
},
{
id: "invite",
label: "Invite teammate",
icon: <UserPlusIcon />,
description: "Send an email invitation",
keywords: ["member", "seat", "user"],
onSelect: () => setRan("Invite teammate"),
},
{
id: "settings",
label: "Workspace settings",
icon: <SettingsIcon />,
shortcut: "mod+,",
onSelect: () => setRan("Workspace settings"),
},
],
},
],
[]
)
return (
<div className="flex w-full max-w-md flex-col gap-3">
<Button
variant="outline"
className="w-full justify-between font-normal"
onClick={() => setOpen(true)}
>
<span className="flex items-center gap-2 text-muted-foreground">
<SearchIcon />
Search commands
</span>
<KbdShortcut keys="mod+k" size="sm" />
</Button>
<CommandPalette
open={open}
onOpenChange={setOpen}
groups={groups}
recentIds={["reports", "invite"]}
footer={
<>
<span className="flex items-center gap-1.5">
<KbdShortcut keys="enter" size="sm" /> to run
</span>
<span className="flex items-center gap-1.5">
<KbdShortcut keys="esc" size="sm" /> to close
</span>
</>
}
/>
<p className="text-sm text-muted-foreground">
{ran ? `Ran ${ran}.` : "Open it from the button, or with the shortcut from anywhere."}
</p>
</div>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| groups | { heading: string; items: CommandItem[] }[] | — | The commands, in labelled groups, in the order they should appear. |
| open | boolean | — | Drives the dialog from your own state; leave it off and the palette owns it. |
| onOpenChange | (open: boolean) => void | — | Called by the hotkey, by a selection, and by every upstream dismissal. |
| placeholder | string | Type a command or search… | Placeholder for the search field. |
| hotkey | string | null | mod+k | The shortcut that opens and closes it from anywhere; null turns it off. |
| emptyMessage | string | No results found. | Shown when the search matches nothing. |
| title | string | Command palette | What the dialog is called for a screen reader — "Search" for a palette that only finds things. |
| description | string | Search for a command to run. | One line under the name, read out with it. |
| recentIds | string[] | — | Ids lifted into a "Recent" group at the top, in the order given. |
| footer | React.ReactNode | — | A hint row under the list, usually the keys that run and dismiss. |
| className | string | — | Classes for the command root inside the dialog; the rest of the props go there too. |
| CommandItem.id | string | — | Unique across every group. |
| CommandItem.label | string | — | The command itself, and the first thing the search matches. |
| CommandItem.description | string | — | A quiet second line under the label. |
| CommandItem.icon | React.ReactNode | — | Sits before the label; sized to 4 unless it sets its own size. |
| CommandItem.shortcut | string | — | A chord ("mod+n") or a sequence of them ("g d"), rendered as keycaps. |
| CommandItem.keywords | string[] | — | Extra words the command is findable by; they never show. |
| CommandItem.onSelect | () => void | — | Runs the command; the palette closes straight after. |
Dependencies
Source
"use client"
import * as React from "react"
import { matchesHotkey } from "@/lib/hotkeys"
import {
Command,
CommandDialog,
CommandEmpty,
// The palette's own CommandGroup and CommandItem are the data shapes callers
// hand in; the primitive uses those two names for components, so the
// components are the ones that get renamed here.
CommandGroup as CommandGroupList,
CommandInput,
CommandItem as CommandRow,
CommandList,
CommandShortcut,
useCommandState,
} from "@/components/ui/command"
import { KbdShortcut } from "@/components/ui/kbd-shortcut"
export type CommandItem = {
id: string
label: string
/** Sits before the label; sized to 4 unless it sets its own size. */
icon?: React.ReactNode
/** A chord ("mod+k") or a sequence of them ("g d"), rendered as keycaps. */
shortcut?: string
/** Extra words the item should be findable by; they never show. */
keywords?: string[]
onSelect: () => void
/** A quiet second line under the label. */
description?: string
}
export type CommandGroup = {
heading: string
items: CommandItem[]
}
export type CommandPaletteProps = React.ComponentProps<typeof Command> & {
open?: boolean
onOpenChange?: (open: boolean) => void
groups: CommandGroup[]
placeholder?: string
/** The shortcut that opens and closes it from anywhere; null turns it off. */
hotkey?: string | null
emptyMessage?: string
/** Ids to lift into a "Recent" group at the top, in the order given. */
recentIds?: string[]
footer?: React.ReactNode
/** What the dialog is called for a screen reader — "Search" for a palette that only finds things. */
title?: string
/** One line under the name, read out with it. */
description?: string
}
/** cmdk matches against the item's value, so the keywords are folded into it. */
function itemValue(item: CommandItem): string {
return [item.label, ...(item.keywords ?? [])].join(" ")
}
/**
* What the query left, said aloud: "12 results", "1 result", or the empty
* message. The rows repaint under a sighted reader as they type; a screen
* reader hears nothing of that without a live region, and cmdk's own empty
* state is `role="presentation"`. Inside the command root, because the count
* is read from cmdk's store.
*/
function CommandResultCount({ emptyMessage }: { emptyMessage: string }) {
const count = useCommandState((state) => state.filtered.count)
return (
<div data-slot="command-palette-status" role="status" aria-live="polite" className="sr-only">
{count === 0 ? emptyMessage : `${count} ${count === 1 ? "result" : "results"}`}
</div>
)
}
/** The ⌘K dialog: grouped commands, fuzzy search, and the shortcut for each one. */
function CommandPalette({
open,
onOpenChange,
groups,
placeholder = "Type a command or search…",
hotkey = "mod+k",
emptyMessage = "No results found.",
recentIds,
footer,
title = "Command palette",
description = "Search for a command to run.",
className,
...props
}: CommandPaletteProps) {
const [selfOpen, setSelfOpen] = React.useState(false)
const isOpen = open ?? selfOpen
const setOpen = React.useCallback(
(next: boolean) => {
// Uncontrolled palettes open themselves; controlled ones only report.
if (open === undefined) setSelfOpen(next)
onOpenChange?.(next)
},
[open, onOpenChange]
)
// The listener is attached once per hotkey and reads the current state
// through a ref, so an inline onOpenChange does not resubscribe it every
// render.
const latest = React.useRef({ isOpen, setOpen })
React.useEffect(() => {
latest.current = { isOpen, setOpen }
}, [isOpen, setOpen])
React.useEffect(() => {
if (!hotkey) return
const handleKeyDown = (event: KeyboardEvent) => {
if (!matchesHotkey(event, hotkey)) return
event.preventDefault()
latest.current.setOpen(!latest.current.isOpen)
}
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [hotkey])
const visibleGroups = React.useMemo(() => {
const recent = new Set(recentIds ?? [])
if (recent.size === 0) return groups
const byId = new Map(groups.flatMap((group) => group.items).map((item) => [item.id, item]))
const recentItems = (recentIds ?? [])
.map((id) => byId.get(id))
.filter((item): item is CommandItem => item !== undefined)
if (recentItems.length === 0) return groups
// Promoted, not copied: cmdk keys a row by its value, so the same item in
// two groups would highlight in both at once. An emptied group goes too.
const rest = groups
.map((group) => ({
...group,
items: group.items.filter((item) => !recent.has(item.id)),
}))
.filter((group) => group.items.length > 0)
return [{ heading: "Recent", items: recentItems }, ...rest]
}, [groups, recentIds])
return (
<CommandDialog
open={isOpen}
onOpenChange={setOpen}
title={title}
description={description}
className="gap-0 sm:max-w-xl"
>
{/* The command root is the palette's own root: data-slot lands here,
replacing the primitive's name (nothing in command.tsx selects on it),
and so do className and the rest of the caller's props. The dialog
shell above keeps its own sizing. */}
{/* `label` is what cmdk names the combobox by: it renders the text as
the hidden <label> the input is already aria-labelledby, which was
empty — an unnamed edit combobox — without it. */}
<Command data-slot="command-palette" label={title} loop className={className} {...props}>
<CommandInput placeholder={placeholder} />
<CommandResultCount emptyMessage={emptyMessage} />
<CommandList>
<CommandEmpty>{emptyMessage}</CommandEmpty>
{visibleGroups.map((group) => (
<CommandGroupList key={group.heading} heading={group.heading}>
{group.items.map((item) => (
<CommandRow
key={item.id}
value={itemValue(item)}
onSelect={() => {
item.onSelect()
setOpen(false)
}}
>
{item.icon}
<span className="flex min-w-0 flex-col">
<span className="truncate">{item.label}</span>
{item.description ? (
<span className="truncate text-xs text-muted-foreground">
{item.description}
</span>
) : null}
</span>
{item.shortcut ? (
// COUPLED TO registry/vibra/ui/command.tsx: CommandShortcut
// spaces plain-text shortcuts out with tracking-widest,
// which pulls the keycaps apart — but the slot has to stay,
// because CommandItem hides its trailing tick only for a
// row that carries a data-slot="command-shortcut".
<CommandShortcut className="flex shrink-0 items-center gap-1 tracking-normal">
{item.shortcut
.split(" ")
.filter(Boolean)
.map((chord, index) => (
<KbdShortcut key={index} keys={chord} size="sm" />
))}
</CommandShortcut>
) : null}
</CommandRow>
))}
</CommandGroupList>
))}
</CommandList>
</Command>
{footer ? (
<div
data-slot="command-palette-footer"
className="flex items-center justify-between gap-2 border-t px-3 py-2 text-xs text-muted-foreground"
>
{footer}
</div>
) : null}
</CommandDialog>
)
}
export { CommandPalette }