{"slug":"combobox","name":"Combobox","description":"Searchable combobox with a morphing portal, grouped filtering, keyboard navigation, and controlled or uncontrolled state.","category":"motion","source_url":"https://beui.dev/r/combobox/raw","detail_url":"https://beui.dev/r/combobox","raw_url":"https://beui.dev/r/combobox/raw","page_url":"https://beui.dev/components/motion/combobox","markdown_url":"https://beui.dev/components/motion/combobox.md","published_at":"2026-08-11","updated_at":"2026-08-22","dependencies":["clsx","lucide-react","motion","react","react-dom","tailwind-merge"],"internal":["./combobox/content","./combobox/context","./combobox/list","./combobox/trigger","./context","./use-active-option","@/components/motion/combobox","@/components/motion/popover-position","@/lib/ease","@/lib/utils"],"files":[{"path":"components/motion/combobox.tsx","type":"component","content":"\"use client\";\n// beui.dev/components/motion/combobox\n\nexport {\n  ComboboxContent,\n  type ComboboxContentProps,\n} from \"./combobox/content\";\nexport {\n  Combobox,\n  type ComboboxFilter,\n  type ComboboxProps,\n} from \"./combobox/context\";\nexport {\n  ComboboxEmpty,\n  type ComboboxEmptyProps,\n  ComboboxGroup,\n  type ComboboxGroupProps,\n  ComboboxItem,\n  type ComboboxItemProps,\n  ComboboxLabel,\n  type ComboboxLabelProps,\n  ComboboxList,\n  type ComboboxListProps,\n  ComboboxSeparator,\n  type ComboboxSeparatorProps,\n} from \"./combobox/list\";\nexport {\n  ComboboxInput,\n  type ComboboxInputProps,\n  ComboboxTrigger,\n  type ComboboxTriggerProps,\n  ComboboxValue,\n  type ComboboxValueProps,\n} from \"./combobox/trigger\";\n"},{"path":"components/motion/combobox/content.tsx","type":"util","content":"\"use client\";\n\nimport { motion, type Transition } from \"motion/react\";\nimport {\n  type CSSProperties,\n  type ReactNode,\n  useEffect,\n  useLayoutEffect,\n  useRef,\n  useState,\n} from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { usePopoverPortalPosition } from \"@/components/motion/popover-position\";\nimport { cn } from \"@/lib/utils\";\nimport { useComboboxContext } from \"./context\";\n\ntype Side = \"top\" | \"bottom\";\ntype Align = \"start\" | \"center\" | \"end\";\n\n// The panel uses one weighted spring for both directions, so opening and\n// closing travel through the same detached geometry.\nconst COMBOBOX_MORPH: Transition = {\n  type: \"spring\",\n  duration: 0.5,\n  bounce: 0.22,\n};\nconst VIEWPORT_PADDING = 8;\n\nexport interface ComboboxContentProps {\n  children: ReactNode;\n  side?: Side;\n  align?: Align;\n  sideOffset?: number;\n  avoidCollisions?: boolean;\n  className?: string;\n}\n\nexport function ComboboxContent({\n  children,\n  side = \"bottom\",\n  align = \"start\",\n  sideOffset = 6,\n  avoidCollisions = true,\n  className,\n}: ComboboxContentProps) {\n  const context = useComboboxContext(\"ComboboxContent\");\n  const measureRef = useRef<HTMLDivElement>(null);\n  const [portalReady, setPortalReady] = useState(false);\n  const [actualSide, setActualSide] = useState<Side>(side);\n  const [morphReady, setMorphReady] = useState(false);\n  const layout = usePopoverPortalPosition(\n    context.triggerRef,\n    measureRef,\n    portalReady,\n  );\n\n  useEffect(() => setPortalReady(true), []);\n  useLayoutEffect(() => {\n    if (!portalReady) return;\n    const readyFrame = requestAnimationFrame(() => setMorphReady(true));\n    return () => cancelAnimationFrame(readyFrame);\n  }, [portalReady]);\n\n  useLayoutEffect(() => {\n    // Preserve the resolved side during exit, so top panels close upward.\n    if (!context.open || !layout) return;\n    if (!avoidCollisions) {\n      setActualSide(side);\n      return;\n    }\n    const below =\n      window.innerHeight - (layout.trigger.top + layout.trigger.height);\n    const above = layout.trigger.top;\n    if (\n      side === \"bottom\" &&\n      below < layout.content.height + sideOffset &&\n      above > below\n    )\n      setActualSide(\"top\");\n    else if (\n      side === \"top\" &&\n      above < layout.content.height + sideOffset &&\n      below > above\n    )\n      setActualSide(\"bottom\");\n    else setActualSide(side);\n  }, [avoidCollisions, context.open, layout, side, sideOffset]);\n\n  if (!portalReady) return null;\n\n  const triggerLeft = layout?.trigger.left ?? 0;\n  const triggerWidth = layout?.trigger.width ?? 0;\n  const contentWidth = layout?.content.width ?? triggerWidth;\n  const desiredLeft =\n    align === \"end\"\n      ? triggerLeft + triggerWidth - contentWidth\n      : align === \"center\"\n        ? triggerLeft + (triggerWidth - contentWidth) / 2\n        : triggerLeft;\n  const maxLeft = Math.max(\n    VIEWPORT_PADDING,\n    window.innerWidth - contentWidth - VIEWPORT_PADDING,\n  );\n  const left = Math.min(Math.max(desiredLeft, VIEWPORT_PADDING), maxLeft);\n  const surfaceHeight = layout?.content.height ?? 0;\n\n  return createPortal(\n    <motion.div\n      ref={context.contentRef}\n      data-combobox-content=\"\"\n      data-side={actualSide}\n      aria-hidden={!context.open}\n      inert={!context.open}\n      initial={false}\n      animate={{\n        height: context.open ? surfaceHeight : 0,\n        opacity: context.open ? 1 : 0,\n        y: context.open\n          ? actualSide === \"bottom\"\n            ? sideOffset\n            : -sideOffset\n          : 0,\n      }}\n      transition={\n        context.reduce || !morphReady ? { duration: 0 } : COMBOBOX_MORPH\n      }\n      style={\n        {\n          left,\n          top:\n            actualSide === \"bottom\" && layout\n              ? layout.trigger.top + layout.trigger.height\n              : undefined,\n          bottom:\n            actualSide === \"top\" && layout\n              ? window.innerHeight - layout.trigger.top\n              : undefined,\n          minWidth: triggerWidth,\n          pointerEvents: context.open ? \"auto\" : \"none\",\n          transformOrigin: actualSide === \"bottom\" ? \"top\" : \"bottom\",\n          visibility: layout ? \"visible\" : \"hidden\",\n          \"--combobox-trigger-width\": `${triggerWidth}px`,\n        } as CSSProperties\n      }\n      className={cn(\n        \"fixed z-[9999] w-(--combobox-trigger-width) overflow-hidden rounded-xl border border-border bg-background text-popover-foreground outline-none will-change-[height,transform]\",\n        className,\n      )}\n    >\n      <motion.div\n        ref={measureRef}\n        initial={false}\n        animate={{ opacity: context.open ? 1 : 0 }}\n        transition={\n          context.reduce || !morphReady ? { duration: 0 } : COMBOBOX_MORPH\n        }\n      >\n        {children}\n      </motion.div>\n    </motion.div>,\n    document.body,\n  );\n}\n"},{"path":"components/motion/combobox/context.tsx","type":"util","content":"\"use client\";\n\nimport { useReducedMotion } from \"motion/react\";\nimport {\n  createContext,\n  type MutableRefObject,\n  type ReactNode,\n  type Ref,\n  useCallback,\n  useContext,\n  useEffect,\n  useId,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { useActiveOption } from \"./use-active-option\";\n\nexport type RegisteredItem = {\n  value: string;\n  label: string;\n  keywords: string[];\n  disabled: boolean;\n  groupId: string | null;\n  id: string;\n  ref: MutableRefObject<HTMLButtonElement | null>;\n};\n\nexport type ComboboxFilter = (\n  value: string,\n  query: string,\n  keywords: string[],\n) => boolean;\n\nconst defaultFilter: ComboboxFilter = (value, query, keywords) => {\n  const needle = query.trim().toLocaleLowerCase();\n  if (!needle) return true;\n\n  const haystack = [value, ...keywords].join(\" \").toLocaleLowerCase();\n  let queryIndex = 0;\n  for (const character of haystack) {\n    if (character === needle[queryIndex]) queryIndex += 1;\n    if (queryIndex === needle.length) return true;\n  }\n  return false;\n};\n\nexport type ComboboxContextValue = {\n  open: boolean;\n  setOpen: (open: boolean, restoreFocus?: boolean) => void;\n  value: string | undefined;\n  select: (value: string) => void;\n  query: string;\n  setQuery: (query: string) => void;\n  /** The option the list is currently pointing at, resolved during render. */\n  activeValue: string | null;\n  /**\n   * Records where the pointer or keyboard moved to. The recorded value is\n   * dropped when the query or the result set changes, so it does not always\n   * survive to the next `activeValue`.\n   */\n  setActiveValue: (value: string | null) => void;\n  moveActive: (direction: 1 | -1 | \"first\" | \"last\") => void;\n  selectActive: () => void;\n  registerItem: (item: RegisteredItem) => void;\n  unregisterItem: (value: string) => void;\n  labelFor: (value: string | undefined) => string | undefined;\n  isVisible: (value: string) => boolean;\n  hasVisibleItems: (groupId: string) => boolean;\n  visibleCount: number;\n  activeItemId: string | undefined;\n  triggerId: string;\n  listId: string;\n  inputId: string;\n  disabled: boolean;\n  reduce: boolean;\n  triggerRef: MutableRefObject<HTMLDivElement | null>;\n  contentRef: MutableRefObject<HTMLDivElement | null>;\n  inputRef: MutableRefObject<HTMLInputElement | null>;\n  activeLayoutId: string;\n};\n\nexport const ComboboxContext = createContext<ComboboxContextValue | null>(null);\nexport const ComboboxGroupContext = createContext<string | null>(null);\n\nexport function useComboboxContext(component: string) {\n  const context = useContext(ComboboxContext);\n  if (!context) throw new Error(`${component} must be used within <Combobox>`);\n  return context;\n}\n\nexport function mergeRefs<T>(...refs: Array<Ref<T> | undefined>) {\n  return (node: T | null) => {\n    for (const ref of refs) {\n      if (typeof ref === \"function\") ref(node);\n      else if (ref && typeof ref === \"object\")\n        (ref as MutableRefObject<T | null>).current = node;\n    }\n  };\n}\n\nexport interface ComboboxProps {\n  children: ReactNode;\n  value?: string;\n  defaultValue?: string;\n  onValueChange?: (value: string) => void;\n  open?: boolean;\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  query?: string;\n  defaultQuery?: string;\n  onQueryChange?: (query: string) => void;\n  filter?: ComboboxFilter;\n  disabled?: boolean;\n  className?: string;\n}\n\nexport function Combobox({\n  children,\n  value: controlledValue,\n  defaultValue,\n  onValueChange,\n  open: controlledOpen,\n  defaultOpen = false,\n  onOpenChange,\n  query: controlledQuery,\n  defaultQuery = \"\",\n  onQueryChange,\n  filter = defaultFilter,\n  disabled = false,\n  className,\n}: ComboboxProps) {\n  const reduce = useReducedMotion() ?? false;\n  const baseId = useId();\n  const rootRef = useRef<HTMLDivElement>(null);\n  const triggerRef = useRef<HTMLDivElement>(null);\n  const contentRef = useRef<HTMLDivElement>(null);\n  const inputRef = useRef<HTMLInputElement>(null);\n  const [internalValue, setInternalValue] = useState(defaultValue);\n  const [internalOpen, setInternalOpen] = useState(defaultOpen);\n  const [internalQuery, setInternalQuery] = useState(defaultQuery);\n  const [items, setItems] = useState<Map<string, RegisteredItem>>(new Map());\n\n  const valueControlled = controlledValue !== undefined;\n  const openControlled = controlledOpen !== undefined;\n  const queryControlled = controlledQuery !== undefined;\n  const value = valueControlled ? controlledValue : internalValue;\n  const open = openControlled ? controlledOpen : internalOpen;\n  const query = queryControlled ? controlledQuery : internalQuery;\n\n  const updateQuery = useCallback(\n    (next: string) => {\n      if (!queryControlled) setInternalQuery(next);\n      onQueryChange?.(next);\n    },\n    [onQueryChange, queryControlled],\n  );\n\n  const updateOpen = useCallback(\n    (next: boolean, restoreFocus = false) => {\n      if (disabled && next) return;\n      if (!openControlled) setInternalOpen(next);\n      onOpenChange?.(next);\n      if (!next) updateQuery(\"\");\n      if (restoreFocus)\n        requestAnimationFrame(() =>\n          inputRef.current?.focus({ preventScroll: true }),\n        );\n    },\n    [disabled, onOpenChange, openControlled, updateQuery],\n  );\n\n  const registerItem = useCallback((item: RegisteredItem) => {\n    setItems((current) => {\n      const existing = current.get(item.value);\n      if (\n        existing?.label === item.label &&\n        existing.disabled === item.disabled &&\n        existing.id === item.id &&\n        existing.ref === item.ref &&\n        existing.groupId === item.groupId &&\n        existing.keywords.join(\"\\u0000\") === item.keywords.join(\"\\u0000\")\n      ) {\n        return current;\n      }\n      const next = new Map(current);\n      next.set(item.value, item);\n      return next;\n    });\n  }, []);\n\n  const unregisterItem = useCallback((itemValue: string) => {\n    setItems((current) => {\n      if (!current.has(itemValue)) return current;\n      const next = new Map(current);\n      next.delete(itemValue);\n      return next;\n    });\n  }, []);\n\n  // The query the list filters by. Closing clears `query`, but the panel is\n  // still on screen for its exit, so it keeps filtering by the query it was\n  // open with rather than repopulating mid-collapse. The input already reads\n  // `query` only while open, so nothing the user can see reads the other one.\n  const [openQuery, setOpenQuery] = useState(query);\n  if (open && openQuery !== query) setOpenQuery(query);\n  const listQuery = open ? query : openQuery;\n\n  const visibleItems = useMemo(\n    () =>\n      Array.from(items.values()).filter((item) =>\n        filter(item.value, listQuery, [item.label, ...item.keywords]),\n      ),\n    [filter, items, listQuery],\n  );\n  const enabledVisibleItems = useMemo(\n    () => visibleItems.filter((item) => !item.disabled),\n    [visibleItems],\n  );\n  const visibleValues = useMemo(\n    () => new Set(visibleItems.map((item) => item.value)),\n    [visibleItems],\n  );\n  const visibleGroupIds = useMemo(\n    () => new Set(visibleItems.map((item) => item.groupId)),\n    [visibleItems],\n  );\n\n  const { activeValue, setActiveValue, moveActive } = useActiveOption({\n    open,\n    query: listQuery,\n    value,\n    enabledItems: enabledVisibleItems,\n  });\n\n  const select = useCallback(\n    (next: string) => {\n      if (items.get(next)?.disabled) return;\n      if (!valueControlled) setInternalValue(next);\n      onValueChange?.(next);\n      updateOpen(false, true);\n    },\n    [items, onValueChange, updateOpen, valueControlled],\n  );\n\n  const selectActive = useCallback(() => {\n    if (activeValue) select(activeValue);\n  }, [activeValue, select]);\n\n  useEffect(() => {\n    if (!open) return;\n    const frame = requestAnimationFrame(() =>\n      inputRef.current?.focus({ preventScroll: true }),\n    );\n    return () => cancelAnimationFrame(frame);\n  }, [open]);\n\n  useEffect(() => {\n    if (!activeValue || !open) return;\n    const item = items.get(activeValue)?.ref.current;\n    const list = item?.closest<HTMLElement>(\"[role='listbox']\");\n    if (!item || !list) return;\n    const itemRect = item.getBoundingClientRect();\n    const listRect = list.getBoundingClientRect();\n    if (itemRect.top < listRect.top) list.scrollTop -= listRect.top - itemRect.top;\n    else if (itemRect.bottom > listRect.bottom)\n      list.scrollTop += itemRect.bottom - listRect.bottom;\n  }, [activeValue, items, open]);\n\n  useEffect(() => {\n    if (!open) return;\n    const isInside = (target: Node) =>\n      rootRef.current?.contains(target) || contentRef.current?.contains(target);\n    const onPointerDown = (event: PointerEvent) => {\n      if (!isInside(event.target as Node)) updateOpen(false);\n    };\n    const onFocusIn = (event: FocusEvent) => {\n      if (!isInside(event.target as Node)) updateOpen(false);\n    };\n    const onKeyDown = (event: KeyboardEvent) => {\n      if (event.key !== \"Escape\") return;\n      event.preventDefault();\n      updateOpen(false, true);\n    };\n    window.addEventListener(\"pointerdown\", onPointerDown);\n    window.addEventListener(\"focusin\", onFocusIn);\n    window.addEventListener(\"keydown\", onKeyDown);\n    return () => {\n      window.removeEventListener(\"pointerdown\", onPointerDown);\n      window.removeEventListener(\"focusin\", onFocusIn);\n      window.removeEventListener(\"keydown\", onKeyDown);\n    };\n  }, [open, updateOpen]);\n\n  const activeItem = activeValue ? items.get(activeValue) : undefined;\n  const context = useMemo<ComboboxContextValue>(\n    () => ({\n      open,\n      setOpen: updateOpen,\n      value,\n      select,\n      query,\n      setQuery: updateQuery,\n      activeValue,\n      setActiveValue,\n      moveActive,\n      selectActive,\n      registerItem,\n      unregisterItem,\n      labelFor: (itemValue) =>\n        itemValue === undefined ? undefined : items.get(itemValue)?.label,\n      isVisible: (itemValue) => !listQuery.trim() || visibleValues.has(itemValue),\n      hasVisibleItems: (groupId) => visibleGroupIds.has(groupId),\n      visibleCount: visibleItems.length,\n      activeItemId: activeItem?.id,\n      triggerId: `${baseId}-trigger`,\n      listId: `${baseId}-list`,\n      inputId: `${baseId}-input`,\n      disabled,\n      reduce,\n      triggerRef,\n      contentRef,\n      inputRef,\n      activeLayoutId: `${baseId}-active`,\n    }),\n    [\n      activeItem?.id,\n      activeValue,\n      baseId,\n      disabled,\n      items,\n      listQuery,\n      moveActive,\n      open,\n      query,\n      reduce,\n      registerItem,\n      select,\n      selectActive,\n      setActiveValue,\n      unregisterItem,\n      updateOpen,\n      updateQuery,\n      value,\n      visibleItems.length,\n      visibleGroupIds,\n      visibleValues,\n    ],\n  );\n\n  return (\n    <ComboboxContext.Provider value={context}>\n      <div ref={rootRef} className={cn(\"relative w-full\", className)}>\n        {children}\n      </div>\n    </ComboboxContext.Provider>\n  );\n}\n"},{"path":"components/motion/combobox/list.tsx","type":"util","content":"\"use client\";\n\nimport { Check } from \"lucide-react\";\nimport { motion } from \"motion/react\";\nimport {\n  type ReactNode,\n  useContext,\n  useId,\n  useLayoutEffect,\n  useMemo,\n  useRef,\n} from \"react\";\nimport { EASE_OUT, SPRING_LAYOUT } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\nimport { ComboboxGroupContext, useComboboxContext } from \"./context\";\n\nexport interface ComboboxListProps {\n  children: ReactNode;\n  ariaLabel?: string;\n  className?: string;\n}\n\nexport function ComboboxList({\n  children,\n  ariaLabel = \"Options\",\n  className,\n}: ComboboxListProps) {\n  const context = useComboboxContext(\"ComboboxList\");\n  return (\n    <div\n      id={context.listId}\n      role=\"listbox\"\n      aria-label={ariaLabel}\n      className={cn(\n        \"relative isolate max-h-64 overflow-y-auto overscroll-contain p-1.5 [-ms-overflow-style:none] scrollbar-none [&::-webkit-scrollbar]:hidden\",\n        className,\n      )}>\n      {children}\n    </div>\n  );\n}\n\nexport interface ComboboxGroupProps {\n  children: ReactNode;\n  className?: string;\n}\n\nexport function ComboboxGroup({ children, className }: ComboboxGroupProps) {\n  const context = useComboboxContext(\"ComboboxGroup\");\n  const groupId = useId();\n  return (\n    <ComboboxGroupContext.Provider value={groupId}>\n      <fieldset\n        hidden={!context.hasVisibleItems(groupId)}\n        className={cn(\"m-0 min-w-0 border-0 p-0 py-0.5\", className)}>\n        {children}\n      </fieldset>\n    </ComboboxGroupContext.Provider>\n  );\n}\n\nexport interface ComboboxLabelProps {\n  children: ReactNode;\n  className?: string;\n}\n\nexport function ComboboxLabel({ children, className }: ComboboxLabelProps) {\n  const groupId = useContext(ComboboxGroupContext);\n  const labelClassName = cn(\n    \"w-full px-2 py-1.5 text-[0.68rem] font-medium uppercase tracking-[0.12em] text-muted-foreground\",\n    className,\n  );\n  return groupId ? (\n    <legend className={labelClassName}>{children}</legend>\n  ) : (\n    <div className={labelClassName}>{children}</div>\n  );\n}\n\nexport interface ComboboxItemProps {\n  value: string;\n  children: ReactNode;\n  textValue?: string;\n  keywords?: string[];\n  disabled?: boolean;\n  onSelect?: (value: string) => void;\n  className?: string;\n}\n\nexport function ComboboxItem({\n  value,\n  children,\n  textValue,\n  keywords = [],\n  disabled = false,\n  onSelect,\n  className,\n}: ComboboxItemProps) {\n  const context = useComboboxContext(\"ComboboxItem\");\n  const groupId = useContext(ComboboxGroupContext);\n  const id = useId();\n  const itemRef = useRef<HTMLButtonElement>(null);\n  const label = textValue ?? (typeof children === \"string\" ? children : value);\n  const visible = context.isVisible(value);\n  const active = context.activeValue === value;\n  const selected = context.value === value;\n  const keywordKey = keywords.join(\"\\u0000\");\n  const normalizedKeywords = useMemo(\n    () => (keywordKey ? keywordKey.split(\"\\u0000\") : []),\n    [keywordKey],\n  );\n  const { registerItem, unregisterItem } = context;\n\n  useLayoutEffect(() => {\n    registerItem({\n      value,\n      label,\n      keywords: normalizedKeywords,\n      disabled,\n      groupId,\n      id,\n      ref: itemRef,\n    });\n    return () => unregisterItem(value);\n  }, [\n    disabled,\n    groupId,\n    id,\n    label,\n    normalizedKeywords,\n    registerItem,\n    unregisterItem,\n    value,\n  ]);\n\n  if (!visible) return null;\n\n  return (\n    <button\n      ref={itemRef}\n      id={id}\n      type=\"button\"\n      role=\"option\"\n      aria-selected={selected}\n      disabled={disabled}\n      tabIndex={-1}\n      data-combobox-item=\"\"\n      data-active={active || undefined}\n      data-selected={selected || undefined}\n      onPointerMove={() => {\n        if (!disabled) context.setActiveValue(value);\n      }}\n      onPointerDown={(event) => event.preventDefault()}\n      onClick={() => {\n        if (disabled) return;\n        onSelect?.(value);\n        context.select(value);\n      }}\n      className={cn(\n        \"relative flex w-full items-center gap-2 rounded-lg px-2 py-2 text-left text-sm outline-none transition-colors duration-150\",\n        active ? \"text-foreground\" : \"text-muted-foreground\",\n        \"disabled:pointer-events-none disabled:opacity-45\",\n        className,\n      )}>\n      {active ? (\n        <motion.span\n          aria-hidden\n          layoutId={context.activeLayoutId}\n          className=\"absolute inset-0 -z-10 rounded-lg bg-muted\"\n          transition={context.reduce ? { duration: 0 } : SPRING_LAYOUT}\n        />\n      ) : null}\n      <span className=\"min-w-0 flex-1\">{children}</span>\n      <motion.span\n        aria-hidden\n        initial={false}\n        animate={{\n          opacity: selected ? 1 : 0,\n          transform: selected ? \"scale(1)\" : \"scale(0.82)\",\n        }}\n        transition={\n          context.reduce ? { duration: 0 } : { duration: 0.14, ease: EASE_OUT }\n        }\n        className=\"grid size-5 shrink-0 place-items-center text-foreground\">\n        <Check className=\"size-4\" />\n      </motion.span>\n    </button>\n  );\n}\n\nexport interface ComboboxEmptyProps {\n  children?: ReactNode;\n  className?: string;\n}\n\nexport function ComboboxEmpty({\n  children = \"No options found.\",\n  className,\n}: ComboboxEmptyProps) {\n  const context = useComboboxContext(\"ComboboxEmpty\");\n  if (context.visibleCount > 0) return null;\n  return (\n    <div\n      role=\"status\"\n      className={cn(\n        \"px-3 py-8 text-center text-sm text-muted-foreground\",\n        className,\n      )}>\n      {children}\n    </div>\n  );\n}\n\nexport interface ComboboxSeparatorProps {\n  className?: string;\n}\n\nexport function ComboboxSeparator({ className }: ComboboxSeparatorProps) {\n  return (\n    <div aria-hidden className={cn(\"-mx-1 my-1 h-px bg-border\", className)} />\n  );\n}\n"},{"path":"components/motion/combobox/trigger.tsx","type":"util","content":"\"use client\";\n\nimport { ChevronsUpDown, Search } from \"lucide-react\";\nimport type {\n  InputHTMLAttributes,\n  KeyboardEvent as ReactKeyboardEvent,\n  ReactNode,\n  Ref,\n} from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { mergeRefs, useComboboxContext } from \"./context\";\n\nexport interface ComboboxTriggerProps {\n  children: ReactNode;\n  className?: string;\n}\n\nexport function ComboboxTrigger({ children, className }: ComboboxTriggerProps) {\n  const context = useComboboxContext(\"ComboboxTrigger\");\n\n  return (\n    <div\n      ref={context.triggerRef}\n      id={context.triggerId}\n      data-state={context.open ? \"open\" : \"closed\"}\n      onPointerDown={(event) => {\n        if (context.disabled || event.target === context.inputRef.current) return;\n        event.preventDefault();\n        context.inputRef.current?.focus({ preventScroll: true });\n        context.setOpen(true);\n      }}\n      className={cn(\n        \"relative z-20 flex h-10 w-full min-w-52 cursor-text items-center justify-between gap-3 rounded-xl border border-border bg-transparent px-3 text-sm text-foreground transition-[border-color] hover:border-(--color-border-strong)\",\n        \"focus-within:ring-2 focus-within:ring-foreground/20\",\n        context.disabled && \"pointer-events-none opacity-50\",\n        className,\n      )}\n    >\n      <span className=\"min-w-0 flex-1 text-left\">{children}</span>\n      <span aria-hidden className=\"shrink-0 text-muted-foreground\">\n        <ChevronsUpDown className=\"size-4\" />\n      </span>\n    </div>\n  );\n}\n\nexport interface ComboboxValueProps {\n  placeholder?: ReactNode;\n  children?:\n    | ReactNode\n    | ((value: string | undefined, label: string | undefined) => ReactNode);\n  className?: string;\n}\n\nexport function ComboboxValue({\n  placeholder = \"Select an option\",\n  children,\n  className,\n}: ComboboxValueProps) {\n  const context = useComboboxContext(\"ComboboxValue\");\n  const label = context.labelFor(context.value);\n  const content =\n    typeof children === \"function\"\n      ? children(context.value, label)\n      : children ?? label ?? placeholder;\n\n  return (\n    <span\n      className={cn(\n        \"block truncate\",\n        context.value === undefined\n          ? \"text-muted-foreground\"\n          : \"text-foreground\",\n        className,\n      )}\n    >\n      {content}\n    </span>\n  );\n}\n\nexport interface ComboboxInputProps\n  extends Omit<\n    InputHTMLAttributes<HTMLInputElement>,\n    \"defaultValue\" | \"value\"\n  > {\n  ref?: Ref<HTMLInputElement>;\n  wrapperClassName?: string;\n}\n\nexport function ComboboxInput({\n  ref,\n  className,\n  wrapperClassName,\n  \"aria-label\": ariaLabel = \"Search options\",\n  onChange,\n  onClick,\n  onFocus,\n  onKeyDown,\n  onPointerDown,\n  placeholder = \"Search…\",\n  ...props\n}: ComboboxInputProps) {\n  const context = useComboboxContext(\"ComboboxInput\");\n  const selectedLabel = context.labelFor(context.value);\n\n  const handleKeyDown = (event: ReactKeyboardEvent<HTMLInputElement>) => {\n    onKeyDown?.(event);\n    if (event.defaultPrevented) return;\n\n    if (event.key === \"ArrowDown\" || event.key === \"ArrowUp\") {\n      event.preventDefault();\n      // Opening is the whole action. While closed the list is still filtering\n      // by the query the last session left, so a step taken here would be\n      // measured against rows the next render replaces — and stamped with a\n      // query it no longer has, which discards it. Open onto the selection,\n      // and let the next key step through the list the user can see.\n      if (!context.open) {\n        context.setOpen(true);\n        return;\n      }\n      context.moveActive(event.key === \"ArrowDown\" ? 1 : -1);\n    } else if (event.key === \"Home\" && context.open) {\n      event.preventDefault();\n      context.moveActive(\"first\");\n    } else if (event.key === \"End\" && context.open) {\n      event.preventDefault();\n      context.moveActive(\"last\");\n    } else if (event.key === \"Enter\") {\n      event.preventDefault();\n      if (context.open) context.selectActive();\n      else context.setOpen(true);\n    } else if (event.key === \"Escape\" && context.open) {\n      event.preventDefault();\n      context.setOpen(false, true);\n    }\n  };\n\n  return (\n    <div\n      className={cn(\n        \"flex min-w-0 flex-1 items-center gap-2\",\n        wrapperClassName,\n      )}\n    >\n      <Search aria-hidden className=\"size-4 shrink-0 text-muted-foreground\" />\n      <input\n        {...props}\n        ref={mergeRefs(ref, context.inputRef)}\n        id={context.inputId}\n        role=\"combobox\"\n        aria-label={ariaLabel}\n        aria-autocomplete=\"list\"\n        aria-expanded={context.open}\n        aria-controls={context.listId}\n        aria-activedescendant={\n          context.open ? context.activeItemId : undefined\n        }\n        autoComplete=\"off\"\n        disabled={context.disabled}\n        value={context.open ? context.query : (selectedLabel ?? \"\")}\n        placeholder={placeholder}\n        onPointerDown={(event) => {\n          onPointerDown?.(event);\n          if (event.defaultPrevented || context.open) return;\n          event.preventDefault();\n          context.inputRef.current?.focus({ preventScroll: true });\n          context.setOpen(true);\n        }}\n        onFocus={(event) => {\n          context.setOpen(true);\n          onFocus?.(event);\n        }}\n        onClick={(event) => {\n          context.setOpen(true);\n          onClick?.(event);\n        }}\n        onChange={(event) => {\n          context.setOpen(true);\n          context.setQuery(event.target.value);\n          onChange?.(event);\n        }}\n        onKeyDown={handleKeyDown}\n        className={cn(\n          \"h-10 min-w-0 flex-1 bg-transparent text-sm text-foreground outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed\",\n          className,\n        )}\n      />\n    </div>\n  );\n}\n"},{"path":"components/motion/popover-position.ts","type":"util","content":"\"use client\";\n\nimport {\n  type MutableRefObject,\n  useCallback,\n  useLayoutEffect,\n  useState,\n} from \"react\";\n\nexport type PortalLayout = {\n  trigger: {\n    left: number;\n    top: number;\n    width: number;\n    height: number;\n  };\n  content: {\n    width: number;\n    height: number;\n  };\n};\n\nfunction sameLayout(a: PortalLayout | null, b: PortalLayout) {\n  return (\n    a?.trigger.left === b.trigger.left &&\n    a.trigger.top === b.trigger.top &&\n    a.trigger.width === b.trigger.width &&\n    a.trigger.height === b.trigger.height &&\n    a.content.width === b.content.width &&\n    a.content.height === b.content.height\n  );\n}\n\n/** Measures a trigger and portalled panel in viewport coordinates. */\nexport function usePopoverPortalPosition<\n  TriggerElement extends HTMLElement,\n  ContentElement extends HTMLElement,\n>(\n  triggerRef: MutableRefObject<TriggerElement | null>,\n  contentRef: MutableRefObject<ContentElement | null>,\n  active: boolean,\n) {\n  const [layout, setLayout] = useState<PortalLayout | null>(null);\n\n  const update = useCallback(() => {\n    const trigger = triggerRef.current;\n    const content = contentRef.current;\n    if (!trigger || !content) return;\n\n    const rect = trigger.getBoundingClientRect();\n    const next: PortalLayout = {\n      trigger: {\n        left: rect.left,\n        top: rect.top,\n        width: rect.width,\n        height: rect.height,\n      },\n      content: {\n        width: content.offsetWidth,\n        height: content.offsetHeight,\n      },\n    };\n    setLayout((current) => (sameLayout(current, next) ? current : next));\n  }, [contentRef, triggerRef]);\n\n  useLayoutEffect(() => {\n    update();\n    if (!active) return;\n\n    const trigger = triggerRef.current;\n    const content = contentRef.current;\n    const observer = new ResizeObserver(update);\n    if (trigger) observer.observe(trigger);\n    if (content) observer.observe(content);\n\n    window.addEventListener(\"scroll\", update, true);\n    window.addEventListener(\"resize\", update);\n    return () => {\n      observer.disconnect();\n      window.removeEventListener(\"scroll\", update, true);\n      window.removeEventListener(\"resize\", update);\n    };\n  }, [active, contentRef, triggerRef, update]);\n\n  return layout;\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/motion/combobox/use-active-option.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, stamped with the query it\n * was placed under. Which option is *active* is resolved from this during\n * render, never in an effect: a passive effect runs after the commit, so a list\n * would briefly have none of its options active, and a key arriving in that\n * window would move from nowhere onto the row it was already about to\n * highlight.\n */\ntype ActiveCursor = { value: string; query: string };\n\ntype Options = {\n  query: string;\n  value: string | undefined;\n  /** The enabled, visible options in list order — all this hook reads of them. */\n  enabledItems: readonly { value: string }[];\n};\n\nconst isEnabled = (\n  enabledItems: Options[\"enabledItems\"],\n  candidate: string | undefined,\n): candidate is string =>\n  candidate !== undefined && enabledItems.some((i) => i.value === candidate);\n\n/**\n * The cursor's option, or null once the query or the result set it was placed\n * in has changed. A cursor that outlived either would steal Enter from the row\n * the user is aiming at. The result-set half costs something: a live search\n * that blanks its rows while fetching and returns the same ones loses the moved\n * highlight. That is deliberate — a highlight visibly back at the top beats one\n * silently in the wrong place.\n *\n * It is stamped with the query rather than with the identity of the visible\n * list because callers routinely pass an inline `filter`, which makes that list\n * a fresh array on every render.\n */\nfunction liveCursorValue(cursor: ActiveCursor | null, options: Options) {\n  if (cursor === null || cursor.query !== options.query) return null;\n  return isEnabled(options.enabledItems, cursor.value) ? cursor.value : null;\n}\n\n/**\n * Where the highlight sits with no live cursor: the selection if it can be\n * selected, otherwise the first option that can. Only enabled options qualify —\n * an active disabled option would point `aria-activedescendant` at a row Enter\n * then refuses to select.\n */\nfunction fallbackActive({ value, enabledItems }: Options) {\n  return isEnabled(enabledItems, value) ? value : (enabledItems[0]?.value ?? null);\n}\n\n/** The active option, from a cursor that may or may not still be live. */\nconst resolveActive = (cursor: ActiveCursor | null, options: Options) =>\n  liveCursorValue(cursor, options) ?? fallbackActive(options);\n\nexport function useActiveOption({ open, ...options }: Options & { open: boolean }) {\n  const { query, value, enabledItems } = options;\n  const [cursor, setCursor] = useState<ActiveCursor | null>(null);\n\n  const live = liveCursorValue(cursor, options);\n  // Cleared rather than ignored: React re-runs this render with the cursor\n  // gone, so a value that reappears later cannot revive it.\n  if (cursor !== null && live === null) setCursor(null);\n  const derived = live ?? fallbackActive(options);\n\n  // Nothing is active until the list has been opened once. After that the\n  // resolution above is already stable across a close — the list keeps\n  // filtering by the query it was open with — so the highlight holds its row\n  // through the exit without being frozen separately.\n  const [opened, setOpened] = useState(open);\n  if (open && !opened) setOpened(true);\n  const activeValue = opened ? derived : null;\n\n  // Both callbacks keep one identity for the life of the component, and read\n  // the list through a ref to do it. A caller will put them in a `useMemo` or\n  // an effect's dependencies — the exhaustive-deps rule makes it — and\n  // `enabledItems` is a fresh array on every render for any consumer passing an\n  // inline `filter`, so a callback keyed to it would be rebuilt every render.\n  // Written after commit rather than during render: a render React discards\n  // still runs the component body, and a handler reading this in that window\n  // would step against a list the committed tree does not have.\n  const latest = useRef({ open, query, value, enabledItems });\n  useLayoutEffect(() => {\n    latest.current = { open, query, value, enabledItems };\n  });\n\n  const setActiveValue = useCallback((next: string | null) => {\n    setCursor(\n      next === null ? null : { value: next, query: latest.current.query },\n    );\n  }, []);\n\n  // Steps from the option the cursor really resolves to, inside the update, so\n  // that two keys landing in one batch move two rows rather than one.\n  const moveActive = useCallback(\n    (direction: 1 | -1 | \"first\" | \"last\") => {\n      const options = latest.current;\n      // While closed the list is still filtering by the query it was open\n      // with, so a step taken now would be measured against rows the next\n      // render replaces. Opening is the caller's job; stepping waits for it.\n      if (!options.open) return;\n      const rows = options.enabledItems;\n      const last = rows.length - 1;\n      if (last < 0) {\n        setCursor(null);\n        return;\n      }\n      setCursor((current) => {\n        // `resolveActive` always lands on a member of `enabledItems` once the\n        // list is non-empty, which the early return above guarantees, so there\n        // is always a row to step from.\n        const from = resolveActive(current, options);\n        const at = rows.findIndex((item) => item.value === from);\n        const index =\n          direction === \"first\"\n            ? 0\n            : direction === \"last\"\n              ? last\n              : (at + direction + rows.length) % rows.length;\n        return { value: rows[index].value, query: options.query };\n      });\n    },\n    [],\n  );\n\n  return { activeValue, setActiveValue, moveActive };\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":"components/previews/motion/combobox.preview.tsx","type":"preview","content":"\"use client\";\n\nimport { Blocks, Box, Component, Layers3 } from \"lucide-react\";\nimport { useState } from \"react\";\nimport {\n  Combobox,\n  ComboboxContent,\n  ComboboxEmpty,\n  ComboboxGroup,\n  ComboboxInput,\n  ComboboxItem,\n  ComboboxLabel,\n  ComboboxList,\n  ComboboxSeparator,\n  ComboboxTrigger,\n} from \"@/components/motion/combobox\";\n\nconst WORKSPACES = [\n  {\n    value: \"studio\",\n    label: \"Design studio\",\n    detail: \"12 projects\",\n    group: \"Recent\",\n    icon: Component,\n    color: \"bg-amber-400/20 text-amber-700 dark:text-amber-300\",\n  },\n  {\n    value: \"product\",\n    label: \"Product team\",\n    detail: \"8 projects\",\n    group: \"Recent\",\n    icon: Layers3,\n    color: \"bg-sky-400/20 text-sky-700 dark:text-sky-300\",\n  },\n  {\n    value: \"playground\",\n    label: \"Playground\",\n    detail: \"24 experiments\",\n    group: \"Workspaces\",\n    icon: Blocks,\n    color: \"bg-emerald-400/20 text-emerald-700 dark:text-emerald-300\",\n  },\n  {\n    value: \"archive\",\n    label: \"Component archive\",\n    detail: \"41 components\",\n    group: \"Workspaces\",\n    icon: Box,\n    color: \"bg-rose-400/20 text-rose-700 dark:text-rose-300\",\n  },\n] as const;\n\nfunction WorkspaceMark({ value }: { value: string }) {\n  const workspace = WORKSPACES.find((item) => item.value === value);\n  if (!workspace) return null;\n  const Icon = workspace.icon;\n  return (\n    <span\n      className={`grid size-7 shrink-0 place-items-center rounded-lg ${workspace.color}`}\n    >\n      <Icon className=\"size-3.5\" />\n    </span>\n  );\n}\n\nexport function ComboboxPreview() {\n  const [value, setValue] = useState(\"studio\");\n\n  return (\n    <div className=\"w-full max-w-72\">\n      <p className=\"mb-2 text-xs font-medium text-muted-foreground\">\n        Workspace\n      </p>\n      <Combobox value={value} onValueChange={setValue}>\n        <ComboboxTrigger className=\"h-12 rounded-2xl px-2.5\">\n          <ComboboxInput\n            aria-label=\"Search workspaces\"\n            placeholder=\"Search workspaces…\"\n          />\n        </ComboboxTrigger>\n\n        <ComboboxContent className=\"w-72 rounded-2xl\">\n          <ComboboxList ariaLabel=\"Workspaces\" className=\"p-2\">\n            <ComboboxEmpty>No workspaces found.</ComboboxEmpty>\n            {([\"Recent\", \"Workspaces\"] as const).map((group, groupIndex) => (\n              <ComboboxGroup key={group}>\n                {groupIndex > 0 ? <ComboboxSeparator /> : null}\n                <ComboboxLabel>{group}</ComboboxLabel>\n                {WORKSPACES.filter((item) => item.group === group).map(\n                  (workspace) => (\n                    <ComboboxItem\n                      key={workspace.value}\n                      value={workspace.value}\n                      textValue={workspace.label}\n                      keywords={[workspace.detail, workspace.group]}\n                      className=\"py-2\"\n                    >\n                      <span className=\"flex min-w-0 items-center gap-2.5\">\n                        <WorkspaceMark value={workspace.value} />\n                        <span className=\"min-w-0\">\n                          <span className=\"block truncate font-medium text-foreground\">\n                            {workspace.label}\n                          </span>\n                          <span className=\"block truncate text-xs text-muted-foreground\">\n                            {workspace.detail}\n                          </span>\n                        </span>\n                      </span>\n                    </ComboboxItem>\n                  ),\n                )}\n              </ComboboxGroup>\n            ))}\n          </ComboboxList>\n        </ComboboxContent>\n      </Combobox>\n    </div>\n  );\n}\n"}]}