{"slug":"command-palette","name":"Command Palette","description":"⌘K palette with fuzzy filter, spring-animated active row and glass surface.","category":"blocks","source_url":"https://beui.dev/r/command-palette/raw","detail_url":"https://beui.dev/r/command-palette","raw_url":"https://beui.dev/r/command-palette/raw","page_url":"https://beui.dev/components/blocks/command-palette","markdown_url":"https://beui.dev/components/blocks/command-palette.md","published_at":"2026-05-17","updated_at":"2026-09-22","dependencies":["clsx","lucide-react","motion","react","react-dom","tailwind-merge"],"internal":["@/components/motion/command-palette","@/lib/command-search","@/lib/ease","@/lib/hooks/use-on-open","@/lib/hooks/use-row-cursor","@/lib/hooks/use-touch-capable","@/lib/presence-gate","@/lib/utils"],"files":[{"path":"components/motion/command-palette.tsx","type":"component","content":"\"use client\";\n// beui.dev/components/blocks/command-palette\n\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { Search, type LucideIcon } from \"lucide-react\";\nimport {\n  type ReactNode,\n  useCallback,\n  useEffect,\n  useId,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { EASE_OUT } from \"@/lib/ease\";\nimport { useOnOpen } from \"@/lib/hooks/use-on-open\";\nimport { useRowCursor } from \"@/lib/hooks/use-row-cursor\";\nimport { useTouchCapable } from \"@/lib/hooks/use-touch-capable\";\nimport { PresenceGate } from \"@/lib/presence-gate\";\nimport { cn } from \"@/lib/utils\";\nimport { searchCommands } from \"@/lib/command-search\";\n\nexport type CommandItem = {\n  id: string;\n  label: string;\n  group?: string;\n  hint?: string;\n  keywords?: string[];\n  icon?: LucideIcon;\n  badge?: ReactNode;\n  onSelect: () => void;\n};\n\nexport interface CommandPaletteProps {\n  items: CommandItem[];\n  /** Opens with Cmd/Ctrl + this key. Default: \"k\" */\n  shortcut?: string;\n  placeholder?: string;\n  emptyMessage?: string;\n  open?: boolean;\n  onOpenChange?: (open: boolean) => void;\n}\n\n// Opened via a keyboard shortcut many times a day — entrance must read as\n// instant. Tight spring, even faster exit.\nconst PANEL_SPRING = {\n  type: \"spring\",\n  stiffness: 560,\n  damping: 40,\n  mass: 0.5,\n} as const;\n\nexport function CommandPalette({\n  items,\n  shortcut = \"k\",\n  placeholder = \"Type a command or search…\",\n  emptyMessage = \"No results found.\",\n  open: controlledOpen,\n  onOpenChange,\n}: CommandPaletteProps) {\n  const [internalOpen, setInternalOpen] = useState(false);\n  const controlled = controlledOpen !== undefined;\n  const open = controlled ? controlledOpen : internalOpen;\n  const setOpen = useCallback(\n    (v: boolean) => {\n      if (!controlled) setInternalOpen(v);\n      onOpenChange?.(v);\n    },\n    [controlled, onOpenChange],\n  );\n\n  const [query, setQuery] = useState(\"\");\n  // Portal target only exists client-side; render nothing during SSR/hydration.\n  const [mounted, setMounted] = useState(false);\n  useEffect(() => setMounted(true), []);\n  const uid = useId();\n  const reduce = useReducedMotion();\n  const canTouch = useTouchCapable();\n  const inputRef = useRef<HTMLInputElement>(null);\n  const listRef = useRef<HTMLDivElement>(null);\n\n  useEffect(() => {\n    const onKey = (e: KeyboardEvent) => {\n      if (\n        (e.metaKey || e.ctrlKey) &&\n        e.key.toLowerCase() === shortcut.toLowerCase()\n      ) {\n        e.preventDefault();\n        setOpen(!open);\n        return;\n      }\n      if (e.key === \"Escape\" && open) {\n        e.preventDefault();\n        setOpen(false);\n      }\n    };\n    window.addEventListener(\"keydown\", onKey);\n    return () => window.removeEventListener(\"keydown\", onKey);\n  }, [open, shortcut, setOpen]);\n\n  useEffect(() => {\n    if (!open) return;\n    const root = document.documentElement;\n    const previousRootOverflow = root.style.overflow;\n    const previousBodyOverflow = document.body.style.overflow;\n    root.style.overflow = \"hidden\";\n    document.body.style.overflow = \"hidden\";\n    return () => {\n      root.style.overflow = previousRootOverflow;\n      document.body.style.overflow = previousBodyOverflow;\n    };\n  }, [open]);\n\n  const filtered = useMemo(() => searchCommands(items, query), [items, query]);\n\n  // Reserve the icon column only when at least one item brings an icon, so\n  // icon-less lists don't render a dead gap before every label.\n  const hasIcons = useMemo(() => items.some((it) => it.icon), [items]);\n\n  const grouped = useMemo(() => {\n    const map = new Map<string, CommandItem[]>();\n    filtered.forEach((it) => {\n      const g = it.group ?? \"Results\";\n      const groupItems = map.get(g) ?? [];\n      groupItems.push(it);\n      map.set(g, groupItems);\n    });\n    return Array.from(map.entries());\n  }, [filtered]);\n\n  // Grouping reorders the list, so the rendered order is not the filtered\n  // order whenever two groups interleave. Everything that has to agree on\n  // \"which row\" — the highlight, the ids, Enter, the scroll — reads this one\n  // array, so they cannot drift apart.\n  const rows = useMemo(() => grouped.flatMap(([, list]) => list), [grouped]);\n\n  const { activeIndex: active, moveTo, moveActive } = useRowCursor(rows, query);\n\n  // Clearing the query would drop the cursor on its own, but only if it had\n  // changed; `moveTo(null)` covers reopening on an already-empty query.\n  useOnOpen(open, () => {\n    setQuery(\"\");\n    moveTo(null);\n  });\n\n  useEffect(() => {\n    if (!open) return;\n    const frame = requestAnimationFrame(() => inputRef.current?.focus());\n    return () => cancelAnimationFrame(frame);\n  }, [open]);\n\n  const onKeyDown = (e: React.KeyboardEvent) => {\n    if (e.key === \"ArrowDown\") {\n      e.preventDefault();\n      moveActive(1);\n    } else if (e.key === \"ArrowUp\") {\n      e.preventDefault();\n      moveActive(-1);\n    } else if (e.key === \"Enter\") {\n      e.preventDefault();\n      const it = rows[active];\n      if (it) {\n        it.onSelect();\n        setOpen(false);\n      }\n    }\n  };\n\n  useEffect(() => {\n    if (!open) return;\n    const el = listRef.current?.querySelector<HTMLButtonElement>(\n      `[data-index=\"${active}\"]`,\n    );\n    el?.scrollIntoView({ block: \"nearest\" });\n  }, [active, open]);\n\n  if (!mounted) return null;\n\n  // Portaled to <body> so ancestors with transforms, filters, or fixed\n  // positioning can't trap the overlay in their stacking context, and mounted\n  // only while open. The chrome is two fixed siblings rather than one wrapper:\n  // the backdrop spans the viewport edges but carries the scrim colour, and the\n  // layer positioning the panel is inset off every edge. Both hang off\n  // `PresenceGate`, so interaction releases in the same commit that starts the\n  // exit rather than when it ends — `open` is already false for those frames.\n  return createPortal(\n    <AnimatePresence initial={false}>\n      {open ? (\n        <PresenceGate key=\"backdrop\">\n          {({ gate }) => (\n            <motion.button\n              type=\"button\"\n              aria-label=\"Close command palette\"\n              initial={{ opacity: 0 }}\n              animate={{ opacity: 1 }}\n              exit={{\n                opacity: 0,\n                transition: { duration: 0.12, ease: EASE_OUT },\n              }}\n              transition={{ duration: 0.18, ease: EASE_OUT }}\n              {...gate}\n              onClick={() => setOpen(false)}\n              className=\"pointer-events-auto fixed inset-0 z-[100] bg-background/5 [backdrop-filter:blur(12px)_saturate(140%)] [-webkit-backdrop-filter:blur(12px)_saturate(140%)]\"\n            />\n          )}\n        </PresenceGate>\n      ) : null}\n\n      {open ? (\n        <PresenceGate key=\"panel-layer\">\n          {({ isPresent, gate }) => (\n            // The layer itself never takes pointer events, so it carries\n            // `inert` alone rather than the gate's pointer-events value.\n            <div\n              inert={!isPresent}\n              className=\"pointer-events-none fixed inset-x-4 bottom-4 top-[18vh] z-[100] flex items-start justify-center\"\n            >\n              <motion.div\n                role=\"dialog\"\n                aria-modal=\"true\"\n                aria-label=\"Command palette\"\n                initial={{\n                  opacity: 0,\n                  y: reduce ? 0 : -8,\n                  scale: reduce ? 1 : 0.97,\n                }}\n                animate={{ opacity: 1, y: 0, scale: 1 }}\n                exit={{\n                  opacity: 0,\n                  y: reduce ? 0 : -8,\n                  scale: reduce ? 1 : 0.97,\n                  transition: { duration: 0.12, ease: EASE_OUT },\n                }}\n                transition={reduce ? { duration: 0.1 } : PANEL_SPRING}\n                {...gate}\n                onKeyDown={onKeyDown}\n                className=\"pointer-events-auto w-full max-w-xl overflow-hidden rounded-2xl border border-border bg-card shadow-2xl will-change-transform\"\n              >\n                <div className=\"flex items-center gap-3 border-b border-border px-4\">\n                  <Search className=\"h-4 w-4 text-muted-foreground\" />\n                  <input\n                    ref={inputRef}\n                    value={query}\n                    onChange={(e) => setQuery(e.target.value)}\n                    placeholder={placeholder}\n                    role=\"combobox\"\n                    // The field only exists while the palette is open.\n                    aria-expanded=\"true\"\n                    aria-controls={`${uid}-list`}\n                    aria-activedescendant={\n                      rows.length > 0 ? `${uid}-opt-${active}` : undefined\n                    }\n                    aria-autocomplete=\"list\"\n                    className={cn(\n                      \"h-12 flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground outline-none\",\n                      // The palette focuses this field the moment it opens, and iOS\n                      // zooms the page in on a focused field under 16px: the fixed\n                      // overlay is magnified off-center — clipped leading edge, half\n                      // an icon column — and the zoom outlives the palette. 16px on\n                      // touch keeps the page at scale 1; pointer devices keep 14px.\n                      canTouch && \"text-base\",\n                    )}\n                  />\n                  <kbd className=\"hidden rounded border border-border bg-background px-1.5 py-0.5 text-[10px] text-muted-foreground sm:inline-block\">\n                    ESC\n                  </kbd>\n                </div>\n                <div\n                  ref={listRef}\n                  id={`${uid}-list`}\n                  role=\"listbox\"\n                  aria-label=\"Commands\"\n                  className=\"max-h-[60vh] overflow-y-auto overscroll-contain p-2 [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden\"\n                >\n                  {rows.length === 0 ? (\n                    <div className=\"p-8 text-center text-sm text-muted-foreground\">\n                      {emptyMessage}\n                    </div>\n                  ) : (\n                    grouped.map(([group, list]) => (\n                      <div key={group} className=\"mb-1 last:mb-0\">\n                        <div\n                          aria-hidden\n                          className=\"px-2 py-1.5 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground\"\n                        >\n                          {group}\n                        </div>\n                        {list.map((it) => {\n                          // `rows` holds these very objects, in render order.\n                          const idx = rows.indexOf(it);\n                          const isActive = idx === active;\n                          const Icon = it.icon;\n                          return (\n                            <button\n                              key={it.id}\n                              type=\"button\"\n                              id={`${uid}-opt-${idx}`}\n                              role=\"option\"\n                              aria-selected={isActive}\n                              data-index={idx}\n                              onMouseEnter={() => moveTo(it.id)}\n                              onClick={() => {\n                                it.onSelect();\n                                setOpen(false);\n                              }}\n                              className={cn(\n                                \"relative isolate flex w-full items-center gap-3 rounded-md px-2 py-2 text-left text-sm transition-colors\",\n                                isActive\n                                  ? \"text-foreground\"\n                                  : \"text-muted-foreground\",\n                              )}\n                            >\n                              {isActive ? (\n                                <motion.span\n                                  layoutId={`${uid}-active`}\n                                  className=\"absolute inset-0 z-0 rounded-md bg-muted/60\"\n                                  transition={\n                                    reduce\n                                      ? { duration: 0 }\n                                      : // Tracks rapid arrow-key navigation — keep it tighter\n                                        // than SPRING_LAYOUT so it never lags the active row.\n                                        {\n                                          type: \"spring\",\n                                          stiffness: 480,\n                                          damping: 38,\n                                        }\n                                  }\n                                />\n                              ) : null}\n                              {Icon ? (\n                                <Icon className=\"relative z-10 h-4 w-4\" />\n                              ) : hasIcons ? (\n                                <span className=\"relative z-10 h-4 w-4\" />\n                              ) : null}\n                              <span className=\"relative z-10 flex-1 truncate\">\n                                {it.label}\n                              </span>\n                              {it.badge ? (\n                                <span className=\"relative z-10 shrink-0\">\n                                  {it.badge}\n                                </span>\n                              ) : null}\n                              {it.hint ? (\n                                <kbd className=\"relative z-10 rounded border border-border bg-background px-1.5 py-0.5 text-[10px] text-muted-foreground\">\n                                  {it.hint}\n                                </kbd>\n                              ) : null}\n                            </button>\n                          );\n                        })}\n                      </div>\n                    ))\n                  )}\n                </div>\n              </motion.div>\n            </div>\n          )}\n        </PresenceGate>\n      ) : null}\n    </AnimatePresence>,\n    document.body,\n  );\n}\n"},{"path":"lib/command-search.ts","type":"util","content":"export type SearchableCommand = {\n  label: string;\n  group?: string;\n  keywords?: string[];\n};\n\nfunction normalize(value: string) {\n  return value\n    .replace(/([a-z])([A-Z])/g, \"$1 $2\")\n    .normalize(\"NFKD\")\n    .replace(/\\p{M}/gu, \"\")\n    .toLowerCase()\n    .replace(/[^\\p{L}\\p{N}]+/gu, \" \")\n    .trim();\n}\n\nfunction tokenScore(token: string, field: string) {\n  const words = field.split(\" \");\n  if (words.includes(token)) return 100;\n  if (words.some((word) => word.startsWith(token))) return 80;\n  if (field.includes(token)) return 60;\n  // Keep short queries precise; fuzzy abbreviations may skip at most two\n  // letters within a word, rather than matching across a whole description.\n  if (token.length < 3) return 0;\n  for (const word of words) {\n    if (word.length - token.length > 2) continue;\n    let matched = 0;\n    for (const char of word) {\n      if (char === token[matched]) matched++;\n    }\n    if (matched === token.length) return 20;\n  }\n  return 0;\n}\n\n/** Match every query word across fields and put direct name matches first. */\nexport function searchCommands<T extends SearchableCommand>(items: T[], query: string): T[] {\n  const normalized = normalize(query);\n  if (!normalized) return items;\n  const tokens = normalized.split(/\\s+/);\n  return items\n    .map((item) => {\n      const label = normalize(item.label);\n      const fields = [label, normalize(item.group ?? \"\"), ...(item.keywords ?? []).map(normalize)];\n      let score = label === normalized ? 10000 : label.startsWith(normalized) ? 2000 : 0;\n      for (const token of tokens) {\n        const best = Math.max(...fields.map((field, index) => {\n          const match = tokenScore(token, field);\n          return match ? match + (index === 0 ? 40 : 0) : 0;\n        }));\n        if (!best) return { item, score: 0 };\n        score += best;\n      }\n      return { item, score };\n    })\n    .filter(({ score }) => score > 0)\n    .sort((a, b) => b.score - a.score)\n    .map(({ item }) => item);\n}\n"},{"path":"lib/ease.ts","type":"util","content":"// Shared motion tokens. Easing curves mirror the CSS custom properties in\n// globals.css; springs are the canonical physics used across components.\n// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.\n\nexport const EASE_OUT = [0.16, 1, 0.3, 1] as const;\nexport const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;\nexport const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;\n\n/** CSS string form of EASE_OUT for inline style transitions. */\nexport const EASE_OUT_CSS = \"cubic-bezier(0.16, 1, 0.3, 1)\";\n\n/** Press feedback on buttons and other tappable surfaces. */\nexport const SPRING_PRESS = {\n  type: \"spring\",\n  stiffness: 500,\n  damping: 30,\n  mass: 0.6,\n} as const;\n\n/** Content swaps — label/icon slots trading places inside a control. */\nexport const SPRING_SWAP = {\n  type: \"spring\",\n  stiffness: 460,\n  damping: 30,\n  mass: 0.55,\n} as const;\n\n/** Overlay panel entrances — modals and sheets summoned by pointer. */\nexport const SPRING_PANEL = {\n  type: \"spring\",\n  stiffness: 420,\n  damping: 40,\n  mass: 0.5,\n} as const;\n\n/** Shared-layout glides — pills, indicators and panels morphing between positions. */\nexport const SPRING_LAYOUT = {\n  type: \"spring\",\n  stiffness: 360,\n  damping: 32,\n  mass: 0.6,\n} as const;\n\n/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */\nexport const SPRING_MOUSE = {\n  stiffness: 200,\n  damping: 15,\n  mass: 0.3,\n} as const;\n\n/** Dragged handles and fills (sliders) — critically damped `useSpring` config,\n * so the value follows the pointer butterily and never rebounds off an end. */\nexport const SPRING_GLIDE = {\n  stiffness: 700,\n  damping: 50,\n  mass: 0.5,\n} as const;\n"},{"path":"lib/hooks/use-on-open.ts","type":"util","content":"\"use client\";\n\nimport { useState } from \"react\";\n\n/**\n * Runs `start` during the render in which `open` becomes true.\n *\n * Opening a list starts a fresh session — an empty query, the highlight back at\n * the top — and that is a resolution, not a side effect: a passive effect runs\n * after the commit, so the new session would carry the last one's state for a\n * window in which a key press can land.\n *\n * `start` may only set state belonging to the calling component. Anything that\n * reaches outside it — a consumer's callback, a DOM write, moving focus — is a\n * side effect, and React rejects a callback that sets another component's state\n * during this one's render. Put those in an effect keyed to `open`.\n */\nexport function useOnOpen(open: boolean, start: () => void) {\n  const [wasOpen, setWasOpen] = useState(open);\n  if (open !== wasOpen) {\n    setWasOpen(open);\n    if (open) start();\n  }\n}\n"},{"path":"lib/hooks/use-row-cursor.ts","type":"util","content":"\"use client\";\n\nimport { useCallback, useLayoutEffect, useRef, useState } from \"react\";\n\n/**\n * Where the keyboard or the pointer last moved to: the row's id, stamped with\n * the query it was placed under.\n */\ntype RowCursor = { id: string; query: string };\n\n/** The cursor's row, or -1 once the query has moved on or the row has left. */\nfunction indexOfCursor(\n  rows: readonly { id: string }[],\n  query: string,\n  cursor: RowCursor | null,\n) {\n  if (cursor === null || cursor.query !== query) return -1;\n  return rows.findIndex((row) => row.id === cursor.id);\n}\n\n/**\n * The highlighted row of a list whose rows can change under it.\n *\n * Which row is highlighted is resolved during render, never in a passive\n * effect: a passive effect runs after the commit, so a list that had just\n * changed would carry an `aria-activedescendant` naming a row that has left it,\n * and a key pressed in that window would commit the wrong row or nothing at\n * all.\n *\n * The cursor holds the row's id, not its position. A position alone cannot tell\n * a list that shrank from one that swapped its rows for a different set of the\n * same length, and the second case is the one that silently hands Enter to a\n * row the user never chose. A cursor whose row has left the list returns the\n * highlight to the first row rather than to the nearest surviving one: the row\n * the user aimed at is gone, and the first row is where a new query already\n * puts the highlight.\n *\n * Pass the query the list is filtered by. The cursor is stamped with it and\n * dropped when it changes, so a caller cannot forget to clear it — including a\n * caller whose query arrives as a prop and so never runs its own handler. Rows\n * that come back under a query that has moved on cannot revive it either.\n * `moveTo(null)` is for deliberate resets, such as reopening the list.\n *\n * Both callbacks keep one identity for the life of the component, and read the\n * rows and the query through a ref to do it. A caller will put them in an\n * effect's dependencies — the exhaustive-deps rule makes it — and a `moveTo`\n * rebuilt on every keystroke would re-run that effect on every keystroke.\n */\nexport function useRowCursor(rows: readonly { id: string }[], query: string) {\n  const [cursor, setCursor] = useState<RowCursor | null>(null);\n  // Written after commit, not during render: a render React discards or has not\n  // finished still runs the component body, and an event handler that read this\n  // in that window would stamp the cursor with a query the committed tree does\n  // not have.\n  const latest = useRef({ rows, query });\n  useLayoutEffect(() => {\n    latest.current = { rows, query };\n  });\n\n  const cursorRow = indexOfCursor(rows, query, cursor);\n  // Cleared rather than ignored: React re-runs this render with the cursor\n  // already gone, so rows that come back cannot revive a highlight the user has\n  // stopped aiming at.\n  if (cursor !== null && cursorRow < 0) setCursor(null);\n\n  const moveTo = useCallback(\n    (id: string | null) =>\n      setCursor(id === null ? null : { id, query: latest.current.query }),\n    [],\n  );\n\n  const moveActive = useCallback((direction: 1 | -1) => {\n    const { rows: live, query: liveQuery } = latest.current;\n    const last = live.length - 1;\n    if (last < 0) return;\n    // Steps from the row the cursor is really on, inside the update, so that\n    // two keys landing in one batch move two rows rather than one.\n    setCursor((current) => {\n      const at = Math.max(indexOfCursor(live, liveQuery, current), 0);\n      const next = Math.min(Math.max(at + direction, 0), last);\n      return { id: live[next].id, query: liveQuery };\n    });\n  }, []);\n\n  return { activeIndex: cursorRow < 0 ? 0 : cursorRow, moveTo, moveActive };\n}\n"},{"path":"lib/hooks/use-touch-capable.ts","type":"util","content":"\"use client\";\n\nimport { useEffect, useState } from \"react\";\n\n/**\n * Returns true on devices that can be touched, whatever else they claim.\n *\n * This is not the inverse of `useHoverCapable`: iPadOS Safari browses\n * desktop-class and answers `(hover: hover) and (pointer: fine)` with true\n * while a finger is the only input there is, so anything that treats\n * hover-capable as \"no touch here\" strands every iPad. Gate the *touch path*\n * of an interaction on this hook and leave hover-only polish on\n * `useHoverCapable`, so a component that opens on hover also opens on tap.\n */\nexport function useTouchCapable() {\n  const [canTouch, setCanTouch] = useState(false);\n\n  useEffect(() => {\n    if (typeof window === \"undefined\") return;\n    const mq = window.matchMedia?.(\"(any-pointer: coarse)\");\n    // iPadOS disguises its pointer media queries; maxTouchPoints it reports\n    // honestly, which is what makes it the standard iPad tell.\n    const update = () =>\n      setCanTouch(Boolean(mq?.matches) || navigator.maxTouchPoints > 0);\n    update();\n    mq?.addEventListener?.(\"change\", update);\n    return () => mq?.removeEventListener?.(\"change\", update);\n  }, []);\n\n  return canTouch;\n}\n"},{"path":"lib/presence-gate.tsx","type":"util","content":"\"use client\";\n\nimport { useIsPresent } from \"motion/react\";\nimport type { ReactNode } from \"react\";\n\nexport interface PresenceGateRenderProps {\n  /**\n   * False from the render that starts the exit animation onward. An overlay\n   * kept in the tree by `AnimatePresence` is still the topmost thing on the\n   * page, so anything it decides from `open` alone stays true for the whole\n   * exit — this is the boolean that already knows the overlay is leaving.\n   */\n  isPresent: boolean;\n  /**\n   * Spread onto every layer that takes pointer events while the overlay is\n   * open. Interaction releases in the same commit that starts the exit while\n   * the visual exit keeps playing: pointer events stop landing, and `inert`\n   * drops the subtree from focus order, from tab order and from the\n   * accessibility tree — an exiting dialog is not a dialog you can still type\n   * into. A layer that never takes pointer events (a wrapper that only centres\n   * the panel) takes `inert={!isPresent}` alone, so its own\n   * `pointer-events-none` is not overwritten.\n   */\n  gate: {\n    inert: boolean;\n    style: { pointerEvents: \"auto\" | \"none\" };\n  };\n}\n\nexport interface PresenceGateProps {\n  children: (props: PresenceGateRenderProps) => ReactNode;\n}\n\n/**\n * Reads the presence of the subtree it renders and hands it down.\n *\n * `useIsPresent` only answers inside the `AnimatePresence` subtree, and the\n * components that own an overlay render the `AnimatePresence` themselves, so\n * the boolean has to be read one component further down: this is that\n * component, and the render prop is how it reaches the layers.\n */\nexport function PresenceGate({ children }: PresenceGateProps) {\n  const isPresent = useIsPresent();\n\n  return children({\n    isPresent,\n    gate: {\n      inert: !isPresent,\n      style: { pointerEvents: isPresent ? \"auto\" : \"none\" },\n    },\n  });\n}\n"},{"path":"lib/utils.ts","type":"util","content":"import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n"},{"path":"components/previews/blocks/command-palette.preview.tsx","type":"preview","content":"\"use client\";\n\nimport { FileText, Home, Plus, Settings, User } from \"lucide-react\";\nimport { useState } from \"react\";\nimport { CommandPalette } from \"@/components/motion/command-palette\";\n\nexport function CommandPalettePreview() {\n  const [open, setOpen] = useState(false);\n\n  return (\n    <div className=\"flex flex-col items-start gap-3\">\n      <button\n        type=\"button\"\n        onClick={() => setOpen(true)}\n        className=\"inline-flex h-10 items-center rounded-full border border-border bg-card px-5 text-sm font-medium text-foreground press hover:border-(--color-border-strong)\"\n      >\n        Open command palette\n      </button>\n      <p className=\"text-sm text-muted-foreground\">\n        Press{\" \"}\n        <kbd className=\"rounded border border-border bg-card px-1.5 py-0.5 text-xs text-foreground\">\n          ⌘ J\n        </kbd>{\" \"}\n        (or <kbd className=\"rounded border border-border bg-card px-1.5 py-0.5 text-xs text-foreground\">Ctrl J</kbd>) to open.\n      </p>\n      <CommandPalette\n        open={open}\n        onOpenChange={setOpen}\n        shortcut=\"j\"\n        items={[\n          { id: \"home\", label: \"Go to Home\", group: \"Navigation\", icon: Home, hint: \"G H\", onSelect: () => {} },\n          { id: \"profile\", label: \"Open profile\", group: \"Navigation\", icon: User, hint: \"G P\", onSelect: () => {} },\n          { id: \"settings\", label: \"Settings\", group: \"Navigation\", icon: Settings, onSelect: () => {} },\n          { id: \"new-doc\", label: \"Create document\", group: \"Actions\", icon: FileText, hint: \"⌘ N\", onSelect: () => {} },\n          { id: \"new-project\", label: \"New project\", group: \"Actions\", icon: Plus, hint: \"⌘ ⇧ N\", onSelect: () => {} },\n        ]}\n      />\n    </div>\n  );\n}\n"}]}