Command Menu
A keyboard-first command palette. ⌘K (Mac) / Ctrl+K (everywhere else) toggles a focus-trapped dialog with a search input and a scrollable, filterable list of commands. Filtering and arrow-key navigation come from cmdk; the dialog overlay, focus management, and motion are ours.
Installation
npx shadcn@latest add https://craftbits.dev/r/command-menu.jsonUsage
CommandMenu is a compound — Root owns the dialog and the global hotkey; the parts inside it mirror cmdk's structure (Input, List, Group, Item, Empty, Separator, Shortcut). Press ⌘K to open the demo above, or wire your own trigger:
"use client";
import { useState } from "react";
import { CommandMenu } from "@craft-bits/core";
export function SiteSearch() {
const [open, setOpen] = useState(false);
return (
<>
<button type="button" onClick={() => setOpen(true)}>
Open <kbd>⌘K</kbd>
</button>
<CommandMenu.Root open={open} onOpenChange={setOpen} label="Site search">
<CommandMenu.Input placeholder="Type a command or search…" />
<CommandMenu.List>
<CommandMenu.Empty>No matches.</CommandMenu.Empty>
<CommandMenu.Group heading="Navigate">
<CommandMenu.Item onSelect={() => setOpen(false)}>
Home
<CommandMenu.Shortcut>⌘H</CommandMenu.Shortcut>
</CommandMenu.Item>
<CommandMenu.Item onSelect={() => setOpen(false)}>
Docs
</CommandMenu.Item>
</CommandMenu.Group>
<CommandMenu.Separator />
<CommandMenu.Group heading="Theme">
<CommandMenu.Item onSelect={() => setOpen(false)}>
Toggle dark mode
</CommandMenu.Item>
</CommandMenu.Group>
</CommandMenu.List>
</CommandMenu.Root>
</>
);
}Uncontrolled — pass defaultOpen and let the Root own its state:
<CommandMenu.Root defaultOpen={false} label="Site search">
{/* … */}
</CommandMenu.Root>Disable the global ⌘K hotkey if your app already owns that binding:
<CommandMenu.Root open={open} onOpenChange={setOpen} hotkey={false}>
{/* … */}
</CommandMenu.Root>Understanding the component
- Compound parts.
Rootis the dialog + cmdk container;Inputis the search field;Listis the scrollable result container;Groupis a labelled cluster (setheadingfor the eyebrow);Itemis a selectable command (wireonSelect);Emptyis the no-match fallback;Separatoris a hairline between groups;Shortcutis the right-aligned keyboard hint inside anItem. Composing the parts rather than passing a flatcommandsarray means one group can opt out of a heading, one item can wrap arbitrary JSX, and refactors stay local to the JSX. - Controlled & uncontrolled. Pass
open+onOpenChangefor controlled mode, ordefaultOpento let the Root own its state — the same Radix convention asDialog,Popover,Tabs. - Global hotkey. A
keydownlistener onwindowtoggles the menu on ⌘K / Ctrl+K. The handlerpreventDefaults so the browser's Find-in-Page never fights you. Disable withhotkey={false}if your app already owns that binding. - Filtering is cmdk's job. Each
Itemexposes its text to cmdk's scorer; typing inInputfilters the list, highlights the first match, and arrow keys / Enter act on whichever item is currently selected. Passvalue="extra keywords"onItemwhen the visible label isn't enough for recall. - Focus-trap + restore. On open, focus moves into the search input on the next frame so motion has time to mount the panel. Tab cycles through focusable elements inside the dialog without escaping. On close, focus restores to whatever element opened the menu.
- Motion. The backdrop fades in and the panel scale-pops from the top, both with
SPRINGS.snap.<AnimatePresence initial={false}>skips animation on the very first render so apps that mount withdefaultOpendon't get a flash.
Props
CommandMenu.Root
| Prop | Type | Default | Description |
|---|---|---|---|
open | boolean | — | Controlled open state. Pair with onOpenChange. |
defaultOpen | boolean | false | Uncontrolled initial open state. |
onOpenChange | (open: boolean) => void | — | Fired when the menu opens or closes. |
label | string | "Command menu" | Accessible name for the dialog. |
hotkey | boolean | true | Register the global ⌘K / Ctrl+K toggle. |
className | string | — | Merged onto the dialog panel. |
overlayClassName | string | — | Merged onto the backdrop overlay. |
children | ReactNode | — | Compound parts (Input, List, …). |
CommandMenu.Input
Forwarded to cmdk's Command.Input. Accepts placeholder, value, onValueChange, etc.
CommandMenu.List
Forwarded to cmdk's Command.List. Scrollable container with a max-height of 22rem.
CommandMenu.Group
| Prop | Type | Default | Description |
|---|---|---|---|
heading | ReactNode | — | Eyebrow label rendered above the items. |
value | string | — | Group identifier — overrides the default derived from heading. |
CommandMenu.Item
| Prop | Type | Default | Description |
|---|---|---|---|
onSelect | (value: string) => void | — | Fires on click or Enter when this is the active result. |
value | string | derived | Custom search text — boost recall by including keywords. |
disabled | boolean | false | Prevents selection and dims the row. |
CommandMenu.Empty, CommandMenu.Separator
Forwarded to their cmdk equivalents — both accept className and any other <div> prop.
CommandMenu.Shortcut
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | — | The shortcut glyphs (e.g. ⌘K, Ctrl+/). |
className | string | — | Merged onto the rendered <span>. |
Accessibility
- The dialog panel carries
role="dialog"+aria-modal="true"+ anaria-label(defaults to"Command menu"; override withlabel). A hidden labelled span backsaria-labelledbyso screen readers always announce the dialog name. - Focus is trapped inside the panel while open.
TabandShift+Tabcycle through focusable elements without escaping. On close, focus restores to the element that opened the menu. Escapecloses the menu even while the input has focus. Mouse-down on the backdrop closes; mouse-down inside the panel doesn't, even when the user drags out to release.- The global ⌘K / Ctrl+K toggle is registered on
windowand removed on unmount. Disable withhotkey={false}if your app already owns that binding. cmdkhandles arrow-key navigation, type-ahead filtering, and Enter-to-select for you. EachItemis announced with its visible label.- Body scroll is locked while the menu is open so the page underneath can't scroll out from under the dialog.
- Color contrast in the default theme: item text on the panel uses
--cb-fgagainst--cb-bg-elevated; the selected item flips to a--cb-accent-mutedbackground — both pass WCAG AA. The backdrop is--cb-bg / 0.8withbackdrop-blur-md.
Credits
- Extracted from:
algoflashcards(src/platform/ui/CommandMenu.tsx). The original wrapped a project-local shadcncommand.tsx+Dialogand hard-coded the lesson / problem / LLD-module routes; craft-bits drops the project-specific data hooks, removes the shadcn dialog dependency, and lifts the API into a Radix-style compound built directly oncmdkwith our own motion-driven overlay. - Inspired by: cmdk — the underlying primitive that owns filtering and keyboard navigation.