{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "select-motion",
  "title": "Motion select",
  "description": "Styled select: the Motion set. 5 decorative select variations (SlideSelect, PopSelect, FadeSelect, SpringSelect, MorphSelect) on the ai2 token system, powered by framer-motion, reduced-motion aware and sized sm to xl. Part of the free styled layer.",
  "dependencies": [
    "motion@^12.42.2",
    "lucide-react@^1.23.0"
  ],
  "registryDependencies": [
    "@ai2/tokens"
  ],
  "files": [
    {
      "path": "registry/ai2/styled/select-motion.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { Check, ChevronDown } from \"lucide-react\"\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\"\n\nimport { cn } from \"@/lib/utils\"\n\n/* Motion select family: 5 self-contained single-selects. They all share the same\n   listbox anatomy; the only difference is the option panel's ENTER/EXIT motion\n   (slide, pop, fade, spring, morph). NO radix, NO native <select>, NO portal.\n   The panel pins to its own trigger (a relative wrapper + an absolute panel), so 25\n   examples side by side on a docs page are all positioned correctly. Keyboard:\n   ArrowUp/Down moves the highlight, Enter selects, Escape closes and returns focus\n   to the trigger. Color comes ONLY from tokens, via alpha color-mix. */\n\nexport type StyledSize = \"sm\" | \"md\" | \"lg\" | \"xl\"\n\nconst triggerHeight: Record<StyledSize, string> = {\n  sm: \"h-8 text-sm\",\n  md: \"h-9 text-sm\",\n  lg: \"h-10 text-base\",\n  xl: \"h-12 text-base\",\n}\n\nconst panelWidth: Record<StyledSize, string> = {\n  sm: \"w-48\",\n  md: \"w-56\",\n  lg: \"w-64\",\n  xl: \"w-72\",\n}\n\nexport interface StyledSelectOption {\n  value: string\n  label: string\n}\n\nexport interface StyledSelectProps {\n  className?: string\n  size?: StyledSize\n  placeholder?: string\n  options?: StyledSelectOption[]\n  value?: string\n  defaultValue?: string\n  onValueChange?: (v: string) => void\n}\n\nconst defaultOptions: StyledSelectOption[] = [\n  { value: \"light\", label: \"Light\" },\n  { value: \"dark\", label: \"Dark\" },\n  { value: \"system\", label: \"System\" },\n  { value: \"auto\", label: \"Automatic\" },\n]\n\nconst triggerBase =\n  \"inline-flex w-full shrink-0 select-none items-center justify-between gap-2 whitespace-nowrap rounded-lg border border-field-border bg-background px-3 font-medium text-foreground outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 [&_svg]:size-4 [&_svg]:shrink-0 [&_i]:text-base [&_i]:leading-none\"\n\nconst panelBase =\n  \"absolute left-0 top-full z-50 mt-1 max-h-72 min-w-48 overflow-auto rounded-xl border border-border bg-popover p-1.5 text-sm text-popover-foreground shadow-lg outline-none\"\n\nconst optionBase =\n  \"flex w-full cursor-default select-none items-center gap-2.5 rounded-md px-3 py-2 text-left text-sm text-popover-foreground outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-selected:bg-accent aria-selected:text-accent-foreground [&_svg]:size-4 [&_svg]:shrink-0 [&_i]:text-base [&_i]:leading-none\"\n\ninterface PanelMotion {\n  initial: Record<string, number>\n  animate: Record<string, number>\n  exit: Record<string, number>\n  transition: Record<string, unknown>\n  origin: string\n}\n\n/* Open state plus selected value plus highlight index. The state lives in the ROOT component, because the panel unmounts when it closes. */\nfunction useSelectState(\n  list: StyledSelectOption[],\n  value: string | undefined,\n  defaultValue: string | undefined,\n  onValueChange: ((v: string) => void) | undefined,\n) {\n  const [open, setOpen] = React.useState(false)\n  const [active, setActive] = React.useState(0)\n  const [internal, setInternal] = React.useState<string | undefined>(defaultValue)\n  const wrapperRef = React.useRef<HTMLDivElement>(null)\n  const triggerRef = React.useRef<HTMLButtonElement>(null)\n\n  const isControlled = value !== undefined\n  const current = isControlled ? value : internal\n\n  const close = React.useCallback(() => {\n    setOpen(false)\n    triggerRef.current?.focus()\n  }, [])\n\n  const select = React.useCallback(\n    (next: string) => {\n      if (!isControlled) setInternal(next)\n      onValueChange?.(next)\n      setOpen(false)\n      triggerRef.current?.focus()\n    },\n    [isControlled, onValueChange],\n  )\n\n  React.useEffect(() => {\n    if (!open) return\n    const onPointer = (e: PointerEvent) => {\n      const node = wrapperRef.current\n      if (node && e.target instanceof Node && !node.contains(e.target)) setOpen(false)\n    }\n    window.addEventListener(\"pointerdown\", onPointer)\n    return () => window.removeEventListener(\"pointerdown\", onPointer)\n  }, [open])\n\n  const indexOfCurrent = React.useCallback(() => {\n    const i = list.findIndex((o) => o.value === current)\n    return i < 0 ? 0 : i\n  }, [list, current])\n\n  const onKeyDown = React.useCallback(\n    (e: React.KeyboardEvent) => {\n      if (e.key === \"Escape\") {\n        if (!open) return\n        e.preventDefault()\n        close()\n        return\n      }\n      if (e.key === \"ArrowDown\" || e.key === \"ArrowUp\") {\n        e.preventDefault()\n        if (!open) {\n          setOpen(true)\n          setActive(indexOfCurrent())\n          return\n        }\n        const dir = e.key === \"ArrowDown\" ? 1 : -1\n        setActive((i) => (i + dir + list.length) % list.length)\n        return\n      }\n      if (e.key === \"Enter\" || e.key === \" \") {\n        e.preventDefault()\n        if (!open) {\n          setOpen(true)\n          setActive(indexOfCurrent())\n          return\n        }\n        const opt = list[active]\n        if (opt) select(opt.value)\n      }\n    },\n    [open, close, indexOfCurrent, list, active, select],\n  )\n\n  return { open, setOpen, active, setActive, current, select, close, onKeyDown, wrapperRef, triggerRef }\n}\n\nfunction BaseMotionSelect({\n  className,\n  size = \"md\",\n  placeholder = \"Select...\",\n  options,\n  value,\n  defaultValue,\n  onValueChange,\n  panelMotion,\n}: StyledSelectProps & { panelMotion: PanelMotion }) {\n  const list = options ?? defaultOptions\n  const reduce = useReducedMotion()\n  const uid = React.useId()\n  const listId = `${uid}-listbox`\n  const optionId = (i: number) => `${uid}-option-${i}`\n\n  const { open, setOpen, active, setActive, current, select, onKeyDown, wrapperRef, triggerRef } =\n    useSelectState(list, value, defaultValue, onValueChange)\n\n  const selectedLabel = list.find((o) => o.value === current)?.label\n  const fade = {\n    initial: { opacity: 0 },\n    animate: { opacity: 1 },\n    exit: { opacity: 0 },\n  }\n  const anim = reduce ? fade : panelMotion\n\n  return (\n    <div\n      ref={wrapperRef}\n      data-slot=\"styled-select\"\n      className={cn(\"relative inline-flex w-56 max-w-full\", panelWidth[size], className)}\n    >\n      <button\n        ref={triggerRef}\n        type=\"button\"\n        data-slot=\"styled-select-trigger\"\n        role=\"combobox\"\n        aria-expanded={open}\n        aria-haspopup=\"listbox\"\n        aria-controls={listId}\n        aria-activedescendant={open ? optionId(active) : undefined}\n        className={cn(triggerBase, triggerHeight[size])}\n        onClick={() => {\n          setActive(Math.max(0, list.findIndex((o) => o.value === current)))\n          setOpen(!open)\n        }}\n        onKeyDown={onKeyDown}\n      >\n        <span className={cn(\"truncate\", selectedLabel === undefined && \"text-muted-foreground\")}>\n          {selectedLabel ?? placeholder}\n        </span>\n        <ChevronDown\n          className={cn(\"shrink-0 opacity-70 transition-transform duration-200\", open && \"rotate-180\")}\n        />\n      </button>\n\n      <AnimatePresence>\n        {open ? (\n          <motion.div\n            id={listId}\n            role=\"listbox\"\n            data-slot=\"styled-select-content\"\n            className={cn(panelBase, panelWidth[size], !reduce && panelMotion.origin)}\n            initial={anim.initial}\n            animate={anim.animate}\n            exit={anim.exit}\n            transition={reduce ? { duration: 0.14 } : panelMotion.transition}\n          >\n            <div className=\"flex flex-col\">\n              {list.map((option, i) => {\n                const isSelected = option.value === current\n                return (\n                  <button\n                    key={option.value}\n                    id={optionId(i)}\n                    type=\"button\"\n                    role=\"option\"\n                    tabIndex={-1}\n                    aria-selected={isSelected}\n                    data-active={i === active ? \"\" : undefined}\n                    className={cn(\n                      optionBase,\n                      i === active && \"bg-accent text-accent-foreground\",\n                    )}\n                    onPointerEnter={() => setActive(i)}\n                    onClick={() => select(option.value)}\n                  >\n                    <span className=\"flex size-4 shrink-0 items-center justify-center text-primary\">\n                      {isSelected ? <Check /> : null}\n                    </span>\n                    <span className=\"truncate\">{option.label}</span>\n                  </button>\n                )\n              })}\n            </div>\n          </motion.div>\n        ) : null}\n      </AnimatePresence>\n    </div>\n  )\n}\n\n/* Slide: the panel slides downwards from under the trigger (tween). */\nexport function SlideSelect(props: StyledSelectProps) {\n  return (\n    <BaseMotionSelect\n      {...props}\n      panelMotion={{\n        origin: \"origin-top\",\n        initial: { opacity: 0, y: -10 },\n        animate: { opacity: 1, y: 0 },\n        exit: { opacity: 0, y: -10 },\n        transition: { duration: 0.18, ease: \"easeOut\" },\n      }}\n    />\n  )\n}\n\n/* Pop: it opens by bursting from small (a stiff spring, origin-top). */\nexport function PopSelect(props: StyledSelectProps) {\n  return (\n    <BaseMotionSelect\n      {...props}\n      panelMotion={{\n        origin: \"origin-top\",\n        initial: { opacity: 0, scale: 0.85 },\n        animate: { opacity: 1, scale: 1 },\n        exit: { opacity: 0, scale: 0.85 },\n        transition: { type: \"spring\" as const, stiffness: 520, damping: 24 },\n      }}\n    />\n  )\n}\n\n/* Fade: no movement, opacity only. */\nexport function FadeSelect(props: StyledSelectProps) {\n  return (\n    <BaseMotionSelect\n      {...props}\n      panelMotion={{\n        origin: \"origin-top\",\n        initial: { opacity: 0 },\n        animate: { opacity: 1 },\n        exit: { opacity: 0 },\n        transition: { duration: 0.2, ease: \"easeOut\" },\n      }}\n    />\n  )\n}\n\n/* Spring: dusuk damping ile yayli, hafif zipla. */\nexport function SpringSelect(props: StyledSelectProps) {\n  return (\n    <BaseMotionSelect\n      {...props}\n      panelMotion={{\n        origin: \"origin-top\",\n        initial: { opacity: 0, y: -14, scale: 0.96 },\n        animate: { opacity: 1, y: 0, scale: 1 },\n        exit: { opacity: 0, y: -14, scale: 0.96 },\n        transition: { type: \"spring\" as const, stiffness: 320, damping: 14 },\n      }}\n    />\n  )\n}\n\n/* Morph: it opens out of a vertical squash (a scaleY morph, origin-top). */\nexport function MorphSelect(props: StyledSelectProps) {\n  return (\n    <BaseMotionSelect\n      {...props}\n      panelMotion={{\n        origin: \"origin-top\",\n        initial: { opacity: 0, scaleY: 0.55, scaleX: 0.94 },\n        animate: { opacity: 1, scaleY: 1, scaleX: 1 },\n        exit: { opacity: 0, scaleY: 0.55, scaleX: 0.94 },\n        transition: { duration: 0.24, ease: \"easeOut\" },\n      }}\n    />\n  )\n}\n",
      "type": "registry:component",
      "target": "components/ui/select-motion.tsx"
    }
  ],
  "categories": [
    "styled",
    "select"
  ],
  "type": "registry:component"
}