{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "command-menu",
  "type": "registry:ui",
  "title": "Command menu",
  "description": "Search every page, project and action from the keyboard. The panel drops in over a dimmed canvas and one highlight glides between rows.",
  "dependencies": [
    "@radix-ui/react-dialog",
    "motion"
  ],
  "registryDependencies": [
    "https://littmd.vercel.app/r/litt.json",
    "https://littmd.vercel.app/r/button.json",
    "https://littmd.vercel.app/r/glide.json",
    "https://littmd.vercel.app/r/icons.json",
    "https://littmd.vercel.app/r/kbd.json",
    "https://littmd.vercel.app/r/motion.json"
  ],
  "files": [
    {
      "path": "registry/litt/ui/command-menu.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n// @litt/command-menu 2026.11.06. What changed: https://littmd.vercel.app/changelog.md\n\n/**\n * Command menu: ⌘K search over pages, projects and actions.\n *\n * - Overlay, focus trap and Esc come from @radix-ui/react-dialog. The panel is\n *   floating glass with the modal shadow, rounded-litt-surface, max width 560, 15dvh\n *   from the top. Rows and their highlight are rounded-litt-control inside a 4px\n *   list inset, so the corners stay concentric.\n * - The list is the combobox + listbox pattern: DOM focus stays in the input\n *   and aria-activedescendant names the highlighted option. Rows never take\n *   focus (mousedown is prevented), so the caret never leaves the field.\n * - The first enabled row is always highlighted. Arrows move and wrap, skipping\n *   disabled rows. Enter fires only on the highlighted row. Escape clears a\n *   non-empty query first and closes when it is empty. Every handler returns\n *   early while an IME is composing. Pointer leave keeps the highlight.\n * - Filtering keeps the original order and shows a designed empty state.\n * - Motion, responsive tier: opened by a press (a search button), the panel grows out of it (transform-origin on\n *   the button, from GROW, @/motion/deal); opened by the shortcut, it fades, drops half the travel and scales\n *   0.98 to 1; the overlay fades on opacity only; exits use useExit. Typing\n *   changes the list instantly and the panel's height is never animated: the\n *   list has a max height and scrolls inside. The gliding highlight is glide.tsx.\n * - The panel body (CommandPanel) renders without the dialog too, for a static preview.\n * - Compact screens (useDevice().compact, foundations.md section 6): the panel moves\n *   to the top edge, --litt-space-2 below the safe-area inset and --litt-space-2 from each side,\n *   not 15dvh down. The wrapper takes the position and height of window.visualViewport\n *   (resize and scroll events), so the list is the visible screen less the keyboard and\n *   no row hides behind it. Before the first read, and without a visual viewport, 100dvh\n *   stands in. The motion is the same responsive drop. It is not draggable, because the\n *   list scrolls. Nothing else moves; the panel's height is still never animated.\n * - Coarse pointers (useDevice().coarse): the placeholder is shorter beside Cancel (a focused field\n *   clips instead of ellipsizing), the footer hints (arrows, enter, esc) and the\n *   row shortcuts are gone, and a Cancel button (--litt-control-md tall) sits beside the field\n *   when onCancel is given; CommandMenu always gives it. The field keeps the focus, as it\n *   is the first thing in the dialog that takes it, because typing is the point. Both\n *   signals come from useDevice, so <DeviceProvider mode=\"phone\"> previews them.\n * - Device: under data-material=\"device\" the search and the results set in the mono (font-litt-field) and the group labels are engraved (litt-legend).\n */\nimport {\n  useEffect,\n  useId,\n  useLayoutEffect,\n  useMemo,\n  useRef,\n  useState,\n  type KeyboardEvent,\n  type ReactNode,\n} from \"react\";\nimport * as Dialog from \"@radix-ui/react-dialog\";\nimport { AnimatePresence, motion } from \"motion/react\";\nimport { useDevice, useExit, useTransition, useTravel, useVisibleScreen } from \"../../lib/litt/motion\";\nimport { GROW, useGrowFrom, useOverlayMotion } from \"../../lib/litt/motion/deal\";\nimport { Button } from \"./button\";\nimport { SearchIcon } from \"./icons\";\nimport { Kbd } from \"./kbd\";\nimport { GLIDE_ROW, useGlide, type GlideSource } from \"../../lib/litt/glide\";\n\n/* Radix's asChild, spread in: shadcn's CLI rewrites a written asChild to Base UI's render prop in base-* hosts. */\nconst asChildProps = { asChild: true } as const;\n\n/** One row. The search matches its label. */\nexport type CommandItem = {\n  /** Unique across every group, because it names the row for assistive tech. */\n  id: string;\n  /** The row text, and what the search matches. */\n  label: string;\n  /** 16px icon in a fixed cell before the label. */\n  icon?: ReactNode;\n  /** Mono hint on the right, for example ⌘N. It is a hint only: it does not bind the key. */\n  shortcut?: string;\n  /** Dims the row. It is never lit and Enter and clicks skip it. */\n  disabled?: boolean;\n};\n/** A heading and its rows. A group with no match for the query is hidden. */\nexport type CommandGroup = {\n  /** Unique among the groups. */\n  id: string;\n  /** The group label above its rows. */\n  heading: string;\n  items: CommandItem[];\n};\n\ntype Section = { id: string; heading: string; rows: (CommandItem & { index: number })[] };\n\n/** Keeps the original order. Matches the label, ignoring case. */\nfunction filter(groups: CommandGroup[], query: string) {\n  const q = query.trim().toLowerCase();\n  const flat: CommandItem[] = [];\n  const sections: Section[] = [];\n  for (const g of groups) {\n    const rows: Section[\"rows\"] = [];\n    for (const item of g.items) {\n      if (q && !item.label.toLowerCase().includes(q)) continue;\n      rows.push({ ...item, index: flat.length });\n      flat.push(item);\n    }\n    if (rows.length) sections.push({ id: g.id, heading: g.heading, rows });\n  }\n  return { sections, flat };\n}\n\nconst firstEnabled = (items: CommandItem[]) => items.findIndex((i) => !i.disabled);\n\nfunction Hint({ keys, children }: { keys: string; children: ReactNode }) {\n  return (\n    <span className=\"flex min-w-0 items-center gap-2\">\n      <Kbd>{keys}</Kbd>\n      <span className=\"truncate\">{children}</span>\n    </span>\n  );\n}\n\n/* ----------------------------------------------------------------- panel */\n\nexport type CommandPanelProps = {\n  /** What to search, in order. A group is { id, heading, items } and an item is { id, label, icon?, shortcut?, disabled? }. */\n  groups: CommandGroup[];\n  /** The text in the search field. You own it, so you can preset or clear it. */\n  query: string;\n  /** Called with the new text on each keystroke, and with \"\" when Esc clears the field. */\n  onQueryChange: (query: string) => void;\n  /** Called with the item when a row is chosen with Enter or a click. */\n  onSelect: (item: CommandItem) => void;\n  /** How the panel came up. A keyboard open shows the ring on the first row at once. */\n  initialSource?: GlideSource;\n  /** Focuses the search field when the panel mounts. CommandMenu does not need it, because the dialog focuses the field itself. */\n  autoFocus?: boolean;\n  /** Called by the Cancel button beside the field, which shows on touch screens only. CommandMenu closes with it. Leave it out and no button shows. */\n  onCancel?: () => void;\n};\n\n/** Input, list and footer. The caller supplies the surface around it; CommandMenu wraps it in a dialog. */\nexport function CommandPanel({ groups, query, onQueryChange, onSelect, initialSource = \"keyboard\", autoFocus, onCancel }: CommandPanelProps) {\n  const { coarse } = useDevice();\n  const uid = useId();\n  const listId = `${uid}-list`;\n  const { sections, flat } = useMemo(() => filter(groups, query), [groups, query]);\n\n  /* The highlighted row. Null means \"the first enabled row\", which is where every\n     new query starts (adjusted during render, so there is no stale frame). */\n  const [pick, setPick] = useState<number | null>(null);\n  const [lastQuery, setLastQuery] = useState(query);\n  if (lastQuery !== query) {\n    setLastQuery(query);\n    setPick(null);\n  }\n  const [source, setSource] = useState<GlideSource>(initialSource);\n  const picked = pick !== null && flat[pick] && !flat[pick].disabled ? pick : -1;\n  const current = picked >= 0 ? picked : firstEnabled(flat);\n  const active = current >= 0 ? flat[current] : undefined;\n\n  const glide = useGlide({\n    scroll: true,\n    keepOnLeave: true,\n    onPointerRow: (index, _row, type) => {\n      setPick(index);\n      setSource(type === \"touch\" ? \"touch\" : \"pointer\");\n    },\n  });\n\n  /* The glide follows the state. A list change snaps; a move glides. */\n  const { setActive } = glide;\n  const prevFlat = useRef(flat);\n  useLayoutEffect(() => {\n    const changed = prevFlat.current !== flat;\n    prevFlat.current = flat;\n    setActive(current >= 0 ? current : null, source, { jump: changed });\n  }, [current, source, flat, setActive]);\n\n  const move = (dir: 1 | -1) => {\n    const enabled = flat.flatMap((it, i) => (it.disabled ? [] : [i]));\n    if (!enabled.length) return;\n    const at = enabled.indexOf(current);\n    const next = at < 0 ? enabled[dir === 1 ? 0 : enabled.length - 1] : enabled[(at + dir + enabled.length) % enabled.length];\n    setPick(next);\n    setSource(\"keyboard\");\n  };\n\n  const onKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {\n    if (e.nativeEvent.isComposing) return;\n    if (e.key === \"ArrowDown\" || e.key === \"ArrowUp\") {\n      e.preventDefault();\n      move(e.key === \"ArrowDown\" ? 1 : -1);\n    } else if (e.key === \"Enter\") {\n      e.preventDefault();\n      if (active && !active.disabled) onSelect(active);\n    } else if (e.key === \"Escape\" && query) {\n      e.preventDefault();\n      e.stopPropagation();\n      onQueryChange(\"\");\n    }\n  };\n\n  const optionId = (item: CommandItem) => `${uid}-opt-${item.id}`;\n\n  return (\n    <>\n      <div\n        className={\n          \"flex h-13 shrink-0 items-center gap-3 border-b border-[var(--litt-glass-border)] \" + (coarse && onCancel ? \"pl-4 pr-1\" : \"px-4\") +\n          \" in-[[data-material=flat]]:m-1 in-[[data-material=flat]]:rounded-[calc(var(--litt-radius-surface)-4px)] in-[[data-material=flat]]:border-transparent in-[[data-material=flat]]:bg-litt-surface-3\"\n        }\n      >\n        <SearchIcon className=\"shrink-0 text-litt-fg-subtle\" />\n        <input\n          role=\"combobox\"\n          aria-label=\"Search pages, projects and actions\"\n          aria-expanded={flat.length > 0}\n          aria-controls={flat.length > 0 ? listId : undefined}\n          aria-activedescendant={active ? optionId(active) : undefined}\n          aria-autocomplete=\"list\"\n          autoComplete=\"off\"\n          autoCorrect=\"off\"\n          spellCheck={false}\n          autoFocus={autoFocus}\n          value={query}\n          placeholder={coarse && onCancel ? \"Search pages and actions\" : \"Search pages, projects and actions\"}\n          onChange={(e) => {\n            onQueryChange(e.target.value);\n            setSource(\"keyboard\");\n          }}\n          onKeyDown={onKeyDown}\n          className=\"h-full min-w-0 flex-1 truncate bg-transparent text-litt-body text-litt-fg outline-none placeholder:text-litt-fg-subtle pointer-coarse:text-(length:--litt-control-text-lg) font-litt-field\"\n        />\n        {coarse && onCancel && (\n          <Button variant=\"ghost\" size=\"md\" onClick={onCancel}>\n            Cancel\n          </Button>\n        )}\n      </div>\n\n      {flat.length > 0 ? (\n        <div\n          id={listId}\n          role=\"listbox\"\n          aria-label=\"Results\"\n          ref={glide.ref}\n          {...glide.props}\n          onMouseDown={(e) => e.preventDefault()}\n          className=\"relative max-h-[var(--cmd-list-max,min(55dvh,22rem))] min-h-0 overflow-y-auto overscroll-contain p-1 in-[[data-material=flat]]:[&>[data-ring]]:rounded-[calc(var(--litt-radius-surface)-4px)]\"\n        >\n          {glide.highlight}\n          {sections.map((s) => (\n            <div key={s.id} role=\"group\" aria-labelledby={`${uid}-g-${s.id}`}>\n              <div id={`${uid}-g-${s.id}`} className=\"flex h-litt-control-sm items-center px-3 text-litt-caption text-litt-fg-subtle litt-legend\">\n                {s.heading}\n              </div>\n              {s.rows.map((r) => (\n                <div\n                  key={r.id}\n                  id={optionId(r)}\n                  role=\"option\"\n                  aria-selected={r.index === current}\n                  aria-disabled={r.disabled || undefined}\n                  data-active={r.index === current || undefined}\n                  {...GLIDE_ROW}\n                  onClick={() => !r.disabled && onSelect(r)}\n                  className=\"relative flex h-litt-control-md scroll-mt-8 scroll-mb-1 cursor-pointer items-center gap-3 rounded-litt-control in-[[data-material=flat]]:rounded-[calc(var(--litt-radius-surface)-4px)] px-3 text-litt-small text-litt-fg-muted transition-colors duration-(--litt-dur-instant) ease-litt data-[active]:text-litt-fg aria-disabled:cursor-not-allowed aria-disabled:text-litt-fg-disabled forced-colors:data-[active]:[forced-color-adjust:none] forced-colors:data-[active]:text-[HighlightText] forced-colors:data-[active]:**:text-[HighlightText] font-litt-field\"\n                >\n                  {r.icon !== undefined && <span className=\"grid size-4 shrink-0 place-items-center\">{r.icon}</span>}\n                  <span className=\"min-w-0 truncate\">{r.label}</span>\n                  {r.shortcut && !coarse && <span className=\"ml-auto shrink-0 pl-4 text-litt-caption font-normal text-litt-fg-subtle\">{r.shortcut}</span>}\n                </div>\n              ))}\n            </div>\n          ))}\n        </div>\n      ) : (\n        <div role=\"status\" className=\"grid justify-items-center gap-2 px-6 py-12 text-center\">\n          <SearchIcon className=\"text-litt-fg-subtle\" />\n          <p className=\"max-w-full break-words text-litt-small font-litt-title text-litt-fg\">No results for “{query.trim()}”</p>\n          <p className=\"text-litt-caption font-normal text-litt-fg-subtle\">Check the spelling, or try a project name.</p>\n        </div>\n      )}\n\n      {/* A container query, not a viewport one: the same panel is 358 wide in the dialog and 295 in a card.\n          Under 24rem the arrow hint goes first, since the Enter hint says what will happen.\n          On a touch screen every hint here is a keyboard one, so the whole footer goes. */}\n      {!coarse && (\n        <div className=\"@container shrink-0 border-t border-[var(--litt-glass-border)] in-[[data-material=flat]]:border-transparent\">\n          <div className=\"flex h-10 items-center gap-4 px-4 text-litt-caption text-litt-fg-subtle\">\n            <span className=\"flex @max-sm:hidden\">\n              <Hint keys=\"↑↓\">to move</Hint>\n            </span>\n            {active && <Hint keys=\"↵\">to open {active.label}</Hint>}\n            <span className=\"ml-auto flex shrink-0\">\n              <Hint keys=\"esc\">to close</Hint>\n            </span>\n          </div>\n        </div>\n      )}\n    </>\n  );\n}\n\n/** Flat: the glass becomes a solid surface-1 card with the modal shadow and no hairline. */\nconst FLAT_PANEL =\n  \"in-[[data-material=flat]]:[--litt-glass-bg:var(--litt-surface-1)] in-[[data-material=flat]]:[--litt-glass-border:transparent] \" +\n  \"in-[[data-material=flat]]:[--litt-highlight:0_0_0_0_transparent] in-[[data-material=flat]]:[--litt-glass-blur:0px] \";\n\n/* ---------------------------------------------------------------- dialog */\n\nexport type CommandMenuProps = {\n  /** Whether the menu is showing. You own it, so a trigger button and useCommandShortcut can both set it. */\n  open: boolean;\n  /** Called with false when the menu asks to close: Esc on an empty field, a click on the backdrop, or a chosen row. It never asks to open, so your trigger and useCommandShortcut set that. */\n  onOpenChange: (open: boolean) => void;\n  /** What to search, in order. A group is { id, heading, items } and an item is { id, label, icon?, shortcut?, disabled? }. */\n  groups: CommandGroup[];\n  /** Called with the chosen item, and then the menu closes itself. */\n  onSelect: (item: CommandItem) => void;\n  /** Keyboard (the shortcut, Enter on the trigger) or pointer. Decides whether the first row shows the ring at once. */\n  openedBy?: \"keyboard\" | \"pointer\";\n};\n\n/** The dialog form. Overlay, focus trap and Esc come from Radix Dialog, and the query starts empty on every open. */\nexport function CommandMenu({ open, onOpenChange, groups, onSelect, openedBy = \"keyboard\" }: CommandMenuProps) {\n  return (\n    <Dialog.Root open={open} onOpenChange={onOpenChange}>\n      <AnimatePresence>\n        {open && <Body key=\"body\" groups={groups} onSelect={onSelect} onClose={() => onOpenChange(false)} openedBy={openedBy} />}\n      </AnimatePresence>\n    </Dialog.Root>\n  );\n}\n\n/* Mounted once per open, so the query starts empty and survives the exit animation. */\nfunction Body({\n  groups,\n  onSelect,\n  onClose,\n  openedBy,\n}: {\n  groups: CommandGroup[];\n  onSelect: (item: CommandItem) => void;\n  onClose: () => void;\n  openedBy: \"keyboard\" | \"pointer\";\n}) {\n  const enter = useTransition(\"responsive\");\n  const exit = useExit(\"responsive\");\n  const travel = useTravel();\n  const { compact } = useDevice();\n  const visible = useVisibleScreen(compact);\n  const [query, setQuery] = useState(\"\");\n  /* There is no Dialog.Trigger (the page owns the button and the shortcut), so give focus back ourselves. */\n  const [opener] = useState(() => document.activeElement as HTMLElement | null);\n  /* Opened by a press (the search button), it grows out of it; by the shortcut, it drops in from above. */\n  const grow = useGrowFrom<HTMLDivElement>(true, !compact);\n  /* Under Device the panel switches on and off where it stands (@/motion/deal, the motion languages). */\n  const om = useOverlayMotion();\n\n  return (\n    <Dialog.Portal forceMount>\n      <Dialog.Overlay {...asChildProps}>\n        <motion.div\n          className=\"fixed inset-0 z-50 bg-[color-mix(in_oklab,var(--litt-canvas)_55%,transparent)]\"\n          initial={{ opacity: 0 }}\n          animate={{ opacity: 1, transition: enter }}\n          exit={{ opacity: 0, transition: exit }}\n        />\n      </Dialog.Overlay>\n      {/* Desktop: 15dvh down, --litt-space-4 from each side. Compact: the top edge of the visible screen, --litt-space-2 below\n          the safe-area inset and from each side. The wrapper IS the visible screen (visualViewport), so the panel's\n          max-h-full is the visible screen less the keyboard and the gaps. */}\n      <div\n        className={\n          compact\n            ? \"pointer-events-none fixed inset-x-0 top-0 z-50 flex h-dvh items-start justify-center px-(--litt-space-2) pt-[calc(env(safe-area-inset-top)+var(--litt-space-2))] pb-(--litt-space-2)\"\n            : \"pointer-events-none fixed inset-x-0 top-[15dvh] z-50 flex justify-center px-4\"\n        }\n        style={compact && visible ? { top: visible.top, height: visible.height } : undefined}\n      >\n        <Dialog.Content\n          {...asChildProps}\n          aria-describedby={undefined}\n          onCloseAutoFocus={(e) => {\n            e.preventDefault();\n            opener?.focus?.({ preventScroll: true });\n          }}\n          onEscapeKeyDown={(e) => {\n            /* Composing an IME word: Esc cancels the composition, not the menu. */\n            if (e.isComposing) return e.preventDefault();\n            /* A non-empty query clears first; the menu closes on the next Esc. */\n            if (query) {\n              e.preventDefault();\n              setQuery(\"\");\n            }\n          }}\n        >\n          <motion.div\n            ref={grow.ref}\n            className={\n              \"litt-glass pointer-events-auto w-full max-w-140 origin-top rounded-litt-surface outline-none [--litt-glass-shadow:var(--litt-shadow-modal)] \" + FLAT_PANEL +\n              (compact ? \" flex max-h-full flex-col [--cmd-list-max:none]\" : \"\")\n            }\n            initial={om.switches ? { opacity: 0 } : grow.grows ? { opacity: 0, scale: GROW } : { opacity: 0, y: -travel / 2, scale: 0.98 }}\n            animate={{ opacity: 1, y: 0, scale: 1, transition: om.switches ? om.switchIn : enter }}\n            exit={om.switches ? { opacity: 0, transition: om.switchOut } : { opacity: 0, scale: grow.grows ? GROW : 0.98, transition: exit }}\n          >\n            <Dialog.Title className=\"sr-only\">Command menu</Dialog.Title>\n            <CommandPanel\n              groups={groups}\n              query={query}\n              onQueryChange={setQuery}\n              initialSource={openedBy}\n              onCancel={onClose}\n              onSelect={(item) => {\n                onSelect(item);\n                onClose();\n              }}\n            />\n          </motion.div>\n        </Dialog.Content>\n      </div>\n    </Dialog.Portal>\n  );\n}\n\n/**\n * ⌘K or Ctrl+K. Call it from the page that owns the menu: the listener lives only while that page is mounted.\n *\n * @param toggle - Runs when the shortcut is pressed. Flip your open state here. It always sees the latest render, so it needs no memo.\n */\nexport function useCommandShortcut(toggle: () => void) {\n  const latest = useRef(toggle);\n  latest.current = toggle;\n  useEffect(() => {\n    const onKey = (e: globalThis.KeyboardEvent) => {\n      if (e.isComposing) return;\n      if ((e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey && e.key.toLowerCase() === \"k\") {\n        e.preventDefault();\n        latest.current();\n      }\n    };\n    window.addEventListener(\"keydown\", onKey);\n    return () => window.removeEventListener(\"keydown\", onKey);\n  }, []);\n}\n",
      "target": "components/litt/command-menu.tsx"
    }
  ],
  "docs": "Import CommandMenu and useCommandShortcut from components/litt/command-menu. Give it groups of items and an onSelect.",
  "meta": {
    "version": "2026.11.06",
    "released": "2026-11-06",
    "changelog": "https://littmd.vercel.app/changelog.md"
  }
}
