{"slug":"morphing-search","name":"Morphing Search","description":"Search field or compact icon that morphs into a glass results surface, whether opened by click or keyboard shortcut.","category":"blocks","source_url":"https://beui.dev/r/morphing-search/raw","detail_url":"https://beui.dev/r/morphing-search","raw_url":"https://beui.dev/r/morphing-search/raw","page_url":"https://beui.dev/components/blocks/morphing-search","markdown_url":"https://beui.dev/components/blocks/morphing-search.md","published_at":"2026-08-18","updated_at":"2026-08-22","dependencies":["clsx","lucide-react","motion","react","react-dom","tailwind-merge"],"internal":["@/components/motion/morphing-search","@/lib/ease","@/lib/hooks/use-on-open","@/lib/hooks/use-row-cursor","@/lib/utils"],"files":[{"path":"components/motion/morphing-search.tsx","type":"component","content":"\"use client\";\n// beui.dev/components/blocks/morphing-search\n\nimport { type LucideIcon, Search } from \"lucide-react\";\nimport {\n\tAnimatePresence,\n\tLayoutGroup,\n\tmotion,\n\ttype Transition,\n\tuseReducedMotion,\n} from \"motion/react\";\nimport {\n\ttype KeyboardEvent as ReactKeyboardEvent,\n\tuseCallback,\n\tuseEffect,\n\tuseId,\n\tuseLayoutEffect,\n\tuseMemo,\n\tuseRef,\n\tuseState,\n} from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { EASE_OUT, SPRING_LAYOUT } from \"@/lib/ease\";\nimport { useOnOpen } from \"@/lib/hooks/use-on-open\";\nimport { useRowCursor } from \"@/lib/hooks/use-row-cursor\";\nimport { cn } from \"@/lib/utils\";\n\n// Keeps the Wallet Card feel with a little more time to read the morph.\nconst SEARCH_MORPH: Transition = {\n\ttype: \"spring\",\n\tduration: 0.58,\n\tbounce: 0.22,\n};\n\n// Keep the spring on the shell, but unfold complex clip-path values with the\n// same progressive tween as Morph Popover so the content never snaps ahead.\nconst SEARCH_CLIP_TRANSITION: Transition = {\n\tduration: 0.32,\n\tease: EASE_OUT,\n};\n\nexport type MorphingSearchItem = {\n\tid: string;\n\ttitle: string;\n\tdescription?: string;\n\tkeywords?: string[];\n\ticon?: LucideIcon;\n\tonSelect?: () => void;\n};\n\nexport interface MorphingSearchProps {\n\titems: MorphingSearchItem[];\n\tplaceholder?: string;\n\tshortcut?: string;\n\t/** Render the closed trigger as a compact search icon. */\n\ticonOnly?: boolean;\n\temptyMessage?: string;\n\topen?: boolean;\n\tdefaultOpen?: boolean;\n\tonOpenChange?: (open: boolean) => void;\n\tonQueryChange?: (query: string) => void;\n\tonSelect?: (item: MorphingSearchItem) => void;\n\tclassName?: string;\n}\n\ntype AnchorRect = {\n\ttop: number;\n\tleft: number;\n\twidth: number;\n};\n\nfunction isEditableTarget(target: EventTarget | null) {\n\tif (!(target instanceof HTMLElement)) return false;\n\treturn (\n\t\ttarget.isContentEditable ||\n\t\ttarget instanceof HTMLInputElement ||\n\t\ttarget instanceof HTMLTextAreaElement ||\n\t\ttarget instanceof HTMLSelectElement\n\t);\n}\n\nexport function MorphingSearch({\n\titems,\n\tplaceholder = \"Search\",\n\tshortcut = \"f\",\n\ticonOnly = false,\n\temptyMessage = \"No results found.\",\n\topen: controlledOpen,\n\tdefaultOpen = false,\n\tonOpenChange,\n\tonQueryChange,\n\tonSelect,\n\tclassName,\n}: MorphingSearchProps) {\n\tconst [internalOpen, setInternalOpen] = useState(defaultOpen);\n\tconst [query, setQuery] = useState(\"\");\n\tconst [mounted, setMounted] = useState(false);\n\tconst [backgroundScrollLocked, setBackgroundScrollLocked] =\n\t\tuseState(defaultOpen);\n\tconst [anchorRect, setAnchorRect] = useState<AnchorRect>({\n\t\ttop: 16,\n\t\tleft: 16,\n\t\twidth: 288,\n\t});\n\tconst open = controlledOpen ?? internalOpen;\n\tconst controlled = controlledOpen !== undefined;\n\tconst reduce = useReducedMotion();\n\tconst uid = useId();\n\tconst anchorRef = useRef<HTMLDivElement>(null);\n\tconst triggerRef = useRef<HTMLButtonElement>(null);\n\tconst inputRef = useRef<HTMLInputElement>(null);\n\tconst dialogRef = useRef<HTMLDivElement>(null);\n\tconst listRef = useRef<HTMLDivElement>(null);\n\tconst previousFocusRef = useRef<HTMLElement | null>(null);\n\tconst wasOpenRef = useRef(open);\n\tconst transition: Transition = reduce ? { duration: 0 } : SPRING_LAYOUT;\n\tconst morphTransition: Transition = reduce ? { duration: 0 } : SEARCH_MORPH;\n\n\tconst setOpen = useCallback(\n\t\t(next: boolean) => {\n\t\t\tif (!controlled) setInternalOpen(next);\n\t\t\tonOpenChange?.(next);\n\t\t},\n\t\t[controlled, onOpenChange],\n\t);\n\n\tconst measureAnchor = useCallback(() => {\n\t\tconst rect = anchorRef.current?.getBoundingClientRect();\n\t\tif (!rect || rect.width === 0) return;\n\t\tsetAnchorRect({ top: rect.top, left: rect.left, width: rect.width });\n\t}, []);\n\n\tconst openSearch = useCallback(() => {\n\t\tmeasureAnchor();\n\t\tsetBackgroundScrollLocked(true);\n\t\tpreviousFocusRef.current =\n\t\t\tdocument.activeElement instanceof HTMLElement\n\t\t\t\t? document.activeElement\n\t\t\t\t: null;\n\t\tsetOpen(true);\n\t}, [measureAnchor, setOpen]);\n\n\tconst filteredItems = useMemo(() => {\n\t\tconst needle = query.trim().toLowerCase();\n\t\tif (!needle) return items;\n\n\t\treturn items.filter((item) =>\n\t\t\t[item.title, item.description ?? \"\", ...(item.keywords ?? [])]\n\t\t\t\t.join(\" \")\n\t\t\t\t.toLowerCase()\n\t\t\t\t.includes(needle),\n\t\t);\n\t}, [items, query]);\n\n\tconst { activeIndex, moveTo, moveActive } = useRowCursor(filteredItems, query);\n\n\t// The cursor is stamped with the query, so changing it drops the highlight\n\t// without this having to say so.\n\tconst updateQuery = useCallback(\n\t\t(next: string) => {\n\t\t\tsetQuery(next);\n\t\t\tonQueryChange?.(next);\n\t\t},\n\t\t[onQueryChange],\n\t);\n\n\tconst closeSearch = useCallback(() => {\n\t\tupdateQuery(\"\");\n\t\tsetOpen(false);\n\t}, [setOpen, updateQuery]);\n\n\tuseEffect(() => setMounted(true), []);\n\n\tuseEffect(() => {\n\t\tif (open) setBackgroundScrollLocked(true);\n\t}, [open]);\n\n\tuseEffect(() => {\n\t\tmeasureAnchor();\n\t\tconst anchor = anchorRef.current;\n\t\tconst observer =\n\t\t\tanchor && typeof ResizeObserver !== \"undefined\"\n\t\t\t\t? new ResizeObserver(measureAnchor)\n\t\t\t\t: null;\n\t\tif (anchor) observer?.observe(anchor);\n\t\twindow.addEventListener(\"resize\", measureAnchor);\n\t\tdocument.addEventListener(\"scroll\", measureAnchor, true);\n\t\twindow.visualViewport?.addEventListener(\"resize\", measureAnchor);\n\t\twindow.visualViewport?.addEventListener(\"scroll\", measureAnchor);\n\t\treturn () => {\n\t\t\tobserver?.disconnect();\n\t\t\twindow.removeEventListener(\"resize\", measureAnchor);\n\t\t\tdocument.removeEventListener(\"scroll\", measureAnchor, true);\n\t\t\twindow.visualViewport?.removeEventListener(\"resize\", measureAnchor);\n\t\t\twindow.visualViewport?.removeEventListener(\"scroll\", measureAnchor);\n\t\t};\n\t}, [measureAnchor]);\n\n\tuseEffect(() => {\n\t\tif (!backgroundScrollLocked) return;\n\n\t\tconst preventBackgroundWheel = (event: WheelEvent) => {\n\t\t\tconst target = event.target;\n\t\t\tif (target instanceof Node && listRef.current?.contains(target)) return;\n\t\t\tevent.preventDefault();\n\t\t};\n\t\tconst preventBackgroundTouch = (event: TouchEvent) => {\n\t\t\tconst target = event.target;\n\t\t\tif (target instanceof Node && listRef.current?.contains(target)) return;\n\t\t\tevent.preventDefault();\n\t\t};\n\n\t\tdocument.addEventListener(\"wheel\", preventBackgroundWheel, {\n\t\t\tpassive: false,\n\t\t});\n\t\tdocument.addEventListener(\"touchmove\", preventBackgroundTouch, {\n\t\t\tpassive: false,\n\t\t});\n\t\treturn () => {\n\t\t\tdocument.removeEventListener(\"wheel\", preventBackgroundWheel);\n\t\t\tdocument.removeEventListener(\"touchmove\", preventBackgroundTouch);\n\t\t};\n\t}, [backgroundScrollLocked]);\n\n\tuseEffect(() => {\n\t\tconst handleShortcut = (event: KeyboardEvent) => {\n\t\t\tif (event.key === \"Escape\" && open) {\n\t\t\t\tevent.preventDefault();\n\t\t\t\tcloseSearch();\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tif (\n\t\t\t\t!open &&\n\t\t\t\tshortcut &&\n\t\t\t\tevent.key.toLowerCase() === shortcut.toLowerCase() &&\n\t\t\t\t!event.repeat &&\n\t\t\t\t!event.metaKey &&\n\t\t\t\t!event.ctrlKey &&\n\t\t\t\t!event.altKey &&\n\t\t\t\t!event.shiftKey &&\n\t\t\t\t!isEditableTarget(event.target)\n\t\t\t) {\n\t\t\t\tevent.preventDefault();\n\t\t\t\topenSearch();\n\t\t\t}\n\t\t};\n\n\t\twindow.addEventListener(\"keydown\", handleShortcut);\n\t\treturn () => window.removeEventListener(\"keydown\", handleShortcut);\n\t}, [closeSearch, open, openSearch, shortcut]);\n\n\t// Only this component's own state. Telling the consumer the query changed is\n\t// a side effect, so it waits for the effect below.\n\tuseOnOpen(open, () => {\n\t\tsetQuery(\"\");\n\t\tmoveTo(null);\n\t});\n\n\t// Keyed to `open` alone. `onQueryChange` is read through a ref because an\n\t// inline one changes identity on every keystroke, and this effect clearing\n\t// the field on every keystroke is exactly what that costs. Written after\n\t// commit for the reason `lib/hooks/use-row-cursor.ts` gives at length.\n\tconst notifyQuery = useRef(onQueryChange);\n\tuseLayoutEffect(() => {\n\t\tnotifyQuery.current = onQueryChange;\n\t});\n\n\tuseEffect(() => {\n\t\tif (open) {\n\t\t\tnotifyQuery.current?.(\"\");\n\t\t\tconst frame = requestAnimationFrame(() => inputRef.current?.focus());\n\t\t\treturn () => cancelAnimationFrame(frame);\n\t\t}\n\n\t\tif (wasOpenRef.current) {\n\t\t\tconst frame = requestAnimationFrame(() => {\n\t\t\t\tconst previousFocus = previousFocusRef.current;\n\t\t\t\tconst focusTarget = previousFocus?.isConnected\n\t\t\t\t\t? previousFocus\n\t\t\t\t\t: triggerRef.current;\n\t\t\t\tfocusTarget?.focus();\n\t\t\t});\n\t\t\treturn () => cancelAnimationFrame(frame);\n\t\t}\n\t}, [open]);\n\n\tuseEffect(() => {\n\t\twasOpenRef.current = open;\n\t}, [open]);\n\n\tuseEffect(() => {\n\t\tif (!open) return;\n\t\tlistRef.current\n\t\t\t?.querySelector<HTMLElement>(`[data-index=\"${activeIndex}\"]`)\n\t\t\t?.scrollIntoView({ block: \"nearest\" });\n\t}, [activeIndex, open]);\n\n\tconst selectItem = useCallback(\n\t\t(item: MorphingSearchItem) => {\n\t\t\titem.onSelect?.();\n\t\t\tonSelect?.(item);\n\t\t\tcloseSearch();\n\t\t},\n\t\t[closeSearch, onSelect],\n\t);\n\n\tconst handleDialogKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {\n\t\tif (event.key === \"ArrowDown\") {\n\t\t\tevent.preventDefault();\n\t\t\tmoveActive(1);\n\t\t\treturn;\n\t\t}\n\n\t\tif (event.key === \"ArrowUp\") {\n\t\t\tevent.preventDefault();\n\t\t\tmoveActive(-1);\n\t\t\treturn;\n\t\t}\n\n\t\tif (event.key === \"Enter\") {\n\t\t\tevent.preventDefault();\n\t\t\tconst item = filteredItems[activeIndex];\n\t\t\tif (item) selectItem(item);\n\t\t\treturn;\n\t\t}\n\n\t\tif (event.key !== \"Tab\" || !dialogRef.current) return;\n\t\tconst focusable = Array.from(\n\t\t\tdialogRef.current.querySelectorAll<HTMLElement>(\n\t\t\t\t'input, button:not([disabled]), [tabindex]:not([tabindex=\"-1\"])',\n\t\t\t),\n\t\t);\n\t\tconst first = focusable[0];\n\t\tconst last = focusable.at(-1);\n\t\tif (!first || !last) return;\n\n\t\tif (event.shiftKey && document.activeElement === first) {\n\t\t\tevent.preventDefault();\n\t\t\tlast.focus();\n\t\t} else if (!event.shiftKey && document.activeElement === last) {\n\t\t\tevent.preventDefault();\n\t\t\tfirst.focus();\n\t\t}\n\t};\n\n\tconst shellLayoutId = `${uid}-shell`;\n\tconst listboxId = `${uid}-results`;\n\tconst panelWidth = mounted\n\t\t? Math.max(\n\t\t\t\tanchorRect.width,\n\t\t\t\tMath.min(448, window.innerWidth - anchorRect.left - 16),\n\t\t\t)\n\t\t: anchorRect.width;\n\tconst resultsHeight = mounted\n\t\t? Math.max(96, Math.min(288, window.innerHeight - anchorRect.top - 80))\n\t\t: 288;\n\tconst collapsedContentClip = `inset(0px ${Math.max(\n\t\t0,\n\t\tpanelWidth - anchorRect.width,\n\t)}px ${resultsHeight}px 0px round 12px)`;\n\tconst expandedContentClip = \"inset(0px 0px 0px 0px round 12px)\";\n\n\t// Neither grouping layer carries a box: they only hold `inert`/`aria-hidden`,\n\t// the z-index and the presence key, and every child below is `fixed` and\n\t// resolves against the viewport itself. The click catcher spans the viewport\n\t// edges but has no children and filters nothing, so it is not a sampling\n\t// layer either.\n\tconst overlay = mounted\n\t\t? createPortal(\n\t\t\t\t<div\n\t\t\t\t\taria-hidden={!open}\n\t\t\t\t\tinert={!open}\n\t\t\t\t\tclassName=\"pointer-events-none fixed left-0 top-0 z-50 size-0\"\n\t\t\t\t>\n\t\t\t\t\t<AnimatePresence\n\t\t\t\t\t\tinitial={false}\n\t\t\t\t\t\tmode=\"popLayout\"\n\t\t\t\t\t\tonExitComplete={() => setBackgroundScrollLocked(false)}\n\t\t\t\t\t>\n\t\t\t\t\t\t{open ? (\n\t\t\t\t\t\t\t<motion.div\n\t\t\t\t\t\t\t\tkey=\"morphing-search-overlay\"\n\t\t\t\t\t\t\t\tclassName=\"fixed left-0 top-0 size-0\"\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\t\taria-label=\"Close search\"\n\t\t\t\t\t\t\t\t\tclassName=\"pointer-events-auto fixed inset-0 cursor-default bg-transparent\"\n\t\t\t\t\t\t\t\t\tonClick={closeSearch}\n\t\t\t\t\t\t\t\t/>\n\n\t\t\t\t\t\t\t\t<motion.div\n\t\t\t\t\t\t\t\t\tlayoutId={shellLayoutId}\n\t\t\t\t\t\t\t\t\taria-hidden=\"true\"\n\t\t\t\t\t\t\t\t\tclassName=\"fixed z-10 rounded-xl bg-background/90 backdrop-blur-xl\"\n\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\ttop: anchorRect.top,\n\t\t\t\t\t\t\t\t\t\tleft: anchorRect.left,\n\t\t\t\t\t\t\t\t\t\twidth: panelWidth,\n\t\t\t\t\t\t\t\t\t\theight: 48 + resultsHeight,\n\t\t\t\t\t\t\t\t\t\tboxShadow: \"inset 0 0 0 1px var(--color-border)\",\n\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\ttransition={morphTransition}\n\t\t\t\t\t\t\t\t/>\n\n\t\t\t\t\t\t\t\t<motion.div\n\t\t\t\t\t\t\t\t\tref={dialogRef}\n\t\t\t\t\t\t\t\t\trole=\"dialog\"\n\t\t\t\t\t\t\t\t\taria-modal=\"true\"\n\t\t\t\t\t\t\t\t\taria-label=\"Search\"\n\t\t\t\t\t\t\t\t\tonKeyDown={handleDialogKeyDown}\n\t\t\t\t\t\t\t\t\tinitial={\n\t\t\t\t\t\t\t\t\t\treduce\n\t\t\t\t\t\t\t\t\t\t\t? false\n\t\t\t\t\t\t\t\t\t\t\t: { opacity: 0, clipPath: collapsedContentClip }\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\tanimate={{ opacity: 1, clipPath: expandedContentClip }}\n\t\t\t\t\t\t\t\t\texit={{\n\t\t\t\t\t\t\t\t\t\topacity: 0,\n\t\t\t\t\t\t\t\t\t\tclipPath: collapsedContentClip,\n\t\t\t\t\t\t\t\t\t\ttransition: reduce\n\t\t\t\t\t\t\t\t\t\t\t? { duration: 0 }\n\t\t\t\t\t\t\t\t\t\t\t: {\n\t\t\t\t\t\t\t\t\t\t\t\t\tclipPath: SEARCH_CLIP_TRANSITION,\n\t\t\t\t\t\t\t\t\t\t\t\t\topacity: SEARCH_MORPH,\n\t\t\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\ttransition={\n\t\t\t\t\t\t\t\t\t\treduce\n\t\t\t\t\t\t\t\t\t\t\t? { duration: 0 }\n\t\t\t\t\t\t\t\t\t\t\t: {\n\t\t\t\t\t\t\t\t\t\t\t\t\tclipPath: SEARCH_CLIP_TRANSITION,\n\t\t\t\t\t\t\t\t\t\t\t\t\topacity: SEARCH_MORPH,\n\t\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\tclassName=\"pointer-events-auto fixed z-20 overflow-hidden rounded-xl\"\n\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\ttop: anchorRect.top,\n\t\t\t\t\t\t\t\t\t\tleft: anchorRect.left,\n\t\t\t\t\t\t\t\t\t\twidth: panelWidth,\n\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\t\t\t\t\"flex h-12 items-center gap-2.5 border-b border-border\",\n\t\t\t\t\t\t\t\t\t\t\ticonOnly ? \"px-4\" : \"px-3.5\",\n\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t<span className=\"shrink-0\">\n\t\t\t\t\t\t\t\t\t\t\t<Search className=\"size-4 text-muted-foreground\" />\n\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t<div className=\"flex h-10 min-w-0 flex-1 items-center\">\n\t\t\t\t\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\t\t\t\t\tref={inputRef}\n\t\t\t\t\t\t\t\t\t\t\t\tvalue={query}\n\t\t\t\t\t\t\t\t\t\t\t\tonChange={(event) => updateQuery(event.target.value)}\n\t\t\t\t\t\t\t\t\t\t\t\trole=\"combobox\"\n\t\t\t\t\t\t\t\t\t\t\t\taria-label={placeholder}\n\t\t\t\t\t\t\t\t\t\t\t\taria-expanded=\"true\"\n\t\t\t\t\t\t\t\t\t\t\t\taria-controls={listboxId}\n\t\t\t\t\t\t\t\t\t\t\t\taria-autocomplete=\"list\"\n\t\t\t\t\t\t\t\t\t\t\t\taria-activedescendant={\n\t\t\t\t\t\t\t\t\t\t\t\t\tfilteredItems.length > 0\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t? `${uid}-option-${activeIndex}`\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t: undefined\n\t\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\t\t\tplaceholder={placeholder}\n\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"size-full bg-transparent text-sm text-foreground outline-none placeholder:text-muted-foreground\"\n\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t<kbd className=\"flex h-7 shrink-0 items-center rounded-md border border-border px-2 text-xs text-muted-foreground\">\n\t\t\t\t\t\t\t\t\t\t\tEsc\n\t\t\t\t\t\t\t\t\t\t</kbd>\n\t\t\t\t\t\t\t\t\t</div>\n\n\t\t\t\t\t\t\t\t\t<motion.div\n\t\t\t\t\t\t\t\t\t\tref={listRef}\n\t\t\t\t\t\t\t\t\t\tid={listboxId}\n\t\t\t\t\t\t\t\t\t\trole=\"listbox\"\n\t\t\t\t\t\t\t\t\t\taria-label=\"Search results\"\n\t\t\t\t\t\t\t\t\t\ttransition={reduce ? { duration: 0 } : undefined}\n\t\t\t\t\t\t\t\t\t\tvariants={\n\t\t\t\t\t\t\t\t\t\t\treduce\n\t\t\t\t\t\t\t\t\t\t\t\t? undefined\n\t\t\t\t\t\t\t\t\t\t\t\t: {\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tclosed: {\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\topacity: 0,\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\ttransform: \"translateY(6px)\",\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\ttransition: {\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tduration: 0.16,\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tdelay: 0.18,\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tease: EASE_OUT,\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t\t\t\t\t\topen: {\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\topacity: 1,\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\ttransform: \"translateY(0px)\",\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\ttransition: {\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tduration: 0.16,\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tease: EASE_OUT,\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\tinitial={\n\t\t\t\t\t\t\t\t\t\t\treduce\n\t\t\t\t\t\t\t\t\t\t\t\t? { opacity: 1, transform: \"translateY(0px)\" }\n\t\t\t\t\t\t\t\t\t\t\t\t: \"closed\"\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\tanimate={\n\t\t\t\t\t\t\t\t\t\t\treduce\n\t\t\t\t\t\t\t\t\t\t\t\t? { opacity: 1, transform: \"translateY(0px)\" }\n\t\t\t\t\t\t\t\t\t\t\t\t: \"open\"\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\texit={reduce ? undefined : \"closed\"}\n\t\t\t\t\t\t\t\t\t\tclassName=\"overscroll-contain overflow-y-auto p-2\"\n\t\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\t\tmaxHeight: resultsHeight,\n\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t{filteredItems.length > 0 ? (\n\t\t\t\t\t\t\t\t\t\t\tfilteredItems.map((item, index) => {\n\t\t\t\t\t\t\t\t\t\t\t\tconst Icon = item.icon;\n\t\t\t\t\t\t\t\t\t\t\t\tconst active = index === activeIndex;\n\t\t\t\t\t\t\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tkey={item.id}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tid={`${uid}-option-${index}`}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t\trole=\"option\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t\taria-selected={active}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tdata-index={index}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tonMouseMove={() => moveTo(item.id)}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tonFocus={() => moveTo(item.id)}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tonClick={() => selectItem(item)}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"relative flex w-full items-center gap-2.5 rounded-lg px-3 py-2.5 text-left outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t{active ? (\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t<motion.span\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tlayoutId={`${uid}-active-result`}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"absolute inset-0 rounded-lg bg-foreground/5\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\ttransition={transition}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t) : null}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t{Icon ? (\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t<Icon className=\"relative size-4 shrink-0 text-muted-foreground\" />\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t) : null}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<span className=\"relative min-w-0\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t<span className=\"block truncate text-sm font-medium text-foreground\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t{item.title}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t{item.description ? (\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t<span className=\"block truncate text-xs text-muted-foreground\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t{item.description}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t) : null}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t\t\t\t\t);\n\t\t\t\t\t\t\t\t\t\t\t})\n\t\t\t\t\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\t\t\t\t<p className=\"px-3 py-8 text-center text-sm text-muted-foreground\">\n\t\t\t\t\t\t\t\t\t\t\t\t{emptyMessage}\n\t\t\t\t\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t</motion.div>\n\t\t\t\t\t\t\t\t</motion.div>\n\t\t\t\t\t\t\t</motion.div>\n\t\t\t\t\t\t) : null}\n\t\t\t\t\t</AnimatePresence>\n\t\t\t\t</div>,\n\t\t\t\tdocument.body,\n\t\t\t)\n\t\t: null;\n\n\treturn (\n\t\t<LayoutGroup id={uid}>\n\t\t\t<div\n\t\t\t\tref={anchorRef}\n\t\t\t\tclassName={cn(\n\t\t\t\t\t\"relative\",\n\t\t\t\t\ticonOnly ? \"size-12\" : \"h-12 w-72 max-w-full\",\n\t\t\t\t\tclassName,\n\t\t\t\t)}\n\t\t\t>\n\t\t\t\t{!open ? (\n\t\t\t\t\t<motion.button\n\t\t\t\t\t\tref={triggerRef}\n\t\t\t\t\t\tkey=\"morphing-search-trigger\"\n\t\t\t\t\t\tlayoutId={shellLayoutId}\n\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\taria-haspopup=\"dialog\"\n\t\t\t\t\t\taria-expanded=\"false\"\n\t\t\t\t\t\taria-label={placeholder}\n\t\t\t\t\t\tonClick={openSearch}\n\t\t\t\t\t\ttransition={morphTransition}\n\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\tboxShadow: \"inset 0 0 0 1px var(--search-trigger-stroke)\",\n\t\t\t\t\t\t}}\n\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\"flex size-full items-center rounded-xl bg-background/60 text-left backdrop-blur-md outline-none [--search-trigger-stroke:var(--color-border)] hover:[--search-trigger-stroke:var(--color-border-strong)] focus-visible:ring-2 focus-visible:ring-ring\",\n\t\t\t\t\t\t\ticonOnly ? \"cursor-pointer justify-center\" : \"cursor-text px-3.5\",\n\t\t\t\t\t\t)}\n\t\t\t\t\t></motion.button>\n\t\t\t\t) : null}\n\t\t\t\t<motion.div\n\t\t\t\t\taria-hidden=\"true\"\n\t\t\t\t\tinitial={false}\n\t\t\t\t\tanimate={{ opacity: open ? 0 : 1 }}\n\t\t\t\t\ttransition={\n\t\t\t\t\t\treduce\n\t\t\t\t\t\t\t? { duration: 0 }\n\t\t\t\t\t\t\t: {\n\t\t\t\t\t\t\t\t\tduration: 0.1,\n\t\t\t\t\t\t\t\t\tdelay: open ? 0.1 : 0.12,\n\t\t\t\t\t\t\t\t\tease: EASE_OUT,\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\"pointer-events-none absolute inset-0 flex items-center\",\n\t\t\t\t\t\tbackgroundScrollLocked && \"z-[60]\",\n\t\t\t\t\t\ticonOnly ? \"justify-center\" : \"gap-2.5 px-3.5\",\n\t\t\t\t\t)}\n\t\t\t\t>\n\t\t\t\t\t<Search className=\"size-4 shrink-0 text-muted-foreground\" />\n\t\t\t\t\t{iconOnly ? null : (\n\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t<span className=\"min-w-0 flex-1 truncate text-sm text-muted-foreground\">\n\t\t\t\t\t\t\t\t{placeholder}\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t{shortcut ? (\n\t\t\t\t\t\t\t\t<kbd className=\"flex h-7 min-w-7 shrink-0 items-center justify-center rounded-md border border-border px-2 text-xs text-muted-foreground\">\n\t\t\t\t\t\t\t\t\t{shortcut.toUpperCase()}\n\t\t\t\t\t\t\t\t</kbd>\n\t\t\t\t\t\t\t) : null}\n\t\t\t\t\t\t</>\n\t\t\t\t\t)}\n\t\t\t\t</motion.div>\n\t\t\t</div>\n\t\t\t{overlay}\n\t\t</LayoutGroup>\n\t);\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/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/morphing-search.preview.tsx","type":"preview","content":"\"use client\";\n\nimport { Blocks, BookOpen, Bot, FolderOpen, Palette } from \"lucide-react\";\nimport {\n  MorphingSearch,\n  type MorphingSearchItem,\n} from \"@/components/motion/morphing-search\";\n\nconst ITEMS: MorphingSearchItem[] = [\n  {\n    id: \"project-folder\",\n    title: \"Project Folder\",\n    description: \"Block · Files and previews\",\n    keywords: [\"files\", \"overlay\"],\n    icon: FolderOpen,\n  },\n  {\n    id: \"motion-components\",\n    title: \"Motion components\",\n    description: \"Collection · Interaction primitives\",\n    keywords: [\"animation\", \"components\"],\n    icon: Blocks,\n  },\n  {\n    id: \"agent-interfaces\",\n    title: \"Agent interfaces\",\n    description: \"Collection · AI building blocks\",\n    keywords: [\"ai\", \"chat\"],\n    icon: Bot,\n  },\n  {\n    id: \"installation\",\n    title: \"Installation guide\",\n    description: \"Documentation · Add your first component\",\n    keywords: [\"setup\", \"shadcn\"],\n    icon: BookOpen,\n  },\n  {\n    id: \"design-tokens\",\n    title: \"Design tokens\",\n    description: \"Documentation · Color, type, and motion\",\n    keywords: [\"theme\", \"styles\"],\n    icon: Palette,\n  },\n];\n\nexport function MorphingSearchPreview() {\n  return (\n    <div className=\"flex w-full max-w-[22rem] items-center gap-3\">\n      <MorphingSearch items={ITEMS} placeholder=\"Find components\" />\n      <MorphingSearch\n        items={ITEMS}\n        placeholder=\"Find components\"\n        shortcut=\"\"\n        iconOnly\n      />\n    </div>\n  );\n}\n"}]}