Installation
npx shadcn@latest add @pixeldosa/command-menuThe example above also uses Button. To copy it as-is, install everything it needs:
npx shadcn@latest add @pixeldosa/command-menu @pixeldosa/buttonRequires `cn` at `@/lib/utils`. The host app owns the global ⌘K/Ctrl+K keydown listener and the `open` boolean — this component has no built-in trigger or shortcut binding, matching its controlled-only API. `CommandMenuItem`'s `keybind` prop is a single visible string (e.g. "⌘S"), not per-key badges; split it yourself if you need individually boxed keys.
Usage
CommandMenu is controlled-only: your app owns the open boolean and the global
⌘K/Ctrl+K listener, since that listener has to exist in your app regardless of this
component.
"use client";
import * as React from "react";
import {
CommandMenu,
CommandMenuEmpty,
CommandMenuGroup,
CommandMenuInput,
CommandMenuItem,
CommandMenuList,
} from "@/components/ui/command-menu";
export function AppCommandMenu() {
const [open, setOpen] = React.useState(false);
React.useEffect(() => {
function onKeyDown(event: KeyboardEvent) {
if (event.key === "k" && (event.metaKey || event.ctrlKey)) {
event.preventDefault();
setOpen((value) => !value);
}
}
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, []);
return (
<CommandMenu open={open} onOpenChange={setOpen} label="Command Menu">
<CommandMenuInput placeholder="Type a command or search…" />
<CommandMenuList>
<CommandMenuEmpty>No results found.</CommandMenuEmpty>
<CommandMenuGroup heading="Actions">
<CommandMenuItem onSelect={() => createIssue()} keybind="C">
Create new issue
</CommandMenuItem>
</CommandMenuGroup>
</CommandMenuList>
</CommandMenu>
);
}
Triggering the shortcut while the menu is already open should close it — that's why the
handler above toggles rather than always setting true. Pair the global listener with a
visible trigger button (with a ⌘K hint badge) for mouse users and for anyone who
doesn't know the shortcut yet.
Empty state and default content
Never show a blank panel before the user has typed anything — populate the first group
with recent items or sensible defaults. CommandMenuEmpty only renders once a query has
no matches; it does not need a manual query.length > 0 check.
Icons and keybinds
<CommandMenuItem onSelect={() => save()} icon={<SaveIcon />} keybind="⌘S">
Save changes
</CommandMenuItem>
icon is optional and muted by default so it recedes behind the label. keybind renders
as a single visible badge and, because it stays in the item's text content rather than an
aria-hidden decoration, is announced as part of the option's accessible name.
Features
- The ARIA combobox pattern, done properly — the input keeps DOM focus while
aria-activedescendanttracks the highlighted row, so fast typing and arrow keys both behave for screen reader users. Handled bycmdk, not reimplemented. - Grouped, fuzzy-filtered results out of the box, with
shouldFilter={false}as the escape hatch when you're filtering server-side. - Keybind hints that are actually announced — the badge stays in the item's text
content rather than being an
aria-hiddendecoration, so it's part of the accessible name. - A live result count announced on every keystroke, so a silent change in what's matching is never missed.
- Responsive to 375px: the centered floating panel becomes a full-width bottom sheet
under the
smbreakpoint. - Controlled-only, on purpose. Your app owns
openand the⌘Klistener, because that listener has to exist in your app regardless of this component.
Examples
Server-side filtering — turn off the built-in matcher and render whatever your search already returned:
<CommandMenu open={open} onOpenChange={setOpen} shouldFilter={false}>
<CommandMenuInput value={query} onValueChange={setQuery} placeholder="Search…" />
<CommandMenuList>
<CommandMenuEmpty>No results found.</CommandMenuEmpty>
{results.map((result) => (
<CommandMenuItem key={result.id} onSelect={() => go(result.href)}>
{result.title}
</CommandMenuItem>
))}
</CommandMenuList>
</CommandMenu>
Icons and keybind hints on an action row:
<CommandMenuItem onSelect={() => save()} icon={<SaveIcon />} keybind="⌘S">
Save changes
</CommandMenuItem>
Making an item findable by terms that aren't in its visible label — set value
explicitly rather than padding the label with keywords:
<CommandMenuItem value="billing invoice payment receipt" onSelect={() => go("/billing")}>
Billing
</CommandMenuItem>
Default content before anything is typed. Never show a blank panel — lead with recents:
<CommandMenuList>
<CommandMenuEmpty>No results found.</CommandMenuEmpty>
<CommandMenuGroup heading="Recent">
{recents.map((item) => (
<CommandMenuItem key={item.id} onSelect={() => go(item.href)}>{item.title}</CommandMenuItem>
))}
</CommandMenuGroup>
<CommandMenuSeparator />
<CommandMenuGroup heading="Actions">{/* … */}</CommandMenuGroup>
</CommandMenuList>
The example at the top of this page is live — open it and try typing, then use the arrow keys.
Props
CommandMenu
| Prop | Type | Default | Description |
|---|---|---|---|
open | boolean | — | Required. Controlled open state. |
onOpenChange | (open: boolean) => void | — | Required. |
label | string | "Command Menu" | Visually hidden accessible name for the dialog. |
filter | (value, search, keywords) => number | cmdk's default fuzzy matcher | Override to plug in structured or remote filtering. |
shouldFilter | boolean | true | Set false if you filter results yourself (e.g. server-side search) before rendering items. |
loop | boolean | true | Whether arrow-key navigation wraps from the last item back to the first. |
CommandMenuItem
| Prop | Type | Default | Description |
|---|---|---|---|
onSelect | (value: string) => void | — | Required. Fires on click or Enter while highlighted. |
icon | React.ReactNode | — | Leading icon, size-4 recommended. |
keybind | string | — | Visible hint badge, e.g. "⌘S". Announced as part of the item's label. |
disabled | boolean | false | Excluded from keyboard navigation and selection. |
value | string | item text content | What the built-in fuzzy filter matches against; set explicitly if the visible label isn't a good search string. |
CommandMenuInput, CommandMenuList, CommandMenuEmpty, CommandMenuGroup (with a
heading prop) and CommandMenuSeparator forward all other props to their underlying
cmdk element.
Accessibility
- Structurally the ARIA combobox pattern: the input keeps DOM focus at all times while
aria-activedescendantpoints at the highlighted row, so fast typing and arrow-key navigation both work correctly for screen reader users — handled bycmdk, not reimplemented here. - Focus is trapped in the dialog while open and restored to whatever had focus before it
opened, via Radix
Dialog. - Escape closes the menu; clicking the scrim closes it; triggering your own shortcut again while it's open should also close it (wire this in your handler, as shown above).
CommandMenuListautomatically renders anaria-live="polite"region announcing the filtered result count on every keystroke, so a silent count change is never missed.- Responsive down to 375px: a full-width sheet near the bottom of the viewport under the
smbreakpoint, replacing the centered floating panel. - Under
prefers-reduced-motion, the panel/scrim transition and the row-highlight colour transition both lose their animation but not the underlying state change — opacity and colour still update, just without motion.
When not to use
A menu triggered from one visible button or row is a Menu, not a CommandMenu; a
right-click menu is a ContextMenu. Reserve CommandMenu for global, app-wide search
and actions reachable from anywhere.
why it's built this way
Engineering Notes
Uses cmdk for the input/list/filter engine rather than hand-rolling the ARIA combobox contract (input keeps DOM focus while aria-activedescendant points at the highlighted listbox option) — that contract is easy to get subtly wrong and cmdk is the same engine Vercel's own Geist command menu and shadcn/ui's command component build on. Radix Dialog wraps the whole thing rather than cmdk's own Command.Dialog variant, so PixelDosa UI keeps ownership of the visual contract instead of inheriting cmdk's default dialog chrome. Deliberately does NOT compose the shared `overlay` primitive (Motion-driven), despite that being the original plan: `overlay`'s AnimatePresence usage has a confirmed upstream bug (motiondivision/motion#3243) where the exiting panel and its full-viewport scrim sometimes never unmount, permanently blocking further page interaction after the first close — reproduced independently in Overlay's own existing demo, in both dev and production builds, so it is a primitive-level defect, not a composition mistake here. This component instead drives its fade+scale with a plain two-phase (mount in the closed state, flip to open on the next animation frame) CSS transition gated by a deterministic `setTimeout`-based delayed-unmount hook, reading the same `--pd-duration-fast`/`--pd-ease-standard` tokens `overlay` uses, so the visual result is identical while sidestepping the bug entirely. Recommend re-composing `overlay` here once its exit-completion issue is fixed upstream or worked around at the primitive level. Controlled-only open state (no internal fallback) because the consumer's global keydown listener already has to own the boolean. Also works around a second, unrelated composition gotcha: cmdk's own keydown handling on the Command root prevents Escape from reaching Radix's document-level dismissable-layer listener when cmdk is composed manually instead of via its own Command.Dialog, so Escape-to-close is handled explicitly rather than left to Radix alone. The result-count aria-live announcer is rendered automatically inside CommandMenuList via cmdk's useCommandState hook rather than left for every consumer to wire up. A separate kbd primitive was considered (per the component plan) and deliberately deferred: the keybind badge is a few lines of markup used only inside CommandMenuItem for v1, and extracting it into its own registry item before a second consumer needs it would be speculative.
how it moves, and when it doesn't
Motion Notes
Fade (opacity) plus a slight scale-in (0.98 → 1) on both the scrim and panel, matching the shared overlay primitive's visual signature even though it isn't composed directly here (see engineeringNotes). Mounts in the closed state, then flips to the open data-state on the next animation frame so the browser actually has a 'from' value to transition from — setting both in the same commit would skip the transition entirely. Duration and easing come from --pd-duration-fast/--pd-ease-standard, the same tokens Overlay uses, so the timing stays in step with the rest of the system. Exit is handled by a matching deterministic delay (150ms, comfortably above the fast token) before the panel actually leaves the DOM, so the fade-out is never cut short. The mobile sheet layout (full-width, pinned near the viewport bottom under the sm breakpoint) intentionally keeps the same fade+scale rather than a directional slide-from-bottom, per the component plan's call to reuse one motion signature rather than add a second pattern for one breakpoint. Highlighted-row background change on CommandMenuItem uses --pd-duration-instant with --pd-ease-standard, matching Button's press-feedback timing, because it is state feedback rather than decoration. Under prefers-reduced-motion, both the panel/scrim transition and the row-highlight transition are removed via motion-reduce:transition-none (scale pinned to 100% via motion-reduce:scale-100), leaving the opacity and background-colour changes themselves intact so every state change stays legible without motion.