{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "morph-search",
  "type": "registry:ui",
  "title": "Morph Search",
  "description": "A search button that stretches into a search field, with results unfolding underneath as one shape, and shrinks back into the button when it closes.",
  "dependencies": [
    "motion",
    "clsx",
    "tailwind-merge"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/ui/morph-search.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { animate, motion, useMotionValue, useTransform } from \"motion/react\";\n\nimport { cn } from \"@/lib/utils\";\n\n/* ─────────────────────────────────────────────────────────\n * MORPH SEARCH STORYBOARD\n *\n *  open     the trigger stretches into a search field, one\n *           shape the whole way: its own look fades into the\n *           field's, the magnifier slides to the field's left\n *           edge, and the caret is already in the input\n *  unfold   ~90ms later the field's bottom edge keeps growing\n *           into the results; rows ripple in top to bottom\n *  type     results filter as you type; the panel's height\n *           follows on a spring and each match lights up\n *  move     ↑ ↓ (or the pointer) slides one highlight between\n *           rows\n *  close    Esc, a click outside, or picking a result: the\n *           results fold up, then the field shrinks back into\n *           the trigger, exactly where it started. Grab it on\n *           the way back to open it again\n *  palette  instead of growing in place, the field travels to\n *           the middle of the screen over a dimmed page, and\n *           flies back to the trigger on close\n * ───────────────────────────────────────────────────────── */\n\nconst MORPH = {\n  open: { type: \"spring\", visualDuration: 0.36, bounce: 0.16 },\n  travel: { type: \"spring\", visualDuration: 0.42, bounce: 0.08 }, // palette: a longer trip, less overshoot\n  close: { type: \"spring\", visualDuration: 0.3, bounce: 0, delay: 0.05 },\n  unfold: { type: \"spring\", visualDuration: 0.34, bounce: 0.1, delay: 0.09 },\n  resize: { type: \"spring\", visualDuration: 0.26, bounce: 0.06 },\n  fold: { type: \"spring\", visualDuration: 0.2, bounce: 0 },\n  highlight: { type: \"spring\", visualDuration: 0.18, bounce: 0.12 },\n  fade: { duration: 0.16, ease: \"easeOut\" },\n  stagger: 0.022, // s between rows as they ripple in\n  margin: 12, // px kept clear of the viewport edges\n} as const;\n\nconst SIZES = {\n  // `pad` is the gap before the magnifier; `radius` rounds the field and\n  // eases to `panelRadius` once results open under it.\n  inline: { width: 360, height: 44, radius: 22, panelRadius: 18, glyph: 16, pad: 14, maxList: 340 },\n  palette: { width: 560, height: 56, radius: 18, panelRadius: 18, glyph: 18, pad: 18, maxList: 380 },\n} as const;\n\nconst FOOTER_HEIGHT = 40;\n\nexport interface MorphSearchItem {\n  id: string;\n  label: string;\n  /** Muted text after the label. */\n  description?: string;\n  /** Results are grouped under this heading, in order of each group's best match. */\n  group?: string;\n  icon?: React.ReactNode;\n  /** Keys shown on the right, e.g. `[\"⌘\", \"N\"]`. */\n  shortcut?: string[];\n  /** Extra words that should find this item. */\n  keywords?: string[];\n}\n\nexport interface MorphSearchProps\n  extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, \"onSelect\" | \"children\"> {\n  items: MorphSearchItem[];\n  /** Shown before anything is typed. Defaults to `items`. */\n  suggestions?: MorphSearchItem[];\n  onSelect?: (item: MorphSearchItem) => void;\n  onQueryChange?: (query: string) => void;\n  /** `inline` grows in place; `palette` travels to the middle of the screen. */\n  variant?: \"inline\" | \"palette\";\n  placeholder?: string;\n  /** Width of the open field, in px. */\n  width?: number;\n  /** Inline only: which edge of the trigger the field grows from. Defaults to the side with more room. */\n  align?: \"start\" | \"end\";\n  /** `\"k\"` for ⌘K / Ctrl+K, `\"/\"` for a bare slash. */\n  hotkey?: string;\n  /** Scores an item against the query; 0 hides it, higher ranks first. */\n  filter?: (item: MorphSearchItem, query: string) => number;\n  emptyMessage?: (query: string) => React.ReactNode;\n  /** Keyboard hints under the results. Defaults to on for the palette. */\n  footer?: boolean;\n  /** Classes for the open surface's background. */\n  surfaceClassName?: string;\n  /** Custom trigger content. Mark its magnifier with `MorphSearchIcon` so it slides into the field. */\n  children?: React.ReactNode;\n}\n\ntype Rect = { x: number; y: number; width: number; height: number; radius: number };\ntype Geometry = {\n  from: Rect; // the trigger\n  to: Rect; // the open field\n  // The magnifier's center, relative to the shape's top-left, at each end.\n  glyphFrom: { x: number; y: number; scale: number };\n  glyphTo: { x: number; y: number };\n  glyphFades: boolean; // no magnifier in the trigger: fade it in instead\n  detached: boolean; // trigger off screen: fade in place instead of morphing\n};\n\nconst NO_RECT: Rect = { x: 0, y: 0, width: 0, height: 0, radius: 0 };\nconst NO_GEOMETRY: Geometry = {\n  from: NO_RECT,\n  to: NO_RECT,\n  glyphFrom: { x: 0, y: 0, scale: 1 },\n  glyphTo: { x: 0, y: 0 },\n  glyphFades: true,\n  detached: true,\n};\n\nexport function MorphSearch({\n  items,\n  suggestions,\n  onSelect,\n  onQueryChange,\n  variant = \"inline\",\n  placeholder = \"Search…\",\n  width,\n  align,\n  hotkey,\n  filter = defaultFilter,\n  emptyMessage = defaultEmptyMessage,\n  footer = variant === \"palette\",\n  surfaceClassName,\n  className,\n  children,\n  onClick,\n  ...props\n}: MorphSearchProps) {\n  const size = SIZES[variant];\n  const id = React.useId();\n  const listId = `${id}-list`;\n  const optionId = (index: number) => `${id}-option-${index}`;\n\n  const triggerRef = React.useRef<HTMLButtonElement>(null);\n  const layerRef = React.useRef<HTMLElement | null>(null);\n  const surfaceRef = React.useRef<HTMLDivElement>(null);\n  const inputRef = React.useRef<HTMLInputElement>(null);\n  const panelRef = React.useRef<HTMLDivElement>(null);\n  const listRef = React.useRef<HTMLDivElement>(null);\n  const highlightRef = React.useRef<HTMLDivElement>(null);\n  const setLayer = React.useCallback((element: HTMLElement | null) => {\n    layerRef.current = element;\n  }, []);\n\n  const [mounted, setMounted] = React.useState(false);\n  const [query, setQuery] = React.useState(\"\");\n  const [active, setActive] = React.useState(0);\n  const [maxList, setMaxList] = React.useState<number>(size.maxList);\n  // True for the first frames of each opening, so rows ripple in once.\n  const [revealing, setRevealing] = React.useState(true);\n  const [reduceMotion, setReduceMotion] = React.useState(false);\n\n  const live = React.useRef({\n    phase: \"closed\" as \"closed\" | \"open\" | \"closing\",\n    generation: 0,\n    panelTarget: 0,\n    reduceMotion: false,\n    query: \"\",\n    returnFocus: null as HTMLElement | null,\n  });\n\n  // 0 = the trigger, 1 = the open field. Everything about the shape follows it.\n  const progress = useMotionValue(0);\n  const panel = useMotionValue(0); // height of the results under the field\n  const veil = useMotionValue(1); // fades the whole thing when it can't morph\n  const geometry = useMotionValue<Geometry>(NO_GEOMETRY);\n  const highlightY = useMotionValue(0);\n  const highlightHeight = useMotionValue(0);\n\n  const at = (key: \"x\" | \"y\" | \"width\" | \"height\" | \"radius\") =>\n    lerp(geometry.get().from[key], geometry.get().to[key], progress.get());\n  const x = useTransform(() => at(\"x\"));\n  const y = useTransform(() => at(\"y\"));\n  const shapeWidth = useTransform(() => at(\"width\"));\n  const fieldHeight = useTransform(() => at(\"height\"));\n  const shapeHeight = useTransform(() => fieldHeight.get() + panel.get());\n  const radius = useTransform(() => {\n    const extra = (geometry.get().to.radius - size.panelRadius) * clamp(panel.get() / 48, 0, 1);\n    return Math.max(at(\"radius\") - extra, 0);\n  });\n  // The trigger's look hands over to the surface's over the first third.\n  const look = useTransform(() => clamp(progress.get() / 0.35, 0, 1) * veil.get());\n  const skin = useTransform(() =>\n    geometry.get().detached ? 0 : (1 - clamp(progress.get() / 0.35, 0, 1)) * veil.get(),\n  );\n  const content = useTransform(() => clamp((progress.get() - 0.3) / 0.45, 0, 1) * veil.get());\n  const glyphX = useTransform(() => {\n    const g = geometry.get();\n    return lerp(g.glyphFrom.x, g.glyphTo.x, progress.get()) - size.glyph / 2;\n  });\n  const glyphY = useTransform(() => {\n    const g = geometry.get();\n    return lerp(g.glyphFrom.y, g.glyphTo.y, progress.get()) - size.glyph / 2;\n  });\n  const glyphScale = useTransform(() => lerp(geometry.get().glyphFrom.scale, 1, progress.get()));\n  const glyphOpacity = useTransform(() => (geometry.get().glyphFades ? content.get() : veil.get()));\n  const backdrop = useTransform(() => clamp(progress.get(), 0, 1) * veil.get());\n  const divider = useTransform(() => clamp(panel.get() / 12, 0, 1));\n\n  // ── Results ─────────────────────────────────────────────\n\n  const trimmed = query.trim();\n  const groups = React.useMemo(() => {\n    const ranked = trimmed\n      ? items\n          .map((item, index) => ({ item, index, score: filter(item, trimmed) }))\n          .filter((entry) => entry.score > 0)\n          .sort((a, b) => b.score - a.score || a.index - b.index)\n          .map((entry) => entry.item)\n      : (suggestions ?? items);\n    return groupItems(ranked);\n  }, [trimmed, items, suggestions, filter]);\n  const ordered = React.useMemo(() => groups.flatMap((group) => group.items), [groups]);\n  const activeIndex = Math.min(active, Math.max(ordered.length - 1, 0));\n  const showPanel = ordered.length > 0 || trimmed !== \"\";\n\n  // ── Open and close ──────────────────────────────────────\n\n  const open = () => {\n    const state = live.current;\n    if (state.phase === \"open\") return;\n    if (state.phase === \"closing\") {\n      // Caught on its way back: grow out again from wherever it is.\n      state.phase = \"open\";\n      state.generation++;\n      animate(progress, 1, state.reduceMotion ? { duration: 0 } : MORPH.resize);\n      animate(panel, state.panelTarget, state.reduceMotion ? { duration: 0 } : MORPH.resize);\n      animate(veil, 1, MORPH.fade);\n      inputRef.current?.focus({ preventScroll: true });\n      return;\n    }\n\n    const reduce = window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n    const g = measure(triggerRef.current, variant, width, align);\n    state.phase = \"open\";\n    state.reduceMotion = reduce;\n    state.returnFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null;\n    geometry.set(g);\n    progress.jump(reduce ? 1 : 0);\n    panel.jump(0);\n    veil.jump(reduce || g.detached ? 0 : 1);\n    setMaxList(listLimit(g.to, variant, footer));\n    setReduceMotion(reduce);\n    setMounted(true);\n\n    if (!reduce) animate(progress, 1, variant === \"palette\" ? MORPH.travel : MORPH.open);\n    if (reduce || g.detached) animate(veil, 1, MORPH.fade);\n  };\n\n  const close = () => {\n    const state = live.current;\n    if (state.phase !== \"open\") return;\n    state.phase = \"closing\";\n    const generation = ++state.generation;\n    const fadeOnly = state.reduceMotion || geometry.get().detached;\n    if (!fadeOnly) animate(panel, 0, MORPH.fold);\n    const done = fadeOnly ? animate(veil, 0, MORPH.fade) : animate(progress, 0, MORPH.close);\n    done.then(() => {\n      if (live.current.generation === generation) finish();\n    });\n  };\n\n  // Landed back on the trigger: show it, put the layer away and return focus,\n  // all in one task so no frame shows both or neither.\n  const finish = () => {\n    const state = live.current;\n    if (state.phase === \"closed\") return;\n    state.phase = \"closed\";\n    const trigger = triggerRef.current;\n    if (trigger) trigger.style.opacity = \"\";\n    const layer = layerRef.current;\n    const hadFocus = !!layer?.contains(document.activeElement);\n    if (layer instanceof HTMLDialogElement) layer.close();\n    else if (layer?.matches(\":popover-open\")) layer.hidePopover();\n    if (hadFocus || document.activeElement === document.body) {\n      const target = state.returnFocus?.isConnected ? state.returnFocus : trigger;\n      target?.focus({ preventScroll: true });\n    }\n    if (state.query) onQueryChange?.(\"\");\n    state.query = \"\";\n    setMounted(false);\n    setQuery(\"\");\n    setActive(0);\n    setRevealing(true);\n  };\n\n  const toggle = () => (live.current.phase === \"open\" ? close() : open());\n\n  const select = (item: MorphSearchItem) => {\n    if (live.current.phase !== \"open\") return;\n    onSelect?.(item);\n    close();\n  };\n\n  const changeQuery = (value: string) => {\n    live.current.query = value;\n    setQuery(value);\n    setActive(0);\n    onQueryChange?.(value);\n  };\n\n  // Global listeners call the latest versions of these.\n  const actions = React.useRef({ open, close, toggle });\n  React.useLayoutEffect(() => {\n    actions.current = { open, close, toggle };\n  });\n\n  // Mounted: lift the layer into the top layer, hide the trigger behind the\n  // shape, focus the input and unfold the results.\n  React.useLayoutEffect(() => {\n    const layer = layerRef.current;\n    if (!mounted || !layer) return;\n    const state = live.current;\n    if (layer instanceof HTMLDialogElement) {\n      if (!layer.open) layer.showModal();\n    } else if (!layer.matches(\":popover-open\")) {\n      layer.popover = \"manual\";\n      layer.showPopover();\n    }\n    const trigger = triggerRef.current;\n    if (trigger && !state.reduceMotion && !geometry.get().detached) trigger.style.opacity = \"0\";\n    inputRef.current?.focus({ preventScroll: true });\n\n    const height = panelRef.current?.offsetHeight ?? 0;\n    state.panelTarget = height;\n    animate(panel, height, state.reduceMotion ? { duration: 0 } : MORPH.unfold);\n    const timer = window.setTimeout(() => setRevealing(false), 500);\n    return () => window.clearTimeout(timer);\n  }, [mounted, panel, geometry]);\n\n  // The panel follows its content: more results, a taller panel.\n  React.useLayoutEffect(() => {\n    const element = panelRef.current;\n    if (!mounted || !element) return;\n    const observer = new ResizeObserver(() => {\n      const state = live.current;\n      const height = element.offsetHeight;\n      if (state.phase !== \"open\" || Math.abs(height - state.panelTarget) < 0.5) return;\n      state.panelTarget = height;\n      animate(panel, height, state.reduceMotion ? { duration: 0 } : MORPH.resize);\n    });\n    observer.observe(element);\n    return () => observer.disconnect();\n  }, [mounted, panel]);\n\n  // The highlight is drawn straight from its motion values rather than bound\n  // to a motion element, which would reset them when remounted (as StrictMode\n  // does in development).\n  const hasResults = ordered.length > 0;\n  React.useLayoutEffect(() => {\n    const element = highlightRef.current;\n    if (!mounted || !hasResults || !element) return;\n    const draw = () => {\n      element.style.transform = `translateY(${highlightY.get()}px)`;\n      element.style.height = `${highlightHeight.get()}px`;\n    };\n    draw();\n    const offY = highlightY.on(\"change\", draw);\n    const offHeight = highlightHeight.on(\"change\", draw);\n    return () => {\n      offY();\n      offHeight();\n    };\n  }, [mounted, hasResults, highlightY, highlightHeight]);\n\n  // Slide the highlight to the active row and keep that row in view.\n  React.useLayoutEffect(() => {\n    if (!mounted || !ordered.length) return;\n    const row = document.getElementById(`${id}-option-${activeIndex}`);\n    const list = listRef.current;\n    if (!row || !list) return;\n    const top = row.offsetTop;\n    const height = row.offsetHeight;\n    if (highlightHeight.get() === 0 || live.current.reduceMotion) {\n      highlightY.jump(top);\n      highlightHeight.jump(height);\n    } else {\n      animate(highlightY, top, MORPH.highlight);\n      animate(highlightHeight, height, MORPH.highlight);\n    }\n    if (top < list.scrollTop) list.scrollTop = top - 6;\n    else if (top + height > list.scrollTop + list.clientHeight) {\n      list.scrollTop = top + height - list.clientHeight + 6;\n    }\n  }, [mounted, ordered, activeIndex, id, highlightY, highlightHeight]);\n\n  React.useEffect(() => {\n    if (!mounted) highlightHeight.jump(0);\n  }, [mounted, highlightHeight]);\n\n  // Keep the shape on its trigger if the page scrolls or resizes.\n  React.useEffect(() => {\n    if (!mounted) return;\n    let frame = 0;\n    const update = (event: Event) => {\n      if (event.target instanceof Node && layerRef.current?.contains(event.target)) return;\n      cancelAnimationFrame(frame);\n      frame = requestAnimationFrame(() => {\n        const next = measure(triggerRef.current, variant, width, align);\n        // A trigger that scrolled away stays where it was last seen.\n        if (!next.detached) geometry.set(next);\n      });\n    };\n    window.addEventListener(\"resize\", update);\n    window.addEventListener(\"scroll\", update, true);\n    return () => {\n      cancelAnimationFrame(frame);\n      window.removeEventListener(\"resize\", update);\n      window.removeEventListener(\"scroll\", update, true);\n    };\n  }, [mounted, variant, width, align, geometry]);\n\n  // Inline: a click anywhere else closes it. The palette has its backdrop.\n  React.useEffect(() => {\n    if (!mounted || variant !== \"inline\") return;\n    const onPointerDown = (event: PointerEvent) => {\n      const target = event.target as Node;\n      if (surfaceRef.current?.contains(target) || triggerRef.current?.contains(target)) return;\n      actions.current.close();\n    };\n    document.addEventListener(\"pointerdown\", onPointerDown, true);\n    return () => document.removeEventListener(\"pointerdown\", onPointerDown, true);\n  }, [mounted, variant]);\n\n  // Palette: the wheel scrolls results, never the page behind.\n  React.useEffect(() => {\n    const layer = layerRef.current;\n    if (!mounted || variant !== \"palette\" || !layer) return;\n    const onWheel = (event: WheelEvent) => {\n      if (!listRef.current?.contains(event.target as Node)) event.preventDefault();\n    };\n    layer.addEventListener(\"wheel\", onWheel, { passive: false });\n    return () => layer.removeEventListener(\"wheel\", onWheel);\n  }, [mounted, variant]);\n\n  React.useEffect(() => {\n    if (!hotkey) return;\n    const onKeyDown = (event: KeyboardEvent) => {\n      if (event.defaultPrevented || event.repeat || event.isComposing) return;\n      const key = event.key.toLowerCase();\n      if (hotkey === \"/\") {\n        const target = event.target instanceof Element ? event.target : null;\n        const typing = target?.closest(\"input, textarea, select, [contenteditable]\");\n        if (key !== \"/\" || typing || event.metaKey || event.ctrlKey || event.altKey) return;\n        event.preventDefault();\n        actions.current.open();\n        return;\n      }\n      if (key !== hotkey.toLowerCase() || !(event.metaKey || event.ctrlKey)) return;\n      if (event.altKey || event.shiftKey) return;\n      event.preventDefault();\n      actions.current.toggle();\n    };\n    window.addEventListener(\"keydown\", onKeyDown);\n    return () => window.removeEventListener(\"keydown\", onKeyDown);\n  }, [hotkey]);\n\n  const onKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {\n    if (event.key === \"Escape\") {\n      // First Esc clears what was typed; the next one closes.\n      event.preventDefault();\n      if (query) {\n        changeQuery(\"\");\n        inputRef.current?.focus();\n      } else close();\n      return;\n    }\n    if (event.target !== inputRef.current || event.nativeEvent.isComposing) return;\n    const down = event.key === \"ArrowDown\" || (event.ctrlKey && event.key === \"n\");\n    const up = event.key === \"ArrowUp\" || (event.ctrlKey && event.key === \"p\");\n    if (down || up) {\n      event.preventDefault();\n      if (ordered.length) setActive((activeIndex + (down ? 1 : -1) + ordered.length) % ordered.length);\n    } else if (event.key === \"Enter\") {\n      event.preventDefault();\n      const item = ordered[activeIndex];\n      if (item) select(item);\n    }\n  };\n\n  // ── Render ──────────────────────────────────────────────\n\n  const iconOnly = children === undefined;\n  const triggerClassName = cn(\n    \"relative shrink-0 cursor-pointer bg-background text-foreground/80 ring-1 ring-foreground/10 outline-none select-none [-webkit-tap-highlight-color:transparent]\",\n    \"transition-[background-color,scale] duration-150 ease-out hover:bg-foreground/[0.03] active:scale-[0.97] motion-reduce:active:scale-100\",\n    \"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-foreground\",\n    iconOnly\n      ? \"grid size-9 place-items-center rounded-full\"\n      : \"inline-flex h-9 items-center gap-2 rounded-full px-3 text-sm text-muted-foreground\",\n    className,\n  );\n  const triggerContent = iconOnly ? <MorphSearchIcon /> : children;\n\n  const shape = (\n    <>\n      {variant === \"palette\" ? (\n        <motion.div\n          aria-hidden\n          className=\"absolute inset-0 bg-black/25 backdrop-blur-[2px] dark:bg-black/55\"\n          style={{ opacity: backdrop }}\n          onPointerDown={() => close()}\n        />\n      ) : null}\n\n      {/* Ring and shadow sit behind the shape so they can fade in on their own. */}\n      <motion.div\n        aria-hidden\n        className=\"pointer-events-none absolute top-0 left-0 shadow-[0_18px_50px_-12px_rgb(0_0_0/0.22),0_4px_14px_-6px_rgb(0_0_0/0.1)] ring-1 ring-foreground/10 dark:shadow-[0_18px_50px_-12px_rgb(0_0_0/0.7)]\"\n        style={{ x, y, width: shapeWidth, height: shapeHeight, borderRadius: radius, opacity: look }}\n      />\n\n      <motion.div\n        ref={surfaceRef}\n        className=\"pointer-events-auto absolute top-0 left-0 overflow-hidden\"\n        style={{ x, y, width: shapeWidth, height: shapeHeight, borderRadius: radius }}\n        // Caught on its way back to the trigger: open it again.\n        onPointerDown={() => {\n          if (live.current.phase === \"closing\") open();\n        }}\n        // Clicks anywhere but the input and buttons keep the caret in the input.\n        onMouseDown={(event) => {\n          const target = event.target as Element;\n          if (target !== inputRef.current && !target.closest(\"button\")) event.preventDefault();\n        }}\n        onKeyDown={onKeyDown}\n        onBlur={(event) => {\n          // Inline: tabbing out closes it.\n          const next = event.relatedTarget;\n          if (variant === \"inline\" && next && !surfaceRef.current?.contains(next)) close();\n        }}\n      >\n        <motion.div\n          aria-hidden\n          className={cn(\"absolute inset-0 bg-background\", surfaceClassName)}\n          style={{ opacity: look }}\n        />\n        {/* A copy of the trigger that stretches with the shape as it fades. */}\n        <motion.div\n          aria-hidden\n          className={cn(triggerClassName, \"pointer-events-none absolute top-0 left-0 [&_[data-morph-search-icon]]:invisible\")}\n          style={{ width: \"100%\", height: fieldHeight, opacity: skin }}\n        >\n          {triggerContent}\n        </motion.div>\n\n        <motion.div className=\"absolute inset-x-0 top-0\" style={{ height: fieldHeight }}>\n          <motion.span\n            aria-hidden\n            className=\"absolute top-0 left-0 text-muted-foreground\"\n            style={{ x: glyphX, y: glyphY, scale: glyphScale, opacity: glyphOpacity, width: size.glyph, height: size.glyph }}\n          >\n            <SearchGlyph className=\"size-full\" />\n          </motion.span>\n          <motion.div className=\"flex h-full items-center\" style={{ opacity: content }}>\n            <input\n              ref={inputRef}\n              role=\"combobox\"\n              aria-label={placeholder}\n              aria-expanded={showPanel}\n              aria-controls={listId}\n              aria-autocomplete=\"list\"\n              aria-activedescendant={ordered.length ? optionId(activeIndex) : undefined}\n              value={query}\n              onChange={(event) => changeQuery(event.target.value)}\n              placeholder={placeholder}\n              autoComplete=\"off\"\n              autoCorrect=\"off\"\n              autoCapitalize=\"off\"\n              spellCheck={false}\n              enterKeyHint=\"go\"\n              className={cn(\n                \"h-full min-w-0 flex-1 bg-transparent text-base text-foreground outline-none placeholder:text-muted-foreground\",\n                variant === \"inline\" ? \"pl-10 sm:text-sm\" : \"pl-12 sm:text-[15px]\",\n              )}\n            />\n            {query ? (\n              <button\n                type=\"button\"\n                aria-label=\"Clear search\"\n                className=\"mr-2.5 grid size-7 shrink-0 place-items-center rounded-full text-muted-foreground transition-colors hover:bg-foreground/[0.06] hover:text-foreground focus-visible:outline-2 focus-visible:outline-foreground\"\n                onClick={() => {\n                  changeQuery(\"\");\n                  inputRef.current?.focus();\n                }}\n              >\n                <svg aria-hidden viewBox=\"0 0 24 24\" className=\"size-3.5\" fill=\"none\" stroke=\"currentColor\" strokeWidth={2} strokeLinecap=\"round\">\n                  <path d=\"M6 6l12 12M18 6L6 18\" />\n                </svg>\n              </button>\n            ) : (\n              <Kbd className={cn(\"shrink-0 pointer-coarse:hidden\", variant === \"inline\" ? \"mr-3\" : \"mr-4\")}>esc</Kbd>\n            )}\n          </motion.div>\n        </motion.div>\n\n        <motion.div className=\"absolute inset-x-0\" style={{ top: fieldHeight }}>\n          <div ref={panelRef}>\n            {showPanel ? (\n              <>\n                <motion.div className=\"h-px bg-foreground/[0.07]\" style={{ opacity: divider }} />\n                <div\n                  ref={listRef}\n                  id={listId}\n                  role=\"listbox\"\n                  aria-label=\"Results\"\n                  className=\"relative overflow-y-auto overscroll-contain p-1.5 [scrollbar-width:thin]\"\n                  style={{ maxHeight: maxList }}\n                >\n                  {hasResults ? (\n                    <motion.div\n                      aria-hidden\n                      initial={revealing && !reduceMotion ? { opacity: 0 } : false}\n                      animate={{ opacity: 1 }}\n                      transition={{ duration: 0.2, delay: 0.1 }}\n                      className=\"pointer-events-none absolute inset-0\"\n                    >\n                      <div\n                        ref={highlightRef}\n                        className=\"absolute inset-x-1.5 top-0 h-0 rounded-[10px] bg-foreground/[0.06]\"\n                      />\n                    </motion.div>\n                  ) : (\n                    <div className=\"px-3 py-8 text-center text-sm text-muted-foreground\">\n                      {emptyMessage(trimmed)}\n                    </div>\n                  )}\n                  {groups.map((group) => (\n                    <div key={group.name ?? \"_\"} role=\"group\" aria-label={group.name}>\n                      {group.name ? (\n                        <div aria-hidden className=\"px-2.5 pt-2 pb-1 text-[11px] font-medium text-muted-foreground\">\n                          {group.name}\n                        </div>\n                      ) : null}\n                      {group.items.map((item, offset) => {\n                        const index = group.start + offset;\n                        const isActive = index === activeIndex;\n                        return (\n                          <motion.div\n                            key={item.id}\n                            id={optionId(index)}\n                            role=\"option\"\n                            aria-selected={isActive}\n                            initial={\n                              reduceMotion\n                                ? false\n                                : revealing\n                                  ? { opacity: 0, y: 6 }\n                                  : { opacity: 0 }\n                            }\n                            animate={{ opacity: 1, y: 0 }}\n                            transition={\n                              revealing\n                                ? { duration: 0.3, ease: [0.22, 1, 0.36, 1], delay: 0.1 + Math.min(index, 10) * MORPH.stagger }\n                                : { duration: 0.12 }\n                            }\n                            className={cn(\n                              \"relative flex cursor-default items-center gap-3 rounded-[10px] px-2.5 select-none\",\n                              variant === \"inline\" ? \"h-10\" : \"h-11\",\n                            )}\n                            onPointerMove={() => {\n                              if (!isActive) setActive(index);\n                            }}\n                            onClick={() => select(item)}\n                          >\n                            {item.icon ? (\n                              <span className=\"grid size-7 shrink-0 place-items-center rounded-lg bg-foreground/[0.05] text-foreground/70 ring-1 ring-foreground/[0.06] ring-inset [&_svg]:size-4\">\n                                {item.icon}\n                              </span>\n                            ) : null}\n                            <span className=\"flex min-w-0 flex-1 items-baseline gap-2\">\n                              <span className=\"truncate text-sm text-foreground\">\n                                <Match text={item.label} query={trimmed} />\n                              </span>\n                              {item.description ? (\n                                <span className=\"truncate text-xs text-muted-foreground\">{item.description}</span>\n                              ) : null}\n                            </span>\n                            {item.shortcut ? (\n                              <span className=\"flex shrink-0 gap-1\">\n                                {item.shortcut.map((key) => (\n                                  <Kbd key={key}>{key}</Kbd>\n                                ))}\n                              </span>\n                            ) : (\n                              <Kbd\n                                aria-hidden\n                                className={cn(\n                                  \"shrink-0 transition-opacity duration-150 pointer-coarse:hidden\",\n                                  isActive ? \"opacity-100\" : \"opacity-0\",\n                                )}\n                              >\n                                ↵\n                              </Kbd>\n                            )}\n                          </motion.div>\n                        );\n                      })}\n                    </div>\n                  ))}\n                </div>\n                {footer ? (\n                  <div\n                    className=\"flex items-center justify-between border-t border-foreground/[0.07] px-3.5 text-[11px] text-muted-foreground pointer-coarse:hidden\"\n                    style={{ height: FOOTER_HEIGHT }}\n                  >\n                    <span className=\"flex items-center gap-3\">\n                      <span className=\"flex items-center gap-1.5\">\n                        <Kbd>↑</Kbd>\n                        <Kbd>↓</Kbd>\n                        to navigate\n                      </span>\n                      <span className=\"flex items-center gap-1.5\">\n                        <Kbd>↵</Kbd>\n                        to select\n                      </span>\n                    </span>\n                    <span className=\"flex items-center gap-1.5\">\n                      <Kbd>esc</Kbd>\n                      to close\n                    </span>\n                  </div>\n                ) : null}\n              </>\n            ) : null}\n          </div>\n        </motion.div>\n\n        <p className=\"sr-only\" aria-live=\"polite\">\n          {trimmed ? `${ordered.length} ${ordered.length === 1 ? \"result\" : \"results\"}` : \"\"}\n        </p>\n      </motion.div>\n    </>\n  );\n\n  return (\n    <>\n      <button\n        ref={triggerRef}\n        type=\"button\"\n        aria-label={iconOnly ? \"Search\" : undefined}\n        aria-haspopup={variant === \"palette\" ? \"dialog\" : \"listbox\"}\n        aria-expanded={mounted}\n        className={triggerClassName}\n        {...props}\n        onClick={(event) => {\n          onClick?.(event);\n          if (!event.defaultPrevented) toggle();\n        }}\n      >\n        {triggerContent}\n      </button>\n\n      {mounted ? (\n        variant === \"palette\" ? (\n          <dialog\n            ref={setLayer}\n            aria-label={placeholder}\n            onCancel={(event) => {\n              event.preventDefault();\n              close();\n            }}\n            onClose={finish}\n            className=\"fixed inset-0 m-0 size-full max-h-none max-w-none overflow-hidden border-0 bg-transparent p-0 text-foreground outline-none backdrop:bg-transparent\"\n          >\n            {shape}\n          </dialog>\n        ) : (\n          <div\n            ref={setLayer}\n            className=\"pointer-events-none fixed inset-0 m-0 size-full max-h-none max-w-none overflow-visible border-0 bg-transparent p-0 text-foreground\"\n          >\n            {shape}\n          </div>\n        )\n      ) : null}\n    </>\n  );\n}\n\n/** The magnifier. Put it in a custom trigger and it slides into the open field. */\nexport function MorphSearchIcon({ className, ...props }: React.SVGProps<SVGSVGElement>) {\n  return <SearchGlyph data-morph-search-icon=\"\" className={cn(\"size-4 shrink-0\", className)} {...props} />;\n}\n\nfunction SearchGlyph(props: React.SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      aria-hidden\n      viewBox=\"0 0 24 24\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth={2}\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      {...props}\n    >\n      <circle cx=\"11\" cy=\"11\" r=\"7\" />\n      <path d=\"m20 20-3.5-3.5\" />\n    </svg>\n  );\n}\n\nfunction Kbd({ className, ...props }: React.HTMLAttributes<HTMLElement>) {\n  return (\n    <kbd\n      className={cn(\n        \"inline-grid h-5 min-w-5 place-items-center rounded-[5px] bg-foreground/[0.06] px-1 font-mono text-[10.5px] leading-none text-muted-foreground ring-1 ring-foreground/[0.06] ring-inset\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\n/** The label with the matched text in full strength and the rest dimmed. */\nfunction Match({ text, query }: { text: string; query: string }) {\n  if (!query) return text;\n  const lower = text.toLowerCase();\n  const needle = [query, ...query.split(/\\s+/)].find((part) => part && lower.includes(part.toLowerCase()));\n  if (!needle) return text;\n  const start = lower.indexOf(needle.toLowerCase());\n  const end = start + needle.length;\n  return (\n    <>\n      <span className=\"text-foreground/55\">{text.slice(0, start)}</span>\n      {text.slice(start, end)}\n      <span className=\"text-foreground/55\">{text.slice(end)}</span>\n    </>\n  );\n}\n\nfunction defaultEmptyMessage(query: string) {\n  return (\n    <>\n      No results for <span className=\"text-foreground\">“{query}”</span>\n    </>\n  );\n}\n\n/**\n * Every word must appear somewhere in the item. Labels that start with the\n * query rank first, then labels with a word that does, then everything else.\n */\nfunction defaultFilter(item: MorphSearchItem, query: string) {\n  const needle = query.toLowerCase();\n  const label = item.label.toLowerCase();\n  const haystack = [item.label, item.description, item.group, ...(item.keywords ?? [])]\n    .filter(Boolean)\n    .join(\" \")\n    .toLowerCase();\n  const words = needle.split(/\\s+/).filter(Boolean);\n  if (!words.every((word) => haystack.includes(word))) return 0;\n  if (label.startsWith(needle)) return 4;\n  if (label.split(/\\s+/).some((word) => word.startsWith(words[0]))) return 3;\n  if (label.includes(needle)) return 2;\n  return 1;\n}\n\n/** Groups in order of each group's first (best) item, each with the index of its first row. */\nfunction groupItems(items: MorphSearchItem[]) {\n  const groups: { name?: string; items: MorphSearchItem[]; start: number }[] = [];\n  for (const item of items) {\n    const group = groups.find((entry) => entry.name === item.group);\n    if (group) group.items.push(item);\n    else groups.push({ name: item.group, items: [item], start: 0 });\n  }\n  let start = 0;\n  for (const group of groups) {\n    group.start = start;\n    start += group.items.length;\n  }\n  return groups;\n}\n\n// ── Geometry ──────────────────────────────────────────────\n\nfunction measure(\n  trigger: HTMLElement | null,\n  variant: \"inline\" | \"palette\",\n  width: number | undefined,\n  align: \"start\" | \"end\" | undefined,\n): Geometry {\n  const size = SIZES[variant];\n  const margin = MORPH.margin;\n  const viewportWidth = document.documentElement.clientWidth;\n  const viewportHeight = document.documentElement.clientHeight;\n  const fieldWidth = Math.min(width ?? size.width, viewportWidth - margin * 2);\n  const rect = trigger?.getBoundingClientRect();\n  const visible =\n    !!rect &&\n    rect.width > 0 &&\n    rect.height > 0 &&\n    rect.bottom > 0 &&\n    rect.right > 0 &&\n    rect.top < viewportHeight &&\n    rect.left < viewportWidth;\n\n  let to: Rect;\n  if (variant === \"palette\" || !rect) {\n    to = {\n      x: (viewportWidth - fieldWidth) / 2,\n      y: clamp(viewportHeight * 0.14, margin, 120),\n      width: fieldWidth,\n      height: size.height,\n      radius: size.radius,\n    };\n  } else {\n    const side = align ?? (rect.left + rect.width / 2 > viewportWidth / 2 ? \"end\" : \"start\");\n    const left = side === \"end\" ? rect.right - fieldWidth : rect.left;\n    to = {\n      x: clamp(left, margin, viewportWidth - margin - fieldWidth),\n      y: Math.max(rect.top + rect.height / 2 - size.height / 2, margin),\n      width: fieldWidth,\n      height: size.height,\n      radius: size.radius,\n    };\n  }\n\n  const from: Rect =\n    visible && trigger\n      ? {\n          x: rect.left,\n          y: rect.top,\n          width: rect.width,\n          height: rect.height,\n          radius: Math.min(\n            parseFloat(getComputedStyle(trigger).borderTopLeftRadius) || 0,\n            rect.width / 2,\n            rect.height / 2,\n          ),\n        }\n      : { ...to, x: to.x + to.width * 0.02, width: to.width * 0.96 };\n\n  const glyphTo = { x: size.pad + size.glyph / 2, y: size.height / 2 };\n  const icon = visible ? trigger?.querySelector(\"[data-morph-search-icon]\") : null;\n  const iconRect = icon?.getBoundingClientRect();\n  const glyphFrom = iconRect\n    ? {\n        x: iconRect.left + iconRect.width / 2 - from.x,\n        y: iconRect.top + iconRect.height / 2 - from.y,\n        scale: iconRect.width / size.glyph,\n      }\n    : { ...glyphTo, scale: 1 };\n\n  return { from, to, glyphFrom, glyphTo, glyphFades: !iconRect, detached: !visible };\n}\n\n/** How tall the list may grow before it scrolls. */\nfunction listLimit(field: Rect, variant: \"inline\" | \"palette\", footer: boolean) {\n  const viewportHeight = document.documentElement.clientHeight;\n  const room = viewportHeight - (field.y + field.height) - MORPH.margin - (footer ? FOOTER_HEIGHT : 0);\n  return Math.max(Math.min(SIZES[variant].maxList, room), 120);\n}\n\nfunction lerp(from: number, to: number, t: number) {\n  return from + (to - from) * t;\n}\n\nfunction clamp(value: number, min: number, max: number) {\n  return Math.min(Math.max(value, min), max);\n}\n"
    }
  ]
}