{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "expanding-search",
  "type": "registry:ui",
  "title": "Expanding search",
  "description": "An icon that morphs into a search field with results beneath it.",
  "categories": [
    "inputs"
  ],
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "https://mcp.xzero.co.uk/r/xzero-motion-tokens.json"
  ],
  "files": [
    {
      "path": "registry/components/expanding-search/expanding-search.tsx",
      "type": "registry:component",
      "target": "@components/xzero/expanding-search/expanding-search.tsx",
      "content": "\"use client\";\n\nimport { useCallback, useId, useLayoutEffect, useMemo, useRef, useState } from \"react\";\nimport type { KeyboardEvent, ReactNode, RefObject } from \"react\";\nimport { flushSync } from \"react-dom\";\nimport { AnimatePresence, animate, motion, useMotionValue, useMotionValueEvent, useReducedMotion } from \"motion/react\";\nimport type { MotionValue } from \"motion/react\";\nimport { Search, X } from \"lucide-react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport styles from \"./expanding-search.module.css\";\n\nexport interface ExpandingSearchItem {\n  id: string;\n  title: string;\n  /** One short line under the title, such as the type, owner, or last edit. */\n  meta?: string;\n  /** Results gather under this heading, in the order groups first appear in `items`. */\n  group?: string;\n  icon?: ReactNode;\n  /** Extra words that should also find this item. */\n  keywords?: string[];\n}\n\n/**\n * A search that waits as an icon button until it is needed. Use it in headers and toolbars where a full field would crowd the layout.\n * The button morphs into the field, results unfold beneath it, and Escape, choosing a result, or leaving the field empty folds it back into the icon.\n * Place it in the space the field may grow into; it fills that space's width and keeps the button on the `anchor` edge.\n */\nexport interface ExpandingSearchProps {\n  /** Names the button, the field, and the results, such as \"Search projects and docs\". */\n  label: string;\n  items: ExpandingSearchItem[];\n  /** Shown before anything is typed, such as recent searches. */\n  suggestions?: ExpandingSearchItem[];\n  suggestionsLabel?: string;\n  placeholder?: string;\n  onSelect?: (item: ExpandingSearchItem) => void;\n  onExpandedChange?: (expanded: boolean) => void;\n  /** The widest the field grows. It never grows past its container. */\n  expandedWidth?: number;\n  maxResults?: number;\n  /** The edge the button sits on; the field grows away from it. */\n  anchor?: \"start\" | \"end\";\n  /** A short tip under the empty state. */\n  emptyHint?: string;\n  className?: string;\n}\n\ntype Group = { name: string; items: ExpandingSearchItem[] };\ntype Mode = \"suggestions\" | \"results\" | \"empty\";\n\nconst enter = [...motionTokens.ease.enter] as [number, number, number, number];\nconst standard = [...motionTokens.ease.standard] as [number, number, number, number];\nconst FALLBACK_SIZE = 44;\n\n/** Title starts beat word starts, which beat matches inside a word, which beat keyword and meta matches. Ties keep the original order. */\nfunction rank(items: ExpandingSearchItem[], query: string, limit: number) {\n  const q = query.toLocaleLowerCase();\n  const scored: { item: ExpandingSearchItem; score: number; order: number }[] = [];\n  items.forEach((item, order) => {\n    const title = item.title.toLocaleLowerCase();\n    const at = title.indexOf(q);\n    const extra = [item.meta ?? \"\", ...(item.keywords ?? [])].some(word => word.toLocaleLowerCase().includes(q));\n    const score = at === 0 ? 0 : at > 0 && /[\\s\\-/]/.test(title[at - 1]!) ? 1 : at > 0 ? 2 : extra ? 3 : -1;\n    if (score >= 0) scored.push({ item, score, order });\n  });\n  return scored.sort((a, b) => a.score - b.score || a.order - b.order).slice(0, limit).map(entry => entry.item);\n}\n\nfunction groupBy(list: ExpandingSearchItem[], source: ExpandingSearchItem[]): Group[] {\n  const order = new Map<string, number>();\n  source.forEach((item, index) => { const name = item.group ?? \"\"; if (!order.has(name)) order.set(name, index); });\n  const groups = new Map<string, ExpandingSearchItem[]>();\n  list.forEach(item => { const name = item.group ?? \"\"; groups.set(name, [...(groups.get(name) ?? []), item]); });\n  return [...groups].sort((a, b) => (order.get(a[0]) ?? 0) - (order.get(b[0]) ?? 0)).map(([name, items]) => ({ name, items }));\n}\n\nfunction Match({ text, query }: { text: string; query: string }) {\n  const at = query ? text.toLocaleLowerCase().indexOf(query.toLocaleLowerCase()) : -1;\n  if (at < 0) return text;\n  return <>{text.slice(0, at)}<mark className={styles.mark}>{text.slice(at, at + query.length)}</mark>{text.slice(at + query.length)}</>;\n}\n\n/** The panel shares the field's width value, so on first open it unfolds out of the field; afterwards only its height follows the content. */\nfunction Panel({ width, reduced, children }: { width: MotionValue<number>; reduced: boolean; children: ReactNode }) {\n  const panelRef = useRef<HTMLDivElement>(null);\n  const bodyRef = useRef<HTMLDivElement>(null);\n  const height = useMotionValue(0);\n  useLayoutEffect(() => {\n    const panel = panelRef.current, body = bodyRef.current;\n    if (!panel || !body || typeof ResizeObserver === \"undefined\") return;\n    const observer = new ResizeObserver(() => {\n      const next = body.offsetHeight + panel.offsetHeight - panel.clientHeight;\n      if (reduced) height.jump(next);\n      else animate(height, next, motionTokens.spring.smooth);\n    });\n    observer.observe(body);\n    return () => observer.disconnect();\n  }, [height, reduced]);\n  // Reduced motion skips the unfold: the panel is its content's height from the first frame and only fades.\n  return <motion.div ref={panelRef} className={styles.panel} style={{ width, height: reduced ? \"auto\" : height }}\n    initial={{ opacity: 0 }}\n    animate={{ opacity: 1, transition: { duration: reduced ? .15 : motionTokens.duration.fast, ease: enter } }}\n    exit={{ opacity: 0, transition: { duration: reduced ? .1 : motionTokens.duration.instant, ease: standard } }}>\n    <div ref={bodyRef} className={styles.body}>{children}</div>\n  </motion.div>;\n}\n\n/** Offsets ignore transforms, so they describe where rows sit in the list, not where a gliding row happens to be mid-flight. */\nfunction offsetWithin(node: HTMLElement, container: HTMLElement) {\n  let top = 0;\n  let current: HTMLElement | null = node;\n  while (current && current !== container) { top += current.offsetTop; current = current.offsetParent as HTMLElement | null; }\n  return top;\n}\n\ninterface ListboxProps { id: string; label: string; groups: Group[]; query: string; activeIndex: number; optionId: (item: ExpandingSearchItem) => string; reduced: boolean; keyboard: RefObject<boolean>; onHover: (index: number) => void; onChoose: (item: ExpandingSearchItem) => void }\n\nfunction Listbox({ id, label, groups, query, activeIndex, optionId, reduced, keyboard, onHover, onChoose }: ListboxProps) {\n  const listRef = useRef<HTMLDivElement>(null);\n  const flat = groups.flatMap(group => group.items);\n  const layoutKey = flat.map(item => item.id).join(\"|\");\n  const activeItem = flat[activeIndex];\n  const activeId = activeItem ? optionId(activeItem) : \"\";\n  /** The highlight lives inside the active row, so it rides that row wherever it glides. Moving to another row, it starts where the old one was and springs home. */\n  const travel = useMotionValue(0);\n  const fade = useMotionValue(1);\n  const last = useRef<{ id: string; set: string; top: number } | null>(null);\n\n  useLayoutEffect(() => {\n    const list = listRef.current;\n    const row = activeId ? document.getElementById(activeId) : null;\n    if (!list || !row) { last.current = null; return; }\n    const top = offsetWithin(row, list);\n    const previous = last.current;\n    last.current = { id: activeId, set: layoutKey, top };\n    // Same row: its highlight is already in place, and any travel still in flight stays relative to it.\n    if (previous?.id === activeId) return;\n    if (previous && previous.set === layoutKey && !reduced) {\n      const velocity = travel.getVelocity();\n      travel.jump(previous.top + travel.get() - top);\n      animate(travel, 0, { ...motionTokens.spring.snappy, velocity });\n    } else {\n      // Nothing was highlighted, or new results arrived: it fades in on its row, without travel.\n      travel.jump(0);\n      if (reduced) fade.jump(1); else { fade.jump(0); animate(fade, 1, { duration: motionTokens.duration.instant, ease: enter }); }\n    }\n    if (keyboard.current) row.scrollIntoView({ block: \"nearest\" });\n  }, [activeId, layoutKey, reduced, travel, fade, keyboard]);\n\n  return <div ref={listRef} id={id} role=\"listbox\" aria-label={label} className={styles.listbox}>\n    <AnimatePresence mode=\"popLayout\" initial={false}>\n      {groups.map(group => {\n        const headingId = `${id}-${group.name || \"results\"}`.replace(/\\s+/g, \"-\");\n        return <motion.div key={group.name} role=\"group\" aria-labelledby={group.name ? headingId : undefined} className={styles.group}\n          layout={reduced ? false : \"position\"} layoutDependency={layoutKey}\n          initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0, transition: { duration: .08 } }}\n          transition={{ opacity: { duration: motionTokens.duration.fast, ease: enter }, layout: motionTokens.spring.smooth }}>\n          {group.name && <div id={headingId} className={styles.groupLabel} role=\"presentation\">{group.name}</div>}\n          <AnimatePresence mode=\"popLayout\" initial={false}>\n            {group.items.map(item => {\n              const position = flat.indexOf(item);\n              const active = position === activeIndex;\n              return <motion.div key={item.id} id={optionId(item)} role=\"option\" aria-selected={active} data-active={active || undefined} className={styles.option}\n                layout={reduced ? false : \"position\"} layoutDependency={layoutKey}\n                initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0, transition: { duration: .08 } }}\n                transition={{ opacity: { duration: motionTokens.duration.fast, ease: enter }, layout: motionTokens.spring.smooth }}\n                onPointerMove={() => { if (!active) { keyboard.current = false; onHover(position); } }}\n                onMouseDown={event => event.preventDefault()}\n                onClick={() => onChoose(item)}>\n                {active && <motion.span className={styles.highlight} style={{ y: travel, opacity: fade }} aria-hidden=\"true\" />}\n                {item.icon && <span className={styles.optionIcon} aria-hidden=\"true\">{item.icon}</span>}\n                <span className={styles.optionText}>\n                  <span className={styles.optionTitle}><Match text={item.title} query={query} /></span>\n                  {item.meta && <span className={styles.optionMeta}>{item.meta}</span>}\n                </span>\n              </motion.div>;\n            })}\n          </AnimatePresence>\n        </motion.div>;\n      })}\n    </AnimatePresence>\n  </div>;\n}\n\nexport function ExpandingSearch({ label, items, suggestions = [], suggestionsLabel = \"Recent\", placeholder, onSelect, onExpandedChange, expandedWidth = 360, maxResults = 6, anchor = \"end\", emptyHint = \"Try a shorter word or check the spelling.\", className }: ExpandingSearchProps) {\n  const id = useId();\n  const reduced = useReducedMotion() ?? false;\n  const rootRef = useRef<HTMLDivElement>(null);\n  const shellRef = useRef<HTMLDivElement>(null);\n  const inputRef = useRef<HTMLInputElement>(null);\n  const buttonRef = useRef<HTMLButtonElement>(null);\n  const [expanded, setExpanded] = useState(false);\n  const [focused, setFocused] = useState(false);\n  const [query, setQuery] = useState(\"\");\n  const [active, setActive] = useState(-1);\n  const open = useRef(false);\n  const size = useRef({ collapsed: FALLBACK_SIZE, expanded: expandedWidth });\n  const width = useMotionValue(FALLBACK_SIZE);\n  /** True while the last highlight move came from the keyboard, so only keyboard moves scroll the list. */\n  const keyboard = useRef(false);\n\n  // The corner radius follows the width: a circle while collapsed, the control radius once open. It rides the same spring, so the two never drift apart.\n  const syncShape = useCallback((value: number) => {\n    const { collapsed, expanded: full } = size.current;\n    shellRef.current?.style.setProperty(\"--es-open\", String(Math.min(1, Math.max(0, (value - collapsed) / Math.max(1, full - collapsed)))));\n  }, []);\n  useMotionValueEvent(width, \"change\", syncShape);\n\n  // The field grows to its own limit or its container, whichever is smaller. Container resizes are followed at once; they are not the person's action.\n  useLayoutEffect(() => {\n    const root = rootRef.current, shell = shellRef.current;\n    if (!root || !shell) return;\n    const measure = () => {\n      const collapsed = shell.offsetHeight || FALLBACK_SIZE;\n      const full = Math.max(collapsed, Math.min(expandedWidth, root.clientWidth));\n      size.current = { collapsed, expanded: full };\n      root.style.setProperty(\"--es-width\", `${full}px`);\n      const goal = open.current ? full : collapsed;\n      if (width.isAnimating()) animate(width, goal, motionTokens.spring.morph);\n      else width.jump(goal);\n      syncShape(width.get());\n    };\n    measure();\n    if (typeof ResizeObserver === \"undefined\") return;\n    const observer = new ResizeObserver(measure);\n    observer.observe(root);\n    return () => observer.disconnect();\n  }, [expandedWidth, width, syncShape]);\n\n  // Opening grows on the morph spring with a hint of overshoot; folding back settles without one, so the circle never dips below its own size.\n  const morphTo = (goal: number) => { if (reduced) width.jump(goal); else animate(width, goal, goal >= width.get() ? motionTokens.spring.morph : motionTokens.spring.smooth); };\n\n  // Focus moves inside the same click, so touch keyboards open; the field is inert until this flush makes it focusable.\n  function expand() {\n    if (open.current) return;\n    open.current = true;\n    flushSync(() => { setExpanded(true); setFocused(true); });\n    inputRef.current?.focus({ preventScroll: true });\n    morphTo(size.current.expanded);\n    onExpandedChange?.(true);\n  }\n\n  function collapse(returnFocus: boolean) {\n    if (!open.current) return;\n    open.current = false;\n    flushSync(() => { setExpanded(false); setFocused(false); setQuery(\"\"); setActive(-1); });\n    if (returnFocus) buttonRef.current?.focus({ preventScroll: true });\n    morphTo(size.current.collapsed);\n    onExpandedChange?.(false);\n  }\n\n  const trimmed = query.trim();\n  const results = useMemo(() => trimmed ? rank(items, trimmed, maxResults) : [], [items, trimmed, maxResults]);\n  const mode: Mode | null = !trimmed ? (suggestions.length ? \"suggestions\" : null) : results.length ? \"results\" : \"empty\";\n  const groups = useMemo<Group[]>(() => mode === \"suggestions\" ? [{ name: suggestionsLabel, items: suggestions }] : mode === \"results\" ? groupBy(results, items) : [], [mode, suggestions, suggestionsLabel, results, items]);\n  const flat = groups.flatMap(group => group.items);\n  const activeIndex = Math.min(active, flat.length - 1);\n  const activeItem = flat[activeIndex];\n  const optionId = (item: ExpandingSearchItem) => `${id}-${mode}-${item.id}`;\n  const listboxId = `${id}-${mode}-listbox`;\n  const panelOpen = expanded && focused && mode !== null;\n  const announcement = !panelOpen ? \"\" : mode === \"empty\" ? `No results for ${trimmed}` : mode === \"results\" ? `${flat.length} ${flat.length === 1 ? \"result\" : \"results\"}` : `${flat.length} ${suggestionsLabel.toLocaleLowerCase()} ${flat.length === 1 ? \"item\" : \"items\"}`;\n\n  function choose(item: ExpandingSearchItem) {\n    collapse(true);\n    onSelect?.(item);\n  }\n\n  function handleKeyDown(event: KeyboardEvent<HTMLInputElement>) {\n    if (event.key === \"Escape\") { event.preventDefault(); collapse(true); return; }\n    if (!panelOpen || !flat.length) return;\n    if (event.key === \"ArrowDown\" || event.key === \"ArrowUp\") {\n      event.preventDefault();\n      const step = event.key === \"ArrowDown\" ? 1 : -1;\n      keyboard.current = true;\n      setActive(activeIndex < 0 ? (step > 0 ? 0 : flat.length - 1) : (activeIndex + step + flat.length) % flat.length);\n    } else if (event.key === \"Enter\" && activeItem) {\n      event.preventDefault();\n      choose(activeItem);\n    }\n  }\n\n  const clearHidden = reduced ? { opacity: 0 } : { opacity: 0, scale: .6, filter: `blur(${motionTokens.blur.subtle}px)` };\n\n  return <div ref={rootRef} className={[styles.root, className].filter(Boolean).join(\" \")} data-anchor={anchor}>\n    <motion.div ref={shellRef} className={styles.shell} data-expanded={expanded || undefined} style={{ width }}>\n      <button ref={buttonRef} type=\"button\" className={styles.trigger} aria-label={label} hidden={expanded} onClick={expand} />\n      <Search className={styles.icon} width={18} height={18} strokeWidth={1.75} aria-hidden=\"true\" />\n      {/* The placeholder waits for the shape to start moving, then fades in; on close it leaves first. */}\n      <motion.input ref={inputRef} className={styles.input} type=\"text\" role=\"combobox\" inert={!expanded} value={query} placeholder={placeholder ?? label}\n        aria-label={label} aria-expanded={panelOpen} aria-controls={panelOpen && mode !== \"empty\" ? listboxId : undefined} aria-autocomplete=\"list\"\n        aria-activedescendant={panelOpen && activeItem ? optionId(activeItem) : undefined}\n        autoComplete=\"off\" autoCorrect=\"off\" spellCheck={false} enterKeyHint=\"search\"\n        initial={false} animate={{ opacity: expanded ? 1 : 0 }}\n        transition={expanded ? { duration: reduced ? .15 : .2, ease: enter, delay: reduced ? 0 : .08 } : { duration: reduced ? .1 : .08, ease: standard }}\n        onChange={event => { const next = event.target.value; setQuery(next); setActive(next.trim() ? 0 : -1); keyboard.current = false; }}\n        onFocus={() => setFocused(true)}\n        onBlur={() => {\n          // Switching windows keeps the search as it was; the field refocuses on return.\n          if (typeof document !== \"undefined\" && !document.hasFocus()) return;\n          setFocused(false);\n          if (open.current && !inputRef.current?.value.trim()) collapse(false);\n        }}\n        onKeyDown={handleKeyDown} />\n      <AnimatePresence initial={false}>\n        {expanded && query && <motion.button key=\"clear\" type=\"button\" tabIndex={-1} className={styles.clear} aria-label=\"Clear search\"\n          onMouseDown={event => event.preventDefault()}\n          onClick={() => { setQuery(\"\"); setActive(-1); inputRef.current?.focus({ preventScroll: true }); }}\n          initial={clearHidden} animate={{ opacity: 1, scale: 1, filter: \"blur(0px)\" }}\n          exit={{ ...clearHidden, transition: { duration: reduced ? .1 : motionTokens.duration.instant, ease: standard } }}\n          transition={reduced ? { duration: .15 } : { ...motionTokens.spring.snappy, opacity: { duration: motionTokens.duration.fast }, filter: { duration: motionTokens.duration.fast } }}>\n          <X width={16} height={16} strokeWidth={1.75} aria-hidden=\"true\" />\n        </motion.button>}\n      </AnimatePresence>\n    </motion.div>\n    <AnimatePresence>\n      {panelOpen && <Panel key=\"panel\" width={width} reduced={reduced}>\n        <AnimatePresence mode=\"popLayout\" initial={false}>\n            <motion.div key={mode} className={styles.view}\n              initial={reduced ? { opacity: 0 } : { opacity: 0, filter: `blur(${motionTokens.blur.subtle}px)` }}\n              animate={{ opacity: 1, filter: \"blur(0px)\" }}\n              exit={{ opacity: 0, transition: { duration: .1, ease: standard } }}\n              transition={{ duration: reduced ? .15 : motionTokens.duration.fast, ease: enter }}>\n              {mode === \"empty\"\n                ? <div className={styles.empty}><p className={styles.emptyTitle}>No results for “{trimmed}”</p><p className={styles.emptyHint}>{emptyHint}</p></div>\n                : <Listbox id={listboxId} label={mode === \"suggestions\" ? suggestionsLabel : `Results for ${trimmed}`} groups={groups} query={mode === \"results\" ? trimmed : \"\"} activeIndex={activeIndex} optionId={optionId} reduced={reduced} keyboard={keyboard} onHover={setActive} onChoose={choose} />}\n            </motion.div>\n        </AnimatePresence>\n      </Panel>}\n    </AnimatePresence>\n    <span className={styles.srOnly} role=\"status\" aria-live=\"polite\">{announcement}</span>\n  </div>;\n}\n\nexport default ExpandingSearch;\n"
    },
    {
      "path": "registry/components/expanding-search/expanding-search.module.css",
      "type": "registry:component",
      "target": "@components/xzero/expanding-search/expanding-search.module.css",
      "content": "/* The root is the lane the field may grow into. Only the shell and the panel take pointer events, so an idle lane never blocks what sits under it. */\n.root { position: relative; width: 100%; min-width: var(--control-height-md); height: var(--control-height-md); pointer-events: none; }\n/* One shell is both the button and the field. Its width rides a spring and its radius follows the width, from a circle to the control radius. */\n.shell { position: absolute; top: 0; right: 0; height: 100%; overflow: hidden; border: 1px solid var(--border); border-radius: calc(var(--control-height-md) / 2 - (var(--control-height-md) / 2 - var(--radius-control)) * var(--es-open, 0)); background: var(--surface); color: var(--text-secondary); pointer-events: auto; transition: border-color var(--duration-fast) var(--ease-standard), background-color var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard), box-shadow var(--duration-standard) var(--ease-standard), transform var(--duration-spring) var(--ease-spring); }\n.root[data-anchor=\"start\"] .shell { right: auto; left: 0; }\n.shell[data-expanded] { border-color: var(--border-strong); color: var(--foreground); }\n.shell:has(> .trigger:focus-visible), .shell:has(> .input:focus) { border-color: var(--foreground); box-shadow: 0 0 0 3px var(--focus-ring); color: var(--foreground); }\n@media (hover: hover) and (pointer: fine) {\n  .shell:not([data-expanded]):hover { background: var(--surface-muted); color: var(--foreground); }\n  .shell[data-expanded]:not(:has(> .input:focus)):hover { border-color: var(--foreground); }\n  .clear:hover { background: var(--surface-muted); color: var(--foreground); }\n}\n/* A compact press while it is still a button; the morph takes over on release. */\n.shell:not([data-expanded]):has(> .trigger:active) { transform: scale(.97); transition-duration: var(--duration-fast), var(--duration-fast), var(--duration-fast), var(--duration-standard), var(--duration-instant); }\n.trigger { position: absolute; z-index: 1; inset: 0; margin: 0; padding: 0; border: 0; border-radius: inherit; background: transparent; cursor: pointer; -webkit-tap-highlight-color: transparent; }\n.trigger:focus-visible { outline: none; }\n/* The glass sits at the leading edge, so it is centered in the circle and rides that edge to its place in the open field. */\n.icon { position: absolute; top: calc(50% - 9px); left: calc(var(--control-height-md) / 2 - 10px); pointer-events: none; }\n.input { position: absolute; inset: 0; width: 100%; height: 100%; margin: 0; padding: 0 40px 0 38px; border: 0; background: transparent; color: var(--foreground); font: inherit; font-size: var(--text-sm); letter-spacing: var(--tracking-body); text-overflow: ellipsis; outline: none; }\n.input:focus-visible { outline: none; }\n.input::placeholder { color: var(--text-muted); opacity: 1; }\n.input[inert] { pointer-events: none; }\n.clear { position: absolute; z-index: 1; top: calc(50% - 14px); right: 7px; display: grid; width: 28px; height: 28px; place-items: center; margin: 0; padding: 0; border: 0; border-radius: var(--radius-pill); background: transparent; color: var(--text-muted); cursor: pointer; transition: background-color var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard); -webkit-tap-highlight-color: transparent; }\n\n/* A floating layer: its width is the field's, so it unfolds from the field on first open; its content keeps the final width and rides the leading edge. */\n.panel { position: absolute; z-index: 40; top: calc(100% + 8px); right: 0; overflow: hidden; border: 1px solid var(--border); border-radius: var(--radius-panel); background: var(--surface-raised); box-shadow: var(--shadow-floating); pointer-events: auto; }\n.root[data-anchor=\"start\"] .panel { right: auto; left: 0; }\n.body { position: relative; width: calc(var(--es-width, 360px) - 2px); max-height: min(22rem, 60vh); overflow-x: hidden; overflow-y: auto; overscroll-behavior: contain; padding: 6px; scrollbar-width: thin; }\n.view { position: relative; }\n/* A flex gap, not margins: a group popping out while it leaves never lets the next one's margin collapse through the list. */\n.listbox { position: relative; isolation: isolate; display: flex; flex-direction: column; gap: 4px; }\n.group { position: relative; }\n.groupLabel { padding: 8px 12px 4px; color: var(--text-muted); font-size: var(--text-xs); font-weight: 500; line-height: var(--leading-body); }\n/* Nested corners step down from the panel by its inset. The highlight paints under every row's content, so text never hides while it travels. */\n.option { position: relative; display: flex; min-height: 48px; align-items: center; gap: 12px; padding: 6px 12px; border-radius: calc(var(--radius-panel) - 6px); color: var(--foreground); cursor: pointer; user-select: none; -webkit-tap-highlight-color: transparent; }\n.highlight { position: absolute; z-index: -1; inset: 0; border-radius: inherit; background: color-mix(in oklab, var(--foreground) 6%, transparent); pointer-events: none; }\n.optionIcon { display: grid; flex: none; width: 18px; place-items: center; color: var(--text-muted); transition: color var(--duration-fast) var(--ease-standard); }\n.option[data-active] .optionIcon { color: var(--foreground); }\n.optionText { display: grid; min-width: 0; }\n.optionTitle, .optionMeta { overflow: hidden; line-height: var(--leading-body); text-overflow: ellipsis; white-space: nowrap; }\n.optionTitle { color: var(--foreground); font-size: var(--text-sm); }\n.optionMeta { color: var(--text-muted); font-size: var(--text-xs); }\n.mark { background: none; color: var(--foreground); font-weight: 500; }\n.empty { display: grid; gap: 2px; padding: 12px 12px 14px; }\n.emptyTitle { margin: 0; overflow-wrap: anywhere; color: var(--foreground); font-size: var(--text-sm); font-weight: 500; line-height: var(--leading-body); }\n.emptyHint { margin: 0; color: var(--text-muted); font-size: var(--text-xs); line-height: var(--leading-body); }\n.srOnly { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }\n@media (prefers-reduced-motion: reduce) {\n  .shell, .clear, .optionIcon { transition: none; }\n  .shell:not([data-expanded]):has(> .trigger:active) { transform: none; }\n}\n"
    }
  ],
  "docs": "Docs and live preview: https://ui.xzero.co.uk/components/expanding-search",
  "meta": {
    "tier": "free",
    "kind": "component",
    "tags": [
      "search",
      "field",
      "motion"
    ],
    "docs": "https://ui.xzero.co.uk/components/expanding-search",
    "markdown": "https://ui.xzero.co.uk/components/expanding-search/markdown"
  }
}