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.

Customize
State
Content
0

Installation

npx shadcn@latest add https://craftbits.dev/r/command-menu.json

Usage

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

  1. Compound parts. Root is the dialog + cmdk container; Input is the search field; List is the scrollable result container; Group is a labelled cluster (set heading for the eyebrow); Item is a selectable command (wire onSelect); Empty is the no-match fallback; Separator is a hairline between groups; Shortcut is the right-aligned keyboard hint inside an Item. Composing the parts rather than passing a flat commands array means one group can opt out of a heading, one item can wrap arbitrary JSX, and refactors stay local to the JSX.
  2. Controlled & uncontrolled. Pass open + onOpenChange for controlled mode, or defaultOpen to let the Root own its state — the same Radix convention as Dialog, Popover, Tabs.
  3. Global hotkey. A keydown listener on window toggles the menu on ⌘K / Ctrl+K. The handler preventDefaults so the browser's Find-in-Page never fights you. Disable with hotkey={false} if your app already owns that binding.
  4. Filtering is cmdk's job. Each Item exposes its text to cmdk's scorer; typing in Input filters the list, highlights the first match, and arrow keys / Enter act on whichever item is currently selected. Pass value="extra keywords" on Item when the visible label isn't enough for recall.
  5. 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.
  6. 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 with defaultOpen don't get a flash.

Props

CommandMenu.Root

PropTypeDefaultDescription
openbooleanControlled open state. Pair with onOpenChange.
defaultOpenbooleanfalseUncontrolled initial open state.
onOpenChange(open: boolean) => voidFired when the menu opens or closes.
labelstring"Command menu"Accessible name for the dialog.
hotkeybooleantrueRegister the global ⌘K / Ctrl+K toggle.
classNamestringMerged onto the dialog panel.
overlayClassNamestringMerged onto the backdrop overlay.
childrenReactNodeCompound 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

PropTypeDefaultDescription
headingReactNodeEyebrow label rendered above the items.
valuestringGroup identifier — overrides the default derived from heading.

CommandMenu.Item

PropTypeDefaultDescription
onSelect(value: string) => voidFires on click or Enter when this is the active result.
valuestringderivedCustom search text — boost recall by including keywords.
disabledbooleanfalsePrevents selection and dims the row.

CommandMenu.Empty, CommandMenu.Separator

Forwarded to their cmdk equivalents — both accept className and any other <div> prop.

CommandMenu.Shortcut

PropTypeDefaultDescription
childrenReactNodeThe shortcut glyphs (e.g. ⌘K, Ctrl+/).
classNamestringMerged onto the rendered <span>.

Accessibility

  • The dialog panel carries role="dialog" + aria-modal="true" + an aria-label (defaults to "Command menu"; override with label). A hidden labelled span backs aria-labelledby so screen readers always announce the dialog name.
  • Focus is trapped inside the panel while open. Tab and Shift+Tab cycle through focusable elements without escaping. On close, focus restores to the element that opened the menu.
  • Escape closes 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 window and removed on unmount. Disable with hotkey={false} if your app already owns that binding.
  • cmdk handles arrow-key navigation, type-ahead filtering, and Enter-to-select for you. Each Item is 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-fg against --cb-bg-elevated; the selected item flips to a --cb-accent-muted background — both pass WCAG AA. The backdrop is --cb-bg / 0.8 with backdrop-blur-md.

Credits

  • Extracted from: algoflashcards (src/platform/ui/CommandMenu.tsx). The original wrapped a project-local shadcn command.tsx + Dialog and 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 on cmdk with our own motion-driven overlay.
  • Inspired by: cmdk — the underlying primitive that owns filtering and keyboard navigation.