{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"dropdown-menu","type":"registry:component","title":"Dropdown menu","description":"Grows out of its trigger, and press, drag, release picks an item like a native menu.","author":"Yash Bavadiya <https://xevrion.dev>","dependencies":["motion"],"registryDependencies":["utils"],"files":[{"path":"components/dropdown-menu.tsx","type":"registry:component","content":"\"use client\";\n\nimport { useEffect, useId, useRef, useState } from \"react\";\nimport { AnimatePresence, motion, useIsPresent } from \"motion/react\";\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\nimport { cn } from \"@/lib/utils\";\n\nexport type DropdownItem =\n  | {\n      type?: \"item\";\n      label: string;\n      icon?: React.ReactNode;\n      shortcut?: string;\n      destructive?: boolean;\n    }\n  | { type: \"separator\" };\n\nconst EASE_OUT = [0.23, 1, 0.32, 1] as const;\n// Enters over 180ms; leaves in 100ms with a smaller scale change, so closing\n// never holds the eye.\nconst ENTER = { duration: 0.18, ease: EASE_OUT };\nconst EXIT = { duration: 0.1, ease: EASE_OUT };\n// Letters typed within this window extend the search instead of restarting.\nconst TYPEAHEAD_RESET = 500;\n\ntype FocusTarget = \"first\" | \"last\" | \"menu\";\n\nexport function DropdownMenu({\n  label,\n  items,\n  align = \"start\",\n  onSelect,\n  className,\n}: {\n  label: string;\n  items: DropdownItem[];\n  align?: \"start\" | \"end\";\n  onSelect?: (label: string) => void;\n  className?: string;\n}) {\n  const reduceMotion = useReducedMotion();\n  const id = useId();\n  const triggerId = `${id}-trigger`;\n  const menuId = `${id}-menu`;\n\n  const [open, setOpen] = useState(false);\n  const [active, setActive] = useState(-1);\n\n  const rootRef = useRef<HTMLDivElement>(null);\n  const triggerRef = useRef<HTMLButtonElement>(null);\n  const menuRef = useRef<HTMLDivElement>(null);\n  const itemRefs = useRef<(HTMLDivElement | null)[]>([]);\n  const focusTarget = useRef<FocusTarget>(\"menu\");\n  const lastPointerType = useRef(\"\");\n  // True while the press that opened the menu is still held, so releasing\n  // it over an item selects that item, like a native menu.\n  const pressFromTrigger = useRef(false);\n  const typeahead = useRef({ query: \"\", timer: 0 });\n\n  const actionable = items.flatMap((item, i) =>\n    item.type === \"separator\" ? [] : [i],\n  );\n\n  const focusItem = (index: number) => {\n    itemRefs.current[index]?.focus({ preventScroll: true });\n  };\n\n  useEffect(() => {\n    if (!open) return;\n    const target = focusTarget.current;\n    if (target === \"first\") focusItem(actionable[0]);\n    else if (target === \"last\") focusItem(actionable[actionable.length - 1]);\n    else menuRef.current?.focus({ preventScroll: true });\n    // Only when the menu opens; the item list is static while it is open.\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [open]);\n\n  useEffect(() => {\n    const state = typeahead.current;\n    return () => clearTimeout(state.timer);\n  }, []);\n\n  const openMenu = (target: FocusTarget) => {\n    focusTarget.current = target;\n    setActive(-1);\n    setOpen(true);\n  };\n\n  const close = (returnFocus: boolean) => {\n    setOpen(false);\n    if (returnFocus) triggerRef.current?.focus({ preventScroll: true });\n  };\n\n  const select = (index: number) => {\n    const item = items[index];\n    if (!item || item.type === \"separator\") return;\n    onSelect?.(item.label);\n    close(true);\n  };\n\n  const move = (step: number) => {\n    const at = actionable.indexOf(active);\n    const from = at === -1 ? (step > 0 ? -1 : 0) : at;\n    const next = (from + step + actionable.length) % actionable.length;\n    focusItem(actionable[next]);\n  };\n\n  const search = (char: string) => {\n    const state = typeahead.current;\n    clearTimeout(state.timer);\n    state.query += char.toLowerCase();\n    state.timer = window.setTimeout(() => {\n      state.query = \"\";\n    }, TYPEAHEAD_RESET);\n\n    // Pressing the same letter repeatedly cycles through its matches.\n    const repeated = [...state.query].every((c) => c === state.query[0]);\n    const query = repeated ? state.query[0] : state.query;\n    const at = actionable.indexOf(active);\n    const start = repeated ? at + 1 : Math.max(at, 0);\n    for (let n = 0; n < actionable.length; n++) {\n      const index = actionable[(start + n) % actionable.length];\n      const item = items[index];\n      if (\n        item.type !== \"separator\" &&\n        item.label.toLowerCase().startsWith(query)\n      ) {\n        focusItem(index);\n        return;\n      }\n    }\n  };\n\n  const onMenuKeyDown = (e: React.KeyboardEvent) => {\n    switch (e.key) {\n      case \"ArrowDown\":\n        e.preventDefault();\n        move(1);\n        return;\n      case \"ArrowUp\":\n        e.preventDefault();\n        move(-1);\n        return;\n      case \"Home\":\n        e.preventDefault();\n        focusItem(actionable[0]);\n        return;\n      case \"End\":\n        e.preventDefault();\n        focusItem(actionable[actionable.length - 1]);\n        return;\n      case \"Enter\":\n      case \" \":\n        e.preventDefault();\n        if (active !== -1) select(active);\n        return;\n      case \"Escape\":\n        e.preventDefault();\n        close(true);\n        return;\n      case \"Tab\":\n        // Focus moves on to the next control as usual; the menu just gets\n        // out of the way.\n        close(false);\n        return;\n    }\n    if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {\n      search(e.key);\n    }\n  };\n\n  // Both states pass the same keys, so Motion never keeps a stale transform.\n  const hidden = { opacity: 0, transform: reduceMotion ? \"scale(1)\" : \"scale(0.95)\" };\n  const leaving = {\n    opacity: 0,\n    transform: reduceMotion ? \"scale(1)\" : \"scale(0.97)\",\n    transition: EXIT,\n  };\n\n  return (\n    <div ref={rootRef} className={cn(\"relative inline-flex\", className)}>\n      <button\n        ref={triggerRef}\n        id={triggerId}\n        type=\"button\"\n        aria-haspopup=\"menu\"\n        aria-expanded={open}\n        aria-controls={open ? menuId : undefined}\n        onPointerDown={(e) => {\n          lastPointerType.current = e.pointerType;\n          // Touch opens on click instead, so a scroll that starts on the\n          // trigger doesn't pop the menu.\n          if (e.pointerType !== \"mouse\" || e.button !== 0 || e.ctrlKey) return;\n          if (open) {\n            close(false);\n            return;\n          }\n          // Keeps focus off the trigger; the menu takes it on open.\n          e.preventDefault();\n          pressFromTrigger.current = true;\n          const release = () => {\n            pressFromTrigger.current = false;\n          };\n          // Window listeners run after React's, so an item's pointerup still\n          // sees the flag set.\n          window.addEventListener(\"pointerup\", release, { once: true });\n          window.addEventListener(\"pointercancel\", release, { once: true });\n          openMenu(\"menu\");\n        }}\n        onClick={() => {\n          const pointer = lastPointerType.current;\n          lastPointerType.current = \"\";\n          // Mouse presses were handled on pointerdown. This covers touch and\n          // assistive tech, which can click without any keydown.\n          if (pointer === \"mouse\") return;\n          if (open) close(false);\n          else openMenu(pointer ? \"menu\" : \"first\");\n        }}\n        onKeyDown={(e) => {\n          if (e.key === \"Enter\" || e.key === \" \" || e.key === \"ArrowDown\") {\n            e.preventDefault();\n            openMenu(\"first\");\n          } else if (e.key === \"ArrowUp\") {\n            e.preventDefault();\n            openMenu(\"last\");\n          }\n        }}\n        className=\"flex h-9 touch-manipulation items-center gap-1.5 rounded-lg bg-muted pr-2.5 pl-3.5 text-sm font-medium text-foreground shadow-raised outline-hidden transition-[scale] duration-150 ease-out select-none focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-2 focus-visible:outline-foreground active:scale-[0.96] motion-reduce:transition-none\"\n      >\n        {label}\n        <svg\n          viewBox=\"0 0 16 16\"\n          aria-hidden\n          className={cn(\n            \"size-4 text-muted-foreground transition-[rotate] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:transition-none\",\n            open && \"rotate-180\",\n          )}\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeWidth={1.5}\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n        >\n          <path d=\"m4.5 6.5 3.5 3.5 3.5-3.5\" />\n        </svg>\n      </button>\n\n      <AnimatePresence>\n        {open && (\n          <Panel\n            ref={menuRef}\n            id={menuId}\n            labelledBy={triggerId}\n            align={align}\n            initial={hidden}\n            exit={leaving}\n            onKeyDown={onMenuKeyDown}\n            onPointerLeave={(e) => {\n              if (e.pointerType === \"touch\" || active === -1) return;\n              // Parks focus on the menu so the highlight clears but arrow\n              // keys keep working.\n              menuRef.current?.focus({ preventScroll: true });\n              setActive(-1);\n            }}\n            onBlur={(e) => {\n              // Covers clicking outside, tabbing away and leaving the window.\n              if (!rootRef.current?.contains(e.relatedTarget as Node | null)) {\n                close(false);\n              }\n            }}\n          >\n            {items.map((item, i) => {\n              if (item.type === \"separator\") {\n                return (\n                  <div\n                    key={`separator-${i}`}\n                    role=\"separator\"\n                    className=\"-mx-1 my-1 h-px bg-border\"\n                  />\n                );\n              }\n              const highlighted = active === i;\n              return (\n                <div\n                  key={item.label}\n                  ref={(el) => {\n                    itemRefs.current[i] = el;\n                  }}\n                  role=\"menuitem\"\n                  tabIndex={-1}\n                  onFocus={() => setActive(i)}\n                  onPointerMove={(e) => {\n                    if (e.pointerType === \"touch\" || highlighted) return;\n                    focusItem(i);\n                  }}\n                  onPointerUp={() => {\n                    if (pressFromTrigger.current) select(i);\n                  }}\n                  onClick={() => select(i)}\n                  // No transition on the highlight: it moves on every hover,\n                  // so any easing reads as lag.\n                  className={cn(\n                    \"flex h-8 cursor-default items-center gap-2.5 rounded-lg px-2 text-sm outline-hidden select-none\",\n                    item.destructive ? \"text-destructive\" : \"text-foreground\",\n                    highlighted &&\n                      (item.destructive ? \"bg-destructive/10\" : \"bg-foreground/[0.06]\"),\n                  )}\n                >\n                  {item.icon && (\n                    <svg\n                      viewBox=\"0 0 16 16\"\n                      aria-hidden\n                      className={cn(\n                        \"size-4 shrink-0\",\n                        !item.destructive && !highlighted && \"text-muted-foreground\",\n                      )}\n                      fill=\"none\"\n                      stroke=\"currentColor\"\n                      strokeWidth={1.5}\n                      strokeLinecap=\"round\"\n                      strokeLinejoin=\"round\"\n                    >\n                      {item.icon}\n                    </svg>\n                  )}\n                  <span className=\"flex-1 truncate\">{item.label}</span>\n                  {item.shortcut && (\n                    <kbd\n                      className={cn(\n                        \"font-sans text-xs tracking-wide\",\n                        item.destructive ? \"text-destructive/70\" : \"text-muted-foreground\",\n                      )}\n                    >\n                      {item.shortcut}\n                    </kbd>\n                  )}\n                </div>\n              );\n            })}\n          </Panel>\n        )}\n      </AnimatePresence>\n    </div>\n  );\n}\n\nfunction Panel({\n  ref,\n  id,\n  labelledBy,\n  align,\n  initial,\n  exit,\n  onKeyDown,\n  onPointerLeave,\n  onBlur,\n  children,\n}: {\n  ref: React.Ref<HTMLDivElement>;\n  id: string;\n  labelledBy: string;\n  align: \"start\" | \"end\";\n  initial: { opacity: number; transform: string };\n  exit: { opacity: number; transform: string; transition: typeof EXIT };\n  onKeyDown: (e: React.KeyboardEvent) => void;\n  onPointerLeave: (e: React.PointerEvent) => void;\n  onBlur: (e: React.FocusEvent) => void;\n  children: React.ReactNode;\n}) {\n  // A closing menu stops taking pointer input at once, so it never blocks\n  // the next click while it fades.\n  const isPresent = useIsPresent();\n  return (\n    <motion.div\n      ref={ref}\n      id={id}\n      role=\"menu\"\n      aria-labelledby={labelledBy}\n      tabIndex={-1}\n      initial={initial}\n      animate={{ opacity: 1, transform: \"scale(1)\" }}\n      exit={exit}\n      transition={ENTER}\n      onKeyDown={onKeyDown}\n      onPointerLeave={onPointerLeave}\n      onBlur={onBlur}\n      // Grows out of the trigger's corner, not its own center.\n      style={{ transformOrigin: align === \"end\" ? \"top right\" : \"top left\" }}\n      // 12px radius around 4px padding keeps the 8px items concentric.\n      className={cn(\n        \"absolute top-full z-50 mt-1.5 w-52 rounded-xl bg-muted p-1 shadow-raised outline-hidden\",\n        align === \"end\" ? \"right-0\" : \"left-0\",\n        !isPresent && \"pointer-events-none\",\n      )}\n    >\n      {children}\n    </motion.div>\n  );\n}\n\nconst DEMO_ITEMS: DropdownItem[] = [\n  {\n    label: \"Edit\",\n    icon: <path d=\"M10.25 3.25 12.75 5.75 6 12.5H3.5V10l6.75-6.75Z\" />,\n  },\n  {\n    label: \"Duplicate\",\n    shortcut: \"⌘D\",\n    icon: (\n      <>\n        <rect x=\"5.25\" y=\"5.25\" width=\"8\" height=\"8\" rx=\"1.75\" />\n        <path d=\"M10.75 5.25V4.5a1.75 1.75 0 0 0-1.75-1.75H4.5A1.75 1.75 0 0 0 2.75 4.5V9a1.75 1.75 0 0 0 1.75 1.75h.75\" />\n      </>\n    ),\n  },\n  {\n    label: \"Rename\",\n    icon: (\n      <path d=\"M2.75 5.75h6M2.75 10.25h4M11.75 3v10M10.25 3h3M10.25 13h3\" />\n    ),\n  },\n  {\n    label: \"Share\",\n    icon: (\n      <path d=\"M8 2.75v7M5.25 5.5 8 2.75l2.75 2.75M3.75 8.75v3a1.5 1.5 0 0 0 1.5 1.5h5.5a1.5 1.5 0 0 0 1.5-1.5v-3\" />\n    ),\n  },\n  {\n    label: \"Archive\",\n    icon: (\n      <path d=\"M2.75 3.25h10.5v2.5H2.75zM3.75 5.75v6a1.5 1.5 0 0 0 1.5 1.5h5.5a1.5 1.5 0 0 0 1.5-1.5v-6M6.5 8.25h3\" />\n    ),\n  },\n  { type: \"separator\" },\n  {\n    label: \"Delete\",\n    shortcut: \"⌘⌫\",\n    destructive: true,\n    icon: (\n      <path d=\"M2.75 4.25h10.5M6.25 4.25v-1.5h3.5v1.5M4 4.25l.6 8.1a1 1 0 0 0 1 .9h4.8a1 1 0 0 0 1-.9l.6-8.1M6.75 7v3.75M9.25 7v3.75\" />\n    ),\n  },\n];\n\nexport default function DropdownMenuDemo() {\n  const [selected, setSelected] = useState<string | null>(null);\n  // Bumped on every pick, so choosing the same item again still replays\n  // the confirmation.\n  const [pick, setPick] = useState(0);\n\n  return (\n    <div className=\"flex flex-col items-center gap-3\">\n      <DropdownMenu\n        label=\"Options\"\n        items={DEMO_ITEMS}\n        onSelect={(label) => {\n          setSelected(label);\n          setPick((n) => n + 1);\n        }}\n      />\n      {/* Height reserved up front, so the confirmation never shifts the\n          trigger above it. */}\n      <p className=\"h-5 text-sm text-muted-foreground\" aria-live=\"polite\">\n        {selected && (\n          <motion.span\n            key={pick}\n            className=\"block\"\n            initial={{\n              opacity: 0,\n              filter: \"blur(4px)\",\n              transform: \"translateY(2px)\",\n            }}\n            animate={{\n              opacity: 1,\n              filter: \"blur(0px)\",\n              transform: \"translateY(0px)\",\n            }}\n            transition={{ duration: 0.2, ease: EASE_OUT }}\n          >\n            Selected: <span className=\"text-foreground\">{selected}</span>\n          </motion.span>\n        )}\n      </p>\n    </div>\n  );\n}\n"},{"path":"hooks/use-reduced-motion.ts","type":"registry:hook","target":"hooks/use-reduced-motion.ts","content":"\"use client\";\n\nimport { useSyncExternalStore } from \"react\";\n\n// Motion's useReducedMotion reads the media query on the very first client\n// render, but the server can't know it, so every component that renders\n// differently under reduced motion broke hydration for those users. This\n// reports false during hydration, matching the server HTML, then the real\n// preference straight after, and follows it if it changes.\nconst query = \"(prefers-reduced-motion: reduce)\";\n\nfunction subscribe(onChange: () => void) {\n  const media = window.matchMedia(query);\n  media.addEventListener(\"change\", onChange);\n  return () => media.removeEventListener(\"change\", onChange);\n}\n\nexport function useReducedMotion() {\n  return useSyncExternalStore(\n    subscribe,\n    () => window.matchMedia(query).matches,\n    () => false,\n  );\n}\n"}],"cssVars":{"light":{"shadow-raised":"0 0 0 1px oklch(0 0 0 / 0.06), 0 1px 2px oklch(0 0 0 / 0.06), 0 6px 16px -6px oklch(0 0 0 / 0.12)"},"dark":{"shadow-raised":"0 0 0 1px oklch(1 0 0 / 0.08), 0 1px 2px oklch(0 0 0 / 0.4), 0 6px 16px -6px oklch(0 0 0 / 0.6)"},"theme":{"shadow-raised":"var(--shadow-raised)"}},"categories":["navigation"],"docs":"From ui lab: https://lab.xevrion.dev/lab/dropdown-menu"}