{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "dropdown-menu",
  "type": "registry:ui",
  "title": "Dropdown menu",
  "description": "A focused list of actions anchored to a trigger.",
  "categories": [
    "actions"
  ],
  "dependencies": [
    "@radix-ui/react-dropdown-menu",
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "https://mcp.xzero.co.uk/r/xzero-motion-tokens.json"
  ],
  "files": [
    {
      "path": "registry/components/dropdown-menu/dropdown-menu.tsx",
      "type": "registry:component",
      "target": "@components/xzero/dropdown-menu/dropdown-menu.tsx",
      "content": "\"use client\";\n\nimport { Fragment, useEffect, useLayoutEffect, useRef, useState } from \"react\";\nimport type { CSSProperties, FocusEvent, ReactNode } from \"react\";\nimport * as DropdownPrimitive from \"@radix-ui/react-dropdown-menu\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { ChevronDown } from \"lucide-react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport styles from \"./dropdown-menu.module.css\";\n\nexport interface DropdownItem { label: string; onSelect?: () => void; disabled?: boolean; icon?: ReactNode; destructive?: boolean; separatorBefore?: boolean; }\nexport interface DropdownMenuProps { label: string; items: DropdownItem[]; icon?: ReactNode; }\n\ntype Highlight = { top: number; height: number; danger: boolean; glide: boolean };\n\n/** A new trigger label rises in while the old one leaves, and the trigger width springs to the measured text instead of snapping. */\nfunction TriggerLabel({ text }: { text: string }) {\n  const reduced = useReducedMotion();\n  const measure = useRef<HTMLSpanElement>(null);\n  const measured = useRef<string | null>(null);\n  const [size, setSize] = useState<{ width: number | \"auto\"; animate: boolean }>({ width: \"auto\", animate: false });\n  useLayoutEffect(() => {\n    const node = measure.current;\n    if (!node) return;\n    const observer = new ResizeObserver(([entry]) => {\n      if (!entry) return;\n      const current = node.textContent;\n      // Only a text change morphs; the first measure and font swaps settle instantly.\n      const animate = measured.current !== null && measured.current !== current;\n      measured.current = current;\n      setSize({ width: Math.ceil(entry.borderBoxSize?.[0]?.inlineSize ?? node.offsetWidth), animate });\n    });\n    observer.observe(node);\n    return () => observer.disconnect();\n  }, []);\n  return <motion.span className={styles.label} initial={false} animate={{ width: size.width }} transition={size.animate && !reduced ? motionTokens.spring.morph : { duration: 0 }}>\n    <span ref={measure} className={styles.labelMeasure} aria-hidden=\"true\">{text}</span>\n    <AnimatePresence mode=\"popLayout\" initial={false}>\n      <motion.span key={text} className={styles.labelText} initial={reduced ? false : { opacity: 0, y: \"0.3em\", filter: `blur(${motionTokens.blur.soft}px)` }} animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }} exit={reduced ? { opacity: 0, transition: { duration: 0 } } : { opacity: 0, y: \"-0.3em\", filter: `blur(${motionTokens.blur.subtle}px)`, transition: { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.standard] } }} transition={{ duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter] }}>{text}</motion.span>\n    </AnimatePresence>\n  </motion.span>;\n}\n\nexport function DropdownMenu({ label, items, icon }: DropdownMenuProps) {\n  const reduced = useReducedMotion();\n  const [highlight, setHighlight] = useState<Highlight | null>(null);\n  const pointer = useRef(false);\n  const clearTimer = useRef(0);\n  useEffect(() => () => window.clearTimeout(clearTimer.current), []);\n  // Radix focuses the highlighted item (pointer or keyboard) and the content when the pointer leaves an item.\n  function onMenuFocus(event: FocusEvent<HTMLDivElement>) {\n    const item = event.target instanceof HTMLElement ? event.target.closest<HTMLElement>('[role=\"menuitem\"]') : null;\n    window.clearTimeout(clearTimer.current);\n    if (!item) {\n      // A short grace period keeps the highlight gliding across separators and item gaps.\n      clearTimer.current = window.setTimeout(() => setHighlight(null), pointer.current ? 70 : 0);\n      return;\n    }\n    const next = { top: item.offsetTop, height: item.offsetHeight, danger: item.dataset.tone === \"danger\" };\n    const glide = pointer.current;\n    setHighlight(current => ({ ...next, glide: glide && current !== null }));\n  }\n  return <DropdownPrimitive.Root onOpenChange={open => { if (open) { window.clearTimeout(clearTimer.current); setHighlight(null); } }}>\n    <DropdownPrimitive.Trigger className={styles.trigger} type=\"button\">{icon && <span className={styles.triggerIcon} aria-hidden=\"true\">{icon}</span>}<TriggerLabel text={label}/><ChevronDown className={styles.chevron} size={15} strokeWidth={1.8} aria-hidden=\"true\"/></DropdownPrimitive.Trigger>\n    <DropdownPrimitive.Portal><DropdownPrimitive.Content className={styles.menu} sideOffset={6} align=\"end\" collisionPadding={12} loop onFocus={onMenuFocus} onPointerMoveCapture={() => { pointer.current = true; }} onKeyDownCapture={() => { pointer.current = false; }}>\n      {/* One highlight glides between items for the pointer and jumps instantly for the keyboard. */}\n      <motion.span className={styles.highlight} data-tone={highlight?.danger ? \"danger\" : undefined} aria-hidden=\"true\" initial={false} animate={highlight ? { y: highlight.top, height: highlight.height, opacity: 1 } : { opacity: 0 }} transition={{ default: highlight?.glide && !reduced ? motionTokens.spring.snappy : { duration: 0 }, opacity: { duration: reduced ? 0 : .08 } }}/>\n      {items.map((item, index) => <Fragment key={item.label}>{item.separatorBefore && <DropdownPrimitive.Separator className={styles.separator}/>}<DropdownPrimitive.Item className={[styles.item, item.destructive ? styles.destructive : \"\"].filter(Boolean).join(\" \")} data-tone={item.destructive ? \"danger\" : undefined} style={{ \"--i\": index } as CSSProperties} disabled={item.disabled} onSelect={item.onSelect}>{item.icon && <span className={styles.icon} aria-hidden=\"true\">{item.icon}</span>}{item.label}</DropdownPrimitive.Item></Fragment>)}\n    </DropdownPrimitive.Content></DropdownPrimitive.Portal>\n  </DropdownPrimitive.Root>;\n}\n\nexport default DropdownMenu;\n"
    },
    {
      "path": "registry/components/dropdown-menu/dropdown-menu.module.css",
      "type": "registry:component",
      "target": "@components/xzero/dropdown-menu/dropdown-menu.module.css",
      "content": ".trigger { display: inline-flex; max-width: 100%; min-height: var(--control-height-sm); align-items: center; gap: var(--space-2); padding: 0 var(--space-3); border: 1px solid var(--border); border-radius: var(--radius-control); background: var(--surface); color: var(--foreground); font: inherit; font-size: var(--text-sm); font-weight: 500; cursor: pointer; transition: background-color var(--duration-instant) var(--ease-standard), border-color var(--duration-fast) var(--ease-standard), box-shadow var(--duration-fast) var(--ease-standard); }\n@media (hover: hover) and (pointer: fine) { .trigger:hover { border-color: var(--border-strong); background: var(--surface-muted); box-shadow: var(--shadow-resting); } }\n/* The trigger anchors the menu, so press feedback stays in color. A scaled rect would be measured on pointerdown and shift the menu. */\n.trigger[data-state=\"open\"] { border-color: var(--border-strong); background: var(--surface-muted); box-shadow: none; }\n.trigger:active { border-color: var(--border-strong); background: color-mix(in oklab, var(--surface-muted), var(--border) 55%); box-shadow: none; }\n.item:focus-visible { outline: none; }\n.triggerIcon { display: inline-flex; flex: none; color: var(--text-secondary); }\n.label { position: relative; display: inline-flex; min-width: 0; flex: 0 1 auto; overflow-x: clip; }\n.labelMeasure { position: absolute; top: 0; left: 0; visibility: hidden; white-space: nowrap; pointer-events: none; }\n.labelText { display: block; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }\n.chevron { flex: none; color: var(--text-muted); transition: transform var(--duration-spring) var(--ease-spring), color var(--duration-fast) var(--ease-standard); }\n.trigger[data-state=\"open\"] .chevron { transform: rotate(180deg); }\n/* The menu grows from the trigger edge: offset toward the trigger, scale from the Radix origin, fade quickly.\n   Transitions instead of keyframes, so a close that interrupts the open (or a reopen during the close) reverses from where it is. */\n.menu { --menu-x: 0px; --menu-y: -5px; position: relative; z-index: 60; min-width: min(12rem, var(--radix-dropdown-menu-content-available-width)); max-width: var(--radix-dropdown-menu-content-available-width); padding: 5px; border: 1px solid var(--border); border-radius: var(--radius-panel); background: var(--surface-raised); box-shadow: var(--shadow-floating); transform-origin: var(--radix-dropdown-menu-content-transform-origin); transition: opacity var(--duration-fast) var(--ease-enter), transform var(--duration-spring) var(--ease-spring); }\n.menu[data-side=\"top\"] { --menu-y: 5px; }\n.menu[data-side=\"left\"] { --menu-x: 5px; --menu-y: 0px; }\n.menu[data-side=\"right\"] { --menu-x: -5px; --menu-y: 0px; }\n@starting-style {\n  .menu[data-state=\"open\"] { opacity: 0; transform: translate(var(--menu-x), var(--menu-y)) scale(.97); }\n  .menu[data-state=\"open\"] .item { opacity: 0; transform: translate(calc(var(--menu-x) * .4), calc(var(--menu-y) * .4)); }\n}\n/* Radix unmounts when the exit animation ends, so a no-op keyframe times the exit while the transition does the visual work. */\n.menu[data-state=\"closed\"] { opacity: 0; transform: translate(calc(var(--menu-x) * .5), calc(var(--menu-y) * .5)) scale(.985); transition: opacity 130ms var(--ease-standard), transform 130ms var(--ease-standard); animation: menu-exit 130ms linear both; pointer-events: none; }\n.highlight { position: absolute; top: 0; right: 5px; left: 5px; border-radius: calc(var(--radius-panel) - 6px); background: var(--surface-muted); opacity: 0; pointer-events: none; transition: background-color var(--duration-fast) var(--ease-standard); }\n.highlight[data-tone=\"danger\"] { background: color-mix(in oklab, var(--danger) 8%, var(--surface)); }\n.item { position: relative; display: flex; min-height: 36px; align-items: center; gap: 10px; padding: 0 11px; border-radius: calc(var(--radius-panel) - 6px); color: var(--foreground); font-size: var(--text-sm); cursor: pointer; outline: none; transition: color var(--duration-fast) var(--ease-standard), opacity var(--duration-standard) var(--ease-enter) calc(min(var(--i, 0), 4) * 35ms), transform var(--duration-standard) var(--ease-enter) calc(min(var(--i, 0), 4) * 35ms); }\n.icon { display: inline-flex; width: 17px; color: var(--text-secondary); }\n.separator { height: 1px; margin: var(--space-1) calc(var(--space-1) * -1); background: var(--border-subtle); }\n.destructive { color: var(--danger); }\n.destructive .icon { color: var(--danger); }\n.item[data-disabled] { opacity: .45; cursor: default; }\n@keyframes menu-exit { to { --menu-exit: 1; } }\n@media (prefers-reduced-transparency: reduce) { .menu { background: var(--surface-raised); backdrop-filter: none; -webkit-backdrop-filter: none; } }\n@media (prefers-reduced-motion: reduce) {\n  .trigger, .chevron, .item, .highlight { transition: none; }\n  .menu, .menu[data-state=\"closed\"] { transform: none; transition: opacity var(--duration-instant) linear; }\n}\n"
    }
  ],
  "docs": "Docs and live preview: https://ui.xzero.co.uk/components/dropdown-menu",
  "meta": {
    "tier": "free",
    "kind": "component",
    "tags": [
      "menu",
      "action"
    ],
    "docs": "https://ui.xzero.co.uk/components/dropdown-menu",
    "markdown": "https://ui.xzero.co.uk/components/dropdown-menu/markdown"
  }
}