{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "morph-select",
  "type": "registry:ui",
  "title": "Morph select",
  "description": "A select whose trigger grows into the list, with a gliding highlight and type-ahead.",
  "categories": [
    "inputs"
  ],
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "https://mcp.xzero.co.uk/r/xzero-motion-tokens.json"
  ],
  "files": [
    {
      "path": "registry/components/morph-select/morph-select.tsx",
      "type": "registry:component",
      "target": "@components/xzero/morph-select/morph-select.tsx",
      "content": "\"use client\";\n\nimport { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from \"react\";\nimport type { CSSProperties, FocusEvent as ReactFocusEvent, KeyboardEvent as ReactKeyboardEvent, ReactNode } from \"react\";\nimport { AnimatePresence, animate, motion, useMotionValue, useReducedMotion } from \"motion/react\";\nimport type { Transition, Variants } from \"motion/react\";\nimport { Check, ChevronDown, Search, X } from \"lucide-react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport styles from \"./morph-select.module.css\";\n\nexport interface MorphSelectOption {\n  value: string;\n  label: string;\n  /** A 20px mark shown before the label, in the list and in the trigger. An avatar or a plain icon. */\n  icon?: ReactNode;\n  /** Short trailing detail such as an offset or a count. Rendered with tabular numerals. */\n  meta?: string;\n  /** Extra words that match this option when searching. */\n  keywords?: string;\n  disabled?: boolean;\n}\n\n/** A labelled run of options. */\nexport interface MorphSelectGroup {\n  label: string;\n  options: MorphSelectOption[];\n}\n\nexport type MorphSelectItem = MorphSelectOption | MorphSelectGroup;\n\n/**\n * A select whose trigger is the list. Opening grows the trigger itself into the options surface, a highlight glides between options,\n * and the chosen option's label lifts out of the list and settles into the trigger while the trigger springs to its new width.\n * Supports groups, type-ahead, search for long lists, and the full listbox keyboard model.\n * Use it for compact property pickers and form fields with up to a few hundred options. Prefer a combobox when people mostly type free text.\n */\nexport interface MorphSelectProps {\n  /** Visible label, also the accessible name of the trigger and the list. */\n  label: string;\n  /** Keeps the label for assistive technology only. */\n  hideLabel?: boolean;\n  items: MorphSelectItem[];\n  value?: string | null;\n  defaultValue?: string | null;\n  onValueChange?: (value: string, option: MorphSelectOption) => void;\n  placeholder?: string;\n  /** Shows a search field in place of the trigger when open. \"auto\" turns it on above eight options. */\n  searchable?: boolean | \"auto\";\n  searchPlaceholder?: string;\n  /** Width of the open surface in px. It never gets narrower than the trigger. Defaults to 272. */\n  panelWidth?: number;\n  /** Tallest the option list gets before it scrolls, in px. Defaults to 296. */\n  maxListHeight?: number;\n  /** Which edge of the trigger stays put while the surface grows. */\n  align?: \"start\" | \"end\";\n  /** Adds a hidden input for native form submission. */\n  name?: string;\n  disabled?: boolean;\n  className?: string;\n}\n\ntype Section = { key: string; label?: string; options: MorphSelectOption[] };\ntype Change = { key: number; value: string | null; dir: number; fly: { x: number; y: number } | null };\n\nconst { blur } = motionTokens;\ntype Bezier = [number, number, number, number];\nconst enter = [...motionTokens.ease.enter] as Bezier;\nconst standard = [...motionTokens.ease.standard] as Bezier;\n/** Duration springs restated as stiffness and damping, so a retarget mid flight keeps the velocity it already has. */\nconst physical = (visualDuration: number, bounce: number): Transition => {\n  const root = 2 * Math.PI / (visualDuration * 1.2);\n  return { type: \"spring\", stiffness: root * root, damping: 2 * (1 - bounce) * root, mass: 1 };\n};\nconst GROW = physical(.4, 0), SHRINK = physical(.34, 0), GLIDE = physical(.28, 0), WIDTH = physical(.42, 0);\n/** Width that narrows never overshoots: dipping even half a pixel under the closed width ellipsizes the label and nudges the chevron. */\nconst NARROW = physical(.42, 0);\n/** The lifted label travels a touch faster than the surface folds, so the closing edge never catches it. */\nconst FLY = physical(.3, 0);\nconst OPEN_RADIUS = 22;\nconst TYPEAHEAD_RESET = 600, PAGE = 8;\n\nconst subscribe = () => () => {};\nfunction useReducedFlag() {\n  const hydrated = useSyncExternalStore(subscribe, () => true, () => false);\n  return !!useReducedMotion() && hydrated;\n}\n\nconst isGroup = (item: MorphSelectItem): item is MorphSelectGroup => \"options\" in item;\n\n/** Loose options between groups gather into unlabelled sections, so the list keeps the order it was given. */\nfunction normalize(items: MorphSelectItem[]): Section[] {\n  const out: Section[] = [];\n  items.forEach((item, index) => {\n    if (isGroup(item)) { out.push({ key: `group-${index}`, label: item.label, options: item.options }); return; }\n    const last = out[out.length - 1];\n    if (last && !last.label) last.options.push(item);\n    else out.push({ key: `loose-${index}`, options: [item] });\n  });\n  return out;\n}\n\nfunction matches(option: MorphSelectOption, needle: string) {\n  return `${option.label} ${option.meta ?? \"\"} ${option.keywords ?? \"\"}`.toLowerCase().includes(needle);\n}\n\n/** Lid values roll with the list: a later option rises from below, an earlier one drops from above. */\nconst valueVariants: Variants = {\n  enter: (change: Change) => change.fly ? { opacity: 1, x: change.fly.x, y: change.fly.y, filter: \"blur(0px)\" } : { opacity: 0, x: 0, y: `${change.dir * .5}em`, filter: `blur(${blur.soft}px)` },\n  rest: { opacity: 1, x: 0, y: 0, filter: \"blur(0px)\" },\n  exit: (change: Change) => ({ opacity: 0, x: 0, y: change.fly ? \"-0.3em\" : `${change.dir * -.45}em`, filter: `blur(${blur.subtle}px)`, transition: { duration: .12, ease: standard } }),\n};\nconst valueFade: Variants = { enter: { opacity: 0 }, rest: { opacity: 1, x: 0, y: 0, filter: \"blur(0px)\" }, exit: { opacity: 0, transition: { duration: .1 } } };\n\n/** The closed trigger's width, rounded up. offsetWidth rounds to the nearest pixel, and half a pixel short is enough to ellipsize the label. */\nconst triggerWidth = (node: HTMLElement) => {\n  const exact = node.getBoundingClientRect().width, rounded = node.offsetWidth;\n  // Under a scaled ancestor the rect is scaled too, so fall back to the layout width with a pixel of slack.\n  return Math.abs(exact - rounded) <= .5 ? Math.ceil(exact - .01) : rounded + 1;\n};\n\nexport function MorphSelect({ label, hideLabel = false, items, value, defaultValue = null, onValueChange, placeholder = \"Select\", searchable = \"auto\", searchPlaceholder,\n  panelWidth = 272, maxListHeight = 296, align = \"start\", name, disabled = false, className }: MorphSelectProps) {\n  const reduced = useReducedFlag();\n  const uid = useId();\n  const labelId = `${uid}-label`, listId = `${uid}-list`, optionId = (index: number) => `${uid}-option-${index}`;\n\n  const rootRef = useRef<HTMLDivElement>(null);\n  const measureRef = useRef<HTMLSpanElement>(null);\n  const listFaceRef = useRef<HTMLDivElement>(null);\n  const scrollRef = useRef<HTMLDivElement>(null);\n  const triggerRef = useRef<HTMLButtonElement>(null);\n  const inputRef = useRef<HTMLInputElement>(null);\n  const slotRef = useRef<HTMLSpanElement>(null);\n  const optionRefs = useRef(new Map<string, HTMLElement>());\n\n  const sections = useMemo(() => normalize(items), [items]);\n  const all = useMemo(() => sections.flatMap(section => section.options), [sections]);\n  const indexOf = useMemo(() => new Map(all.map((option, index) => [option.value, index])), [all]);\n  const canSearch = searchable === \"auto\" ? all.length > 8 : searchable;\n\n  const [inner, setInner] = useState<string | null>(defaultValue);\n  const selected = value !== undefined ? value : inner;\n  const selectedOption = selected === null ? undefined : all.find(option => option.value === selected);\n\n  const [open, setOpen] = useState(false);\n  const [query, setQuery] = useState(\"\");\n  const [active, setActive] = useState<string | null>(null);\n  const [change, setChange] = useState<Change>({ key: 0, value: null, dir: 1, fly: null });\n\n  const needle = query.trim().toLowerCase();\n  const visibleSections = useMemo(() => needle\n    ? sections.map(section => ({ ...section, options: section.options.filter(option => matches(option, needle)) })).filter(section => section.options.length)\n    : sections, [needle, sections]);\n  const enabled = useMemo(() => visibleSections.flatMap(section => section.options).filter(option => !option.disabled), [visibleSections]);\n  const current = active !== null && enabled.some(option => option.value === active) ? active : enabled[0]?.value ?? null;\n  const resultCount = visibleSections.reduce((sum, section) => sum + section.options.length, 0);\n\n  /* The shape: one surface whose width, height, and corners spring between the trigger and the open list. */\n  const anchorW = useMotionValue<number | string>(\"auto\");\n  const shapeW = useMotionValue<number | string>(\"100%\"), shapeH = useMotionValue<number | string>(\"100%\"), radius = useMotionValue(22);\n  const sizes = useRef({ trigger: 0, lid: 0, listW: 0, listH: 0 });\n  const live = useRef({ open: false, reduced: false, measured: false });\n  useLayoutEffect(() => { live.current.reduced = reduced; }, [reduced]);\n\n  const place = useCallback((animated: boolean) => {\n    const { trigger, lid, listW, listH } = sizes.current;\n    if (!trigger || !lid) return;\n    const isOpen = live.current.open;\n    const next = isOpen ? { w: Math.max(trigger, listW), h: lid + listH, r: OPEN_RADIUS } : { w: trigger, h: lid, r: lid / 2 };\n    if (!animated || live.current.reduced || !live.current.measured) {\n      anchorW.jump(trigger); shapeW.jump(next.w); shapeH.jump(next.h); radius.jump(next.r);\n      live.current.measured = true;\n      return;\n    }\n    const lastW = typeof shapeW.get() === \"number\" ? shapeW.get() as number : next.w;\n    const lastH = typeof shapeH.get() === \"number\" ? shapeH.get() as number : next.h;\n    const lastAnchor = typeof anchorW.get() === \"number\" ? anchorW.get() as number : trigger;\n    const transition = next.w * next.h >= lastW * lastH ? GROW : SHRINK;\n    animate(anchorW, trigger, trigger < lastAnchor ? NARROW : WIDTH);\n    animate(shapeW, next.w, isOpen ? transition : next.w < lastW ? NARROW : WIDTH);\n    animate(shapeH, next.h, transition);\n    animate(radius, next.r, transition);\n  }, [anchorW, radius, shapeH, shapeW]);\n\n  useLayoutEffect(() => {\n    const measure = measureRef.current, face = listFaceRef.current, root = rootRef.current;\n    if (!measure || !face || !root) return;\n    const read = () => {\n      const previous = sizes.current;\n      // The open list is never narrower than the trigger; the width goes to CSS directly so a new label never waits on a render.\n      root.style.setProperty(\"--ms-trigger-w\", `${triggerWidth(measure)}px`);\n      const next = { trigger: triggerWidth(measure), lid: measure.offsetHeight, listW: face.offsetWidth, listH: face.offsetHeight };\n      const changed = next.trigger !== previous.trigger || next.lid !== previous.lid || (live.current.open && (next.listW !== previous.listW || next.listH !== previous.listH));\n      sizes.current = next;\n      if (changed) place(live.current.measured);\n    };\n    read();\n    root.dataset.ready = \"\";\n    if (typeof ResizeObserver === \"undefined\") return;\n    const observer = new ResizeObserver(read);\n    observer.observe(measure);\n    observer.observe(face);\n    return () => observer.disconnect();\n  }, [place]);\n\n  useLayoutEffect(() => {\n    if (live.current.open === open) return;\n    live.current.open = open;\n    // A pick changes the label and closes in the same commit, so both faces are read fresh before the shape moves.\n    const face = listFaceRef.current, measure = measureRef.current;\n    if (face && measure) {\n      rootRef.current?.style.setProperty(\"--ms-trigger-w\", `${triggerWidth(measure)}px`);\n      sizes.current = { trigger: triggerWidth(measure), lid: measure.offsetHeight, listW: face.offsetWidth, listH: face.offsetHeight };\n    }\n    place(true);\n  }, [open, place]);\n\n  /* The highlight glides between options on its own spring and fades when nothing is active. */\n  const hy = useMotionValue(0), hh = useMotionValue(0), ho = useMotionValue(0);\n  const scrollIntent = useRef(false);\n  useLayoutEffect(() => {\n    const node = open && current !== null ? optionRefs.current.get(current) : undefined;\n    if (!node) { animate(ho, 0, { duration: reduced || !open ? 0 : .12, ease: standard }); return; }\n    const top = node.offsetTop, height = node.offsetHeight;\n    if (ho.get() < .05 || reduced) { hy.jump(top); hh.jump(height); }\n    else { animate(hy, top, GLIDE); animate(hh, height, GLIDE); }\n    animate(ho, 1, { duration: reduced ? 0 : .12, ease: enter });\n    const scroller = scrollRef.current;\n    if (scroller && scrollIntent.current) {\n      scrollIntent.current = false;\n      const pad = 6;\n      if (top < scroller.scrollTop + pad) scroller.scrollTop = top - pad;\n      else if (top + height > scroller.scrollTop + scroller.clientHeight - pad) scroller.scrollTop = top + height - scroller.clientHeight + pad;\n    }\n  }, [current, hh, ho, hy, open, reduced, visibleSections]);\n\n  /* Focus lands in the lid: the search field when there is one, otherwise the trigger that keeps the active option. */\n  const pendingFocus = useRef<\"trigger\" | \"input\" | null>(null);\n  useLayoutEffect(() => {\n    const target = pendingFocus.current;\n    pendingFocus.current = null;\n    if (target === \"input\") inputRef.current?.focus({ preventScroll: true });\n    if (target === \"trigger\") triggerRef.current?.focus({ preventScroll: true });\n  }, [open]);\n\n  const typeahead = useRef({ buffer: \"\", at: 0 });\n  /** Space opens and picks on keydown; the click a button fires on the matching keyup must not undo it. */\n  const suppressClick = useRef(false);\n  const [announcement, setAnnouncement] = useState(\"\");\n\n  const openList = (focus?: string | null, seed = \"\") => {\n    if (disabled || open) return;\n    setQuery(seed);\n    setActive(focus !== undefined ? focus : selected);\n    scrollIntent.current = true;\n    pendingFocus.current = canSearch ? \"input\" : \"trigger\";\n    setAnnouncement(\"\");\n    setOpen(true);\n  };\n\n  const close = useCallback((restoreFocus: boolean) => {\n    typeahead.current.buffer = \"\";\n    if (restoreFocus) pendingFocus.current = \"trigger\";\n    setOpen(false);\n  }, []);\n\n  const commit = (option: MorphSelectOption | undefined, source?: HTMLElement | null) => {\n    if (!option || option.disabled) return;\n    if (option.value !== selected) {\n      let fly: Change[\"fly\"] = null;\n      const slot = slotRef.current;\n      const part = source?.querySelector<HTMLElement>(\"[data-part='value']\");\n      if (!reduced && part && slot) {\n        const from = part.getBoundingClientRect(), to = slot.getBoundingClientRect();\n        fly = { x: from.left - to.left, y: from.top + from.height / 2 - (to.top + to.height / 2) };\n      }\n      const from = selected === null ? -1 : indexOf.get(selected) ?? -1, to = indexOf.get(option.value) ?? 0;\n      setChange(last => ({ key: last.key + 1, value: option.value, dir: from < 0 ? 1 : Math.sign(to - from) || 1, fly }));\n      if (value === undefined) setInner(option.value);\n      onValueChange?.(option.value, option);\n    }\n    close(true);\n  };\n\n  const typeTo = (char: string) => {\n    const now = performance.now(), state = typeahead.current;\n    state.buffer = now - state.at > TYPEAHEAD_RESET ? char : state.buffer + char;\n    state.at = now;\n    const buffer = state.buffer.toLowerCase();\n    const repeated = buffer.split(\"\").every(letter => letter === buffer[0]);\n    const search = repeated ? buffer[0]! : buffer;\n    const pool = all.filter(option => !option.disabled);\n    const from = open ? current : selected;\n    const at = pool.findIndex(option => option.value === from);\n    const offset = repeated || buffer.length === 1 ? 1 : 0;\n    const ordered = [...pool.slice(at + offset), ...pool.slice(0, at + offset)];\n    return ordered.find(option => option.label.toLowerCase().startsWith(search));\n  };\n\n  const move = (to: string | null | undefined) => {\n    if (to === undefined || to === null) return;\n    scrollIntent.current = true;\n    setActive(to);\n  };\n\n  const onKey = (event: ReactKeyboardEvent<HTMLElement>, fromInput: boolean) => {\n    const { key } = event;\n    if (key === \" \" && !fromInput) suppressClick.current = true;\n    const printable = key.length === 1 && !event.metaKey && !event.ctrlKey && !event.altKey;\n    const typing = performance.now() - typeahead.current.at < TYPEAHEAD_RESET && typeahead.current.buffer.length > 0;\n\n    if (!open) {\n      if (key === \"ArrowDown\" || key === \"ArrowUp\" || key === \"Enter\" || (key === \" \" && !typing)) { event.preventDefault(); openList(); return; }\n      if (key === \"Home\" || key === \"End\") { event.preventDefault(); const pool = all.filter(option => !option.disabled); openList((key === \"Home\" ? pool[0] : pool[pool.length - 1])?.value ?? null); return; }\n      if (printable) {\n        event.preventDefault();\n        if (canSearch) openList(undefined, key);\n        else { const hit = typeTo(key); openList(hit?.value ?? selected); }\n      }\n      return;\n    }\n\n    const at = enabled.findIndex(option => option.value === current);\n    const caretKeys = fromInput && query.length > 0;\n    switch (key) {\n      case \"ArrowDown\": event.preventDefault(); move(enabled[Math.min(enabled.length - 1, at + 1)]?.value); return;\n      case \"ArrowUp\":\n        event.preventDefault();\n        if (event.altKey) { commit(enabled[at], optionRefs.current.get(current ?? \"\")); return; }\n        move(enabled[Math.max(0, at - 1)]?.value); return;\n      case \"PageDown\": event.preventDefault(); move(enabled[Math.min(enabled.length - 1, at + PAGE)]?.value); return;\n      case \"PageUp\": event.preventDefault(); move(enabled[Math.max(0, at - PAGE)]?.value); return;\n      case \"Home\": if (caretKeys) return; event.preventDefault(); move(enabled[0]?.value); return;\n      case \"End\": if (caretKeys) return; event.preventDefault(); move(enabled[enabled.length - 1]?.value); return;\n      case \"Enter\": event.preventDefault(); commit(enabled[at], optionRefs.current.get(current ?? \"\")); return;\n      case \"Escape\": event.preventDefault(); event.stopPropagation(); close(true); return;\n      case \"Tab\": close(false); return;\n    }\n    if (fromInput) return;\n    if (key === \" \" && !typing) { event.preventDefault(); commit(enabled[at], optionRefs.current.get(current ?? \"\")); return; }\n    if (printable) { event.preventDefault(); move(typeTo(key)?.value); }\n  };\n\n  // A press outside or focus leaving the control folds the list back into the trigger.\n  useEffect(() => {\n    if (!open) return;\n    const down = (event: PointerEvent) => { if (!rootRef.current?.contains(event.target as Node)) close(false); };\n    document.addEventListener(\"pointerdown\", down);\n    return () => document.removeEventListener(\"pointerdown\", down);\n  }, [close, open]);\n  const onBlur = (event: ReactFocusEvent<HTMLDivElement>) => {\n    const next = event.relatedTarget as Node | null;\n    if (open && next && !event.currentTarget.contains(next)) close(false);\n  };\n\n  const onQuery = (next: string) => {\n    setQuery(next);\n    setActive(null);\n    scrollIntent.current = true;\n    if (scrollRef.current) scrollRef.current.scrollTop = 0;\n    const text = next.trim().toLowerCase();\n    const count = text ? all.filter(option => matches(option, text)).length : all.length;\n    setAnnouncement(text ? count ? `${count} ${count === 1 ? \"option\" : \"options\"}` : \"No matches\" : \"\");\n  };\n\n  const renderValue = (option: MorphSelectOption | undefined) => option\n    ? <>{option.icon && <span className={styles.icon} aria-hidden=\"true\">{option.icon}</span>}<span className={styles.valueText}>{option.label}</span></>\n    : <span className={styles.placeholder}>{placeholder}</span>;\n\n  const showSearch = open && canSearch;\n  const layerKey = selected ?? \"__empty\";\n  const changeForLayer: Change = change.value === selected ? change : { ...change, fly: null };\n  const rootStyle = { \"--ms-panel-w\": `${panelWidth}px`, \"--ms-list-max\": `${maxListHeight}px` } as CSSProperties;\n\n  return <div ref={rootRef} className={[styles.root, className].filter(Boolean).join(\" \")} style={rootStyle} data-open={open || undefined} data-align={align} data-disabled={disabled || undefined} onBlur={onBlur}>\n    <span id={labelId} className={hideLabel ? styles.srOnly : styles.label}>{label}</span>\n\n    <motion.div className={styles.anchor} style={{ width: anchorW }}>\n      {/* Sizes the closed trigger. It holds the chosen label with the trigger's own padding, so the width spring has a target before anything moves. */}\n      <span ref={measureRef} className={`${styles.trigger} ${styles.measure}`} aria-hidden=\"true\">{renderValue(selectedOption)}</span>\n\n      <motion.div className={styles.shape} style={{ width: shapeW, height: shapeH, borderRadius: radius }}>\n        <div className={styles.lid}>\n          <button ref={triggerRef} type=\"button\" className={styles.trigger} role=\"combobox\" aria-labelledby={labelId} aria-haspopup=\"listbox\" aria-expanded={open} aria-controls={listId}\n            aria-activedescendant={open && !canSearch && current !== null ? optionId(indexOf.get(current) ?? 0) : undefined}\n            disabled={disabled} inert={showSearch || undefined} tabIndex={showSearch ? -1 : 0}\n            onClick={() => { if (suppressClick.current) { suppressClick.current = false; return; } if (open) close(true); else openList(); }}\n            onKeyDown={event => onKey(event, false)} onKeyUp={event => { if (event.key === \" \") window.setTimeout(() => { suppressClick.current = false; }); }}>\n            <span ref={slotRef} className={styles.slot} data-hidden={showSearch || undefined}>\n              <AnimatePresence initial={false} custom={changeForLayer}>\n                <motion.span key={layerKey} className={styles.layer} custom={changeForLayer} variants={reduced ? valueFade : valueVariants} initial=\"enter\" animate=\"rest\" exit=\"exit\"\n                  transition={reduced ? { duration: .12 } : changeForLayer.fly ? { x: FLY, y: FLY, opacity: { duration: 0 } } : { y: GLIDE, opacity: { duration: .2, ease: enter }, filter: { duration: .22, ease: enter } }}>\n                  {renderValue(selectedOption)}\n                </motion.span>\n              </AnimatePresence>\n            </span>\n          </button>\n\n          {canSearch && <motion.div className={styles.searchRow} inert={!showSearch || undefined} initial={false}\n            animate={showSearch ? { opacity: 1, filter: \"blur(0px)\" } : { opacity: 0, filter: reduced ? \"blur(0px)\" : `blur(${blur.subtle}px)` }}\n            transition={showSearch ? { duration: .18, ease: enter, delay: reduced ? 0 : .04 } : { duration: .1, ease: standard }}>\n            <Search className={styles.searchIcon} size={16} strokeWidth={1.75} aria-hidden=\"true\" />\n            <input ref={inputRef} className={styles.input} type=\"text\" role=\"combobox\" aria-label={`Search ${label.toLowerCase()}`} aria-expanded={open} aria-controls={listId} aria-autocomplete=\"list\"\n              aria-activedescendant={open && current !== null ? optionId(indexOf.get(current) ?? 0) : undefined}\n              placeholder={searchPlaceholder ?? selectedOption?.label ?? \"Search\"} value={query} autoComplete=\"off\" spellCheck={false}\n              onChange={event => onQuery(event.target.value)} onKeyDown={event => onKey(event, true)} />\n            <AnimatePresence initial={false}>\n              {query && <motion.button key=\"clear\" type=\"button\" className={styles.clear} aria-label=\"Clear search\" onPointerDown={event => event.preventDefault()}\n                onClick={() => { onQuery(\"\"); inputRef.current?.focus(); }}\n                initial={{ opacity: 0, scale: .6 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: .6, transition: { duration: .1 } }} transition={reduced ? { duration: 0 } : GLIDE}>\n                <X size={14} strokeWidth={1.75} aria-hidden=\"true\" />\n              </motion.button>}\n            </AnimatePresence>\n            <button type=\"button\" className={styles.closeHit} aria-label={`Close ${label.toLowerCase()}`} onClick={() => close(true)} />\n          </motion.div>}\n\n          <motion.span className={styles.chevron} aria-hidden=\"true\" initial={false} animate={{ rotate: open ? 180 : 0 }} transition={reduced ? { duration: 0 } : GLIDE}>\n            <ChevronDown size={16} strokeWidth={1.75} />\n          </motion.span>\n        </div>\n\n        <motion.div ref={listFaceRef} className={styles.listFace}\n          inert={!open || undefined} aria-hidden={!open || undefined} initial={false}\n          animate={open ? { opacity: 1, y: 0, filter: \"blur(0px)\" } : { opacity: 0, y: reduced ? 0 : -6, filter: reduced ? \"blur(0px)\" : `blur(${blur.subtle}px)` }}\n          transition={open ? { y: GROW, opacity: { duration: .2, ease: enter, delay: reduced ? 0 : .04 }, filter: { duration: .22, ease: enter, delay: .04 } } : { duration: .1, ease: standard }}>\n          <div ref={scrollRef} className={styles.scroll}>\n            <div id={listId} className={styles.options} role=\"listbox\" aria-labelledby={labelId}>\n              <motion.span className={styles.highlight} style={{ y: hy, height: hh, opacity: ho }} aria-hidden=\"true\" />\n              {visibleSections.map(section => {\n                const rows = section.options.map(option => {\n                  const index = indexOf.get(option.value) ?? 0;\n                  const isSelected = option.value === selected, isActive = option.value === current;\n                  return <div key={option.value} id={optionId(index)} role=\"option\" aria-selected={isSelected} aria-disabled={option.disabled || undefined}\n                    className={styles.option} data-active={isActive || undefined}\n                    ref={node => { if (node) optionRefs.current.set(option.value, node); else optionRefs.current.delete(option.value); }}\n                    onPointerMove={event => { if (event.pointerType === \"mouse\" && !option.disabled && option.value !== current) setActive(option.value); }}\n                    onPointerDown={event => event.preventDefault()}\n                    onClick={event => commit(option, event.currentTarget)}>\n                    <span className={styles.optionValue} data-part=\"value\">{option.icon && <span className={styles.icon} aria-hidden=\"true\">{option.icon}</span>}<span className={styles.valueText}>{option.label}</span></span>\n                    {option.meta && <span className={styles.meta}>{option.meta}</span>}\n                    <span className={styles.check} data-on={isSelected || undefined} aria-hidden=\"true\"><Check size={16} strokeWidth={1.75} /></span>\n                  </div>;\n                });\n                return section.label\n                  ? <div key={section.key} role=\"group\" aria-labelledby={`${uid}-${section.key}`} className={styles.group}>\n                    <div id={`${uid}-${section.key}`} className={styles.groupLabel} role=\"presentation\">{section.label}</div>\n                    {rows}\n                  </div>\n                  : <div key={section.key} role=\"presentation\" className={styles.group}>{rows}</div>;\n              })}\n              {resultCount === 0 && <p className={styles.empty}>No matches for “{query.trim()}”</p>}\n            </div>\n          </div>\n        </motion.div>\n      </motion.div>\n    </motion.div>\n\n    {name && <input type=\"hidden\" name={name} value={selected ?? \"\"} />}\n    <span className={styles.srOnly} role=\"status\" aria-live=\"polite\">{announcement}</span>\n  </div>;\n}\n\nexport default MorphSelect;\n"
    },
    {
      "path": "registry/components/morph-select/morph-select.module.css",
      "type": "registry:component",
      "target": "@components/xzero/morph-select/morph-select.module.css",
      "content": "/* The root lays out the label and the anchor. The anchor holds the closed trigger's place; the shape floats over it and grows past it when open. */\n.root { --ms-h: var(--control-height-md); --ms-trigger-w: 0px; position: relative; display: inline-grid; justify-items: start; gap: var(--space-2); min-width: 0; color: var(--foreground); font-family: var(--font-body); font-size: var(--text-sm); letter-spacing: var(--tracking-body); line-height: var(--leading-body); }\n.root[data-align=\"end\"] { justify-items: end; }\n.root[data-open] { z-index: 30; }\n.label { color: var(--text-secondary); font-size: var(--text-sm); font-weight: 500; }\n\n.anchor { position: relative; height: var(--ms-h); max-width: 100%; }\n\n/* One material for both states. The border is an overlay so it never changes a measurement. */\n.shape { position: absolute; top: 0; left: 0; overflow: clip; background: var(--surface); box-shadow: var(--shadow-resting); transition: box-shadow var(--duration-considered) var(--ease-standard), background-color var(--duration-fast) var(--ease-standard); }\n.shape::after { content: \"\"; position: absolute; inset: 0; z-index: 2; border: 1px solid var(--border); border-radius: inherit; pointer-events: none; transition: border-color var(--duration-fast) var(--ease-standard); }\n.root[data-align=\"end\"] .shape { right: 0; left: auto; }\n.root[data-open] .shape { background: var(--surface-raised); box-shadow: var(--shadow-floating); }\n.root[data-disabled] .shape { opacity: .55; }\n@media (hover: hover) and (pointer: fine) {\n  .root:not([data-open], [data-disabled]) .shape:hover { background: var(--surface-muted); }\n  .root:not([data-open], [data-disabled]) .shape:hover::after { border-color: var(--border-strong); }\n}\n\n/* The lid is the trigger row. It stays at the top of the surface in both states and sits above the list, so a lifted label passes over it. */\n.lid { position: absolute; z-index: 1; top: 0; right: 0; left: 0; height: var(--ms-h); }\n\n.trigger { box-sizing: border-box; display: flex; height: var(--ms-h); align-items: center; gap: var(--space-2); padding: 0 36px 0 14px; font-size: var(--text-sm); font-weight: 500; white-space: nowrap; }\n.lid .trigger { position: absolute; inset: 0; width: 100%; margin: 0; border: 0; background: transparent; color: inherit; cursor: pointer; font-family: inherit; letter-spacing: inherit; text-align: left; -webkit-tap-highlight-color: transparent; touch-action: manipulation; }\n.lid .trigger:disabled { cursor: not-allowed; }\n/* An invisible copy that says how wide the closed trigger wants to be. It sits in flow until the first measurement lands, so the server render already has the right size. */\n.measure { position: static; visibility: hidden; pointer-events: none; width: max-content; max-width: min(20rem, calc(100vw - 2rem)); }\n.root[data-ready] .measure { position: absolute; top: 0; left: 0; }\n.root[data-align=\"end\"][data-ready] .measure { right: 0; left: auto; }\n\n.slot { position: relative; display: block; min-width: 0; height: 100%; flex: 1; }\n.slot[data-hidden] { opacity: 0; transition: opacity var(--duration-instant) var(--ease-standard); }\n.layer { position: absolute; inset: 0; display: flex; align-items: center; gap: var(--space-2); min-width: 0; }\n.valueText { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }\n.placeholder { color: var(--text-muted); font-weight: 400; }\n.icon { display: grid; width: 20px; height: 20px; flex: none; place-items: center; color: var(--text-secondary); }\n.icon > svg { width: 16px; height: 16px; }\n.icon > img { width: 20px; height: 20px; border-radius: 50%; object-fit: cover; }\n\n.chevron { position: absolute; top: 50%; right: 12px; display: grid; width: 16px; height: 16px; margin-top: -8px; place-items: center; color: var(--text-muted); pointer-events: none; }\n\n/* Search takes the lid's place when the list is long. */\n.searchRow { position: absolute; inset: 0; display: flex; align-items: center; gap: var(--space-2); padding: 0 2px 0 14px; }\n.searchRow[inert] { pointer-events: none; }\n.searchIcon { flex: none; color: var(--text-muted); }\n.input { min-width: 0; height: 100%; flex: 1; padding: 0; border: 0; background: transparent; color: var(--foreground); font: inherit; font-size: var(--text-sm); letter-spacing: var(--tracking-body); outline: none; }\n.input::placeholder { color: var(--text-muted); }\n.clear { display: grid; width: 24px; height: 24px; flex: none; place-items: center; padding: 0; border: 0; border-radius: var(--radius-pill); background: color-mix(in oklab, var(--foreground) 8%, transparent); color: var(--text-secondary); cursor: pointer; -webkit-tap-highlight-color: transparent; }\n/* Sits exactly over the shared chevron, so the chevron closes the list in both lid states. */\n.closeHit { width: 36px; height: 36px; flex: none; padding: 0; border: 0; border-radius: var(--radius-pill); background: transparent; cursor: pointer; -webkit-tap-highlight-color: transparent; transition: background-color var(--duration-fast) var(--ease-standard); }\n@media (hover: hover) and (pointer: fine) {\n  .closeHit:hover, .clear:hover { background: color-mix(in oklab, var(--foreground) 8%, transparent); }\n  .clear:hover { color: var(--foreground); }\n}\n\n/* The list hangs under the lid at its own width, so it measures the same whatever size the surface is mid spring. */\n.listFace { position: absolute; top: var(--ms-h); left: 0; box-sizing: border-box; width: max(var(--ms-trigger-w), min(var(--ms-panel-w), calc(100vw - 2rem))); padding: 0 6px 6px; }\n.root[data-align=\"end\"] .listFace { right: 0; left: auto; }\n.listFace::before { content: \"\"; position: absolute; top: 0; right: 14px; left: 14px; height: 1px; background: var(--border-subtle); }\n.listFace[inert] { pointer-events: none; }\n.scroll { max-height: var(--ms-list-max); overflow-y: auto; overscroll-behavior: contain; padding-top: 6px; scrollbar-width: thin; }\n.options { position: relative; display: grid; gap: 2px; }\n.highlight { position: absolute; top: 0; right: 0; left: 0; border-radius: 16px; background: color-mix(in oklab, var(--foreground) 6.5%, transparent); pointer-events: none; }\n\n.group { display: grid; gap: 2px; }\n.group + .group { margin-top: 4px; }\n.groupLabel { padding: 8px 10px 4px; color: var(--text-muted); font-size: var(--text-xs); font-weight: 400; }\n\n.option { position: relative; display: flex; min-height: 36px; box-sizing: border-box; align-items: center; gap: var(--space-2); padding: 0 10px 0 8px; border-radius: 16px; color: var(--text-secondary); cursor: pointer; user-select: none; -webkit-user-select: none; transition: color var(--duration-fast) var(--ease-standard); }\n.option[data-active], .option[aria-selected=\"true\"] { color: var(--foreground); }\n.option[aria-disabled=\"true\"] { color: var(--text-muted); cursor: not-allowed; opacity: .6; }\n/* Lid padding minus list inset, so a lifted label starts exactly level with where it lands. */\n.optionValue { display: flex; min-width: 0; flex: 1; align-items: center; gap: var(--space-2); font-weight: 500; }\n.meta { flex: none; color: var(--text-muted); font-size: var(--text-xs); font-variant-numeric: tabular-nums; }\n.check { display: grid; width: 16px; height: 16px; flex: none; place-items: center; color: var(--foreground); opacity: 0; transform: scale(.6); transition: opacity var(--duration-fast) var(--ease-standard), transform var(--duration-spring) var(--ease-spring); }\n.check[data-on] { opacity: 1; transform: none; }\n.empty { margin: 0; padding: 12px 10px 14px; color: var(--text-muted); font-size: var(--text-sm); }\n\n.srOnly { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; }\n\n@media (prefers-reduced-motion: reduce) {\n  .shape, .check, .slot { transition-duration: 0ms; }\n}\n@media (prefers-contrast: more) {\n  .shape::after { border-color: var(--border-strong); }\n}\n"
    }
  ],
  "docs": "Docs and live preview: https://ui.xzero.co.uk/components/morph-select",
  "meta": {
    "tier": "free",
    "kind": "component",
    "tags": [
      "inputs",
      "new"
    ],
    "docs": "https://ui.xzero.co.uk/components/morph-select",
    "markdown": "https://ui.xzero.co.uk/components/morph-select/markdown"
  }
}