{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "dropdown",
  "type": "registry:ui",
  "title": "Dropdown",
  "description": "Moves selection continuously with full keyboard behavior.",
  "dependencies": [
    "motion"
  ],
  "categories": [
    "overlays-surfaces"
  ],
  "docs": "https://motion-lexicon.pages.dev/en/components/dropdown/",
  "meta": {
    "engines": [
      "motion"
    ],
    "runtimeCost": "light",
    "signature": "The highlight settles continuously across options",
    "sceneFamily": "product-mono",
    "motionRole": "ui",
    "primaryState": "Expanded selector with the current option highlighted",
    "assetProvenance": "self-contained"
  },
  "files": [
    {
      "path": "src/registry/components/dropdown.tsx",
      "type": "registry:ui",
      "target": "components/motion-lexicon/dropdown.tsx",
      "content": "\"use client\";\n\nimport { useCallback, useEffect, useId, useRef, useState } from \"react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\n\nconst EASE = [0.23, 1, 0.32, 1] as const;\n\nconst EXIT = [0.23, 1, 0.32, 1] as const;\nconst CELL = { type: \"spring\", stiffness: 520, damping: 34, mass: 0.45 } as const;\n\nconst NUDGE = { type: \"spring\", stiffness: 700, damping: 46, mass: 0.5 } as const;\nconst NONE = { duration: 0 } as const;\n\nconst SLIDE = { type: \"spring\", stiffness: 700, damping: 46, mass: 0.5 } as const;\n\nconst ROW_H = 32;\n\nconst OPEN = { type: \"spring\", stiffness: 620, damping: 38, mass: 0.6 } as const;\n\nexport type DropdownItem = {\n  value: string;\n  label: string;\n  hint?: string;\n  disabled?: boolean;\n};\n\nexport type UseDropdownOptions = {\n  items: DropdownItem[];\n  value?: string;\n  defaultValue?: string;\n  onChange?: (value: string) => void;\n  disabled?: boolean;\n  typeaheadDelay?: number;\n};\n\nexport function useDropdown({\n  items,\n  value,\n  defaultValue,\n  onChange,\n  disabled = false,\n  typeaheadDelay = 600,\n}: UseDropdownOptions) {\n  const uid = useId();\n  const listId = `${uid}-list`;\n  const itemId = useCallback((i: number) => `${uid}-opt-${i}`, [uid]);\n\n  const [uncontrolled, setUncontrolled] = useState<string | null>(\n    defaultValue ?? null,\n  );\n  const selectedValue = value !== undefined ? value : uncontrolled;\n  const selectedIndex = items.findIndex((it) => it.value === selectedValue);\n\n  const [open, setOpen] = useState(false);\n  const [activeIndex, setActiveIndex] = useState(-1);\n\n  const rootRef = useRef<HTMLDivElement>(null);\n  const triggerRef = useRef<HTMLButtonElement>(null);\n  const listRef = useRef<HTMLUListElement>(null);\n  const itemRefs = useRef<(HTMLLIElement | null)[]>([]);\n  const viaKey = useRef(false);\n  const buffer = useRef(\"\");\n  const bufferTimer = useRef<ReturnType<typeof setTimeout> | null>(null);\n\n  const emit = useRef(onChange);\n  emit.current = onChange;\n\n  const step = useCallback(\n    (from: number, dir: 1 | -1) => {\n      const n = items.length;\n      if (n === 0) return -1;\n      let i = from;\n      for (let k = 0; k < n; k++) {\n        i = (i + dir + n) % n;\n        if (!items[i].disabled) return i;\n      }\n      return from;\n    },\n    [items],\n  );\n\n  const edge = useCallback(\n    (dir: 1 | -1) => step(dir === 1 ? -1 : items.length, dir),\n    [step, items.length],\n  );\n\n  const openMenu = useCallback(\n    (index?: number) => {\n      if (disabled || items.length === 0) return;\n      const usable = selectedIndex >= 0 && !items[selectedIndex].disabled;\n      viaKey.current = true;\n      setActiveIndex(index ?? (usable ? selectedIndex : edge(1)));\n      setOpen(true);\n    },\n    [disabled, items, selectedIndex, edge],\n  );\n\n  const close = useCallback((restoreFocus = true) => {\n    buffer.current = \"\";\n    setOpen(false);\n    setActiveIndex(-1);\n    if (restoreFocus) triggerRef.current?.focus();\n  }, []);\n\n  const select = useCallback(\n    (index: number) => {\n      const item = items[index];\n      if (!item || item.disabled) return;\n      if (value === undefined) setUncontrolled(item.value);\n      emit.current?.(item.value);\n      close();\n    },\n    [items, value, close],\n  );\n\n  const typeahead = useCallback(\n    (char: string) => {\n      if (bufferTimer.current) clearTimeout(bufferTimer.current);\n      buffer.current += char.toLowerCase();\n      bufferTimer.current = setTimeout(() => {\n        buffer.current = \"\";\n      }, typeaheadDelay);\n\n      const q = buffer.current;\n      const n = items.length;\n      const from = activeIndex < 0 ? 0 : activeIndex;\n      const start = q.length > 1 ? from : from + 1;\n      for (let k = 0; k < n; k++) {\n        const i = (start + k) % n;\n        const it = items[i];\n        if (!it.disabled && it.label.toLowerCase().startsWith(q)) {\n          viaKey.current = true;\n          setActiveIndex(i);\n          return;\n        }\n      }\n    },\n    [items, activeIndex, typeaheadDelay],\n  );\n\n  useEffect(() => {\n    if (open) listRef.current?.focus();\n  }, [open]);\n\n  useEffect(() => {\n    if (!open) return;\n    const onDown = (e: PointerEvent) => {\n      if (!rootRef.current?.contains(e.target as Node)) close(false);\n    };\n    const onWindowBlur = () => close(false);\n    document.addEventListener(\"pointerdown\", onDown, true);\n    window.addEventListener(\"blur\", onWindowBlur);\n    return () => {\n      document.removeEventListener(\"pointerdown\", onDown, true);\n      window.removeEventListener(\"blur\", onWindowBlur);\n    };\n  }, [open, close]);\n\n  useEffect(() => {\n    if (!open || activeIndex < 0 || !viaKey.current) return;\n    viaKey.current = false;\n    itemRefs.current[activeIndex]?.scrollIntoView({ block: \"nearest\" });\n  }, [open, activeIndex]);\n\n  useEffect(\n    () => () => {\n      if (bufferTimer.current) clearTimeout(bufferTimer.current);\n    },\n    [],\n  );\n\n  const triggerProps = {\n    ref: triggerRef,\n    type: \"button\" as const,\n    disabled,\n    \"aria-haspopup\": \"listbox\" as const,\n    \"aria-expanded\": open,\n    \"aria-controls\": open ? listId : undefined,\n    onClick: () => (open ? close() : openMenu()),\n    onKeyDown: (e: React.KeyboardEvent<HTMLButtonElement>) => {\n      if (e.key === \"ArrowDown\" || e.key === \"Enter\" || e.key === \" \") {\n        e.preventDefault();\n        openMenu();\n      } else if (e.key === \"ArrowUp\") {\n        e.preventDefault();\n        openMenu(edge(-1));\n      }\n    },\n  };\n\n  const listProps = {\n    ref: listRef,\n    id: listId,\n    role: \"listbox\" as const,\n    tabIndex: -1,\n    \"aria-activedescendant\": activeIndex >= 0 ? itemId(activeIndex) : undefined,\n    onKeyDown: (e: React.KeyboardEvent<HTMLUListElement>) => {\n      if (e.key === \"ArrowDown\" || e.key === \"ArrowUp\") {\n        e.preventDefault();\n        const dir = e.key === \"ArrowDown\" ? 1 : -1;\n        viaKey.current = true;\n        setActiveIndex((i) => step(i, dir));\n      } else if (e.key === \"Home\" || e.key === \"End\") {\n        e.preventDefault();\n        viaKey.current = true;\n        setActiveIndex(edge(e.key === \"Home\" ? 1 : -1));\n      } else if (e.key === \"Enter\" || e.key === \" \") {\n        e.preventDefault();\n        select(activeIndex);\n      } else if (e.key === \"Escape\") {\n        e.preventDefault();\n        close();\n      } else if (e.key === \"Tab\") {\n        e.preventDefault();\n        close();\n      } else if (\n        e.key.length === 1 &&\n        !e.metaKey &&\n        !e.ctrlKey &&\n        !e.altKey\n      ) {\n        e.preventDefault();\n        typeahead(e.key);\n      }\n    },\n  };\n\n  const getItemProps = useCallback(\n    (index: number) => ({\n      id: itemId(index),\n      role: \"option\" as const,\n      \"aria-selected\": index === selectedIndex,\n      \"aria-disabled\": items[index]?.disabled ? (true as const) : undefined,\n      ref: (el: HTMLLIElement | null) => {\n        itemRefs.current[index] = el;\n      },\n      onPointerMove: () => {\n        if (items[index]?.disabled) return;\n        viaKey.current = false;\n        setActiveIndex(index);\n      },\n      onClick: () => select(index),\n    }),\n    [itemId, items, selectedIndex, select],\n  );\n\n  return {\n    open,\n    openMenu,\n    close,\n    select,\n    activeIndex,\n    selectedIndex,\n    selectedItem: selectedIndex >= 0 ? items[selectedIndex] : null,\n    itemId,\n    rootRef,\n    triggerProps,\n    listProps,\n    getItemProps,\n  };\n}\n\nexport type DropdownProps = {\n  items: DropdownItem[];\n  value?: string;\n  defaultValue?: string;\n  onChange?: (value: string) => void;\n  label?: string;\n  placeholder?: string;\n  disabled?: boolean;\n  emptyLabel?: string;\n  className?: string;\n};\n\nexport function Dropdown({\n  items,\n  value,\n  defaultValue,\n  onChange,\n  label = \"Options\",\n  placeholder = \"Select an option\",\n  disabled = false,\n  emptyLabel = \"Nothing to choose\",\n  className = \"\",\n}: DropdownProps) {\n  const reduced = useReducedMotion();\n  const {\n    open,\n    activeIndex,\n    selectedIndex,\n    selectedItem,\n    rootRef,\n    triggerProps,\n    listProps,\n    getItemProps,\n  } = useDropdown({ items, value, defaultValue, onChange, disabled });\n\n  const cell = reduced ? NONE : CELL;\n\n  return (\n    <div ref={rootRef} className={`relative inline-block text-left ${className}`}>\n      <button\n        {...triggerProps}\n        className={`flex h-9 select-none items-center gap-2 whitespace-nowrap rounded-[9px] border border-stone-200 bg-white px-3 text-[13px] font-medium text-stone-700 outline-none transition-[box-shadow,border-color] duration-150 disabled:opacity-50 dark:border-white/[0.16] dark:bg-[#1D1D1A] dark:text-stone-200 ${\n          open\n            ? \"shadow-[inset_0_1px_2px_rgba(28,25,23,0.09)] dark:shadow-[inset_0_1px_2px_rgba(0,0,0,0.5)]\"\n            : \"shadow-[0_1px_2px_rgba(28,25,23,0.06),0_4px_10px_-8px_rgba(28,25,23,0.45)] hover:border-stone-300 hover:shadow-[0_1px_2px_rgba(28,25,23,0.06),0_8px_18px_-12px_rgba(28,25,23,0.5)] focus-visible:border-stone-400 focus-visible:shadow-[0_1px_2px_rgba(28,25,23,0.08),0_10px_22px_-12px_rgba(28,25,23,0.55)] dark:shadow-[0_1px_6px_rgba(0,0,0,0.45)] dark:hover:border-white/20 dark:hover:shadow-[0_2px_10px_rgba(0,0,0,0.55)] dark:focus-visible:border-white/30 dark:focus-visible:shadow-[0_2px_12px_rgba(0,0,0,0.6)]\"\n        }`}\n      >\n        <span className=\"sr-only\">\n          {label}: {selectedItem ? selectedItem.label : placeholder}\n        </span>\n        <span aria-hidden>{label}</span>\n        <motion.svg\n          aria-hidden\n          viewBox=\"0 0 12 12\"\n          className=\"size-3 shrink-0 text-stone-500 dark:text-stone-400\"\n          initial={false}\n          animate={{ rotate: open ? 180 : 0 }}\n          transition={reduced ? NONE : NUDGE}\n        >\n          <path\n            d=\"M3 4.75 6 7.75 9 4.75\"\n            fill=\"none\"\n            stroke=\"currentColor\"\n            strokeWidth=\"1.4\"\n            strokeLinecap=\"round\"\n            strokeLinejoin=\"round\"\n          />\n        </motion.svg>\n      </button>\n      <AnimatePresence>\n        {open && (\n          <motion.div\n            initial={reduced ? { opacity: 0 } : { opacity: 0, scale: 0.94, y: -8 }}\n            animate={{ opacity: 1, scale: 1, y: 0 }}\n            exit={{\n              opacity: 0,\n              scale: 0.97,\n              y: -6,\n              transition: reduced ? NONE : { duration: 0.12, ease: EXIT },\n            }}\n            transition={\n              reduced\n                ? NONE\n                : { ...OPEN, opacity: { duration: 0.12, ease: EASE } }\n            }\n            style={{ transformOrigin: \"top left\" }}\n            className=\"absolute left-0 top-[calc(100%+6px)] z-50 min-w-[224px] whitespace-nowrap rounded-[11px] border border-stone-200 bg-white p-[5px] shadow-[0_1px_2px_rgba(28,25,23,0.06),0_16px_36px_-18px_rgba(28,25,23,0.5)] dark:border-white/[0.16] dark:bg-[#1D1D1A] dark:shadow-[0_2px_12px_rgba(0,0,0,0.6)]\"\n          >\n            <ul\n              {...listProps}\n              aria-label={label}\n              className=\"relative max-h-[216px] overflow-y-auto outline-none [scrollbar-gutter:stable]\"\n            >\n              <motion.span\n                aria-hidden\n                className=\"pointer-events-none absolute inset-x-0 top-0 h-8 rounded-[7px] bg-stone-100 dark:bg-white/10\"\n                initial={false}\n                animate={{\n                  y: activeIndex < 0 ? 0 : activeIndex * ROW_H,\n                  opacity: activeIndex < 0 ? 0 : 1,\n                }}\n                transition={\n                  reduced\n                    ? NONE\n                    : { ...SLIDE, opacity: { duration: 0.1, ease: EASE } }\n                }\n              />\n              {items.map((item, i) => {\n                const active = i === activeIndex && !item.disabled;\n                const picked = i === selectedIndex;\n                return (\n                  <li\n                    key={item.value}\n                    {...getItemProps(i)}\n                    className={`relative flex h-8 cursor-default select-none items-center rounded-[7px] px-2.5 text-[13px] ${\n                      item.disabled\n                        ? \"text-stone-500/70 dark:text-stone-400/70\"\n                        : active\n                          ? \"text-stone-900 dark:text-stone-100\"\n                          : \"text-stone-700 dark:text-stone-200\"\n                    }`}\n                  >\n                    <span className=\"relative flex min-w-0 flex-1 items-center gap-3\">\n                      <span className=\"truncate\">{item.label}</span>\n                      {item.hint ? (\n                        <span className=\"ml-auto shrink-0 font-mono text-[10.5px] text-stone-500 dark:text-stone-400\">\n                          {item.hint}\n                        </span>\n                      ) : null}\n                    </span>\n                    <motion.span\n                      aria-hidden\n                      initial={false}\n                      animate={{ opacity: picked ? 1 : 0, scale: picked ? 1 : 0.7 }}\n                      transition={cell}\n                      className=\"relative ml-2 flex size-[14px] shrink-0 items-center justify-center\"\n                    >\n                      <svg viewBox=\"0 0 14 14\" className=\"size-[14px]\">\n                        <path\n                          d=\"M3 7.4 5.8 10.2 11 4.4\"\n                          fill=\"none\"\n                          stroke=\"currentColor\"\n                          strokeWidth=\"1.5\"\n                          strokeLinecap=\"round\"\n                          strokeLinejoin=\"round\"\n                        />\n                      </svg>\n                    </motion.span>\n                  </li>\n                );\n              })}\n\n              {items.length === 0 && (\n                <li\n                  role=\"presentation\"\n                  className=\"flex h-8 items-center px-2.5 text-[13px] text-stone-500 dark:text-stone-400\"\n                >\n                  {emptyLabel}\n                </li>\n              )}\n            </ul>\n          </motion.div>\n        )}\n      </AnimatePresence>\n    </div>\n  );\n}\n"
    }
  ]
}
