{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"dropdown-menu","type":"registry:component","title":"Dropdown menu","description":"Actions behind a button: the menu rises off the trigger, one highlight glides between rows, and checkbox rows draw their tick in place.","author":"Hairline UI","dependencies":["lucide-react"],"registryDependencies":["https://hairlineui.com/r/ui-interaction-motion.json?as=dependency"],"files":[{"path":"registry/dropdown-menu.tsx","type":"registry:component","content":"// Hairline UI · dropdown-menu · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n\"use client\";\n\nimport * as React from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { enterStyle, themeVars, useDismiss, useFloating, usePresence, type Align, type Side } from \"./floating\";\nimport { MenuList, type MenuEntry } from \"./menu-list\";\nimport { useInteractionReducedMotion } from \"./ui-interaction-motion\";\n\n/* ─────────────────────────────────────────────────────────\n * DROPDOWN MENU: actions behind a button\n *\n *   open     the menu rises off the trigger out of a blur, from\n *            the point on the trigger nearest to it; it flips\n *            above when there's no room below\n *   move     one highlight glides between rows\n *   pick     the menu closes and focus goes back to the trigger;\n *            checkbox rows draw their tick and stay open\n *\n * Opening from the keyboard (Enter, Space, ↓) focuses the first\n * row; ↑ focuses the last. Escape or a press outside closes it.\n * ───────────────────────────────────────────────────────── */\n\nexport type { MenuEntry } from \"./menu-list\";\n\nexport interface DropdownMenuProps {\n  /** The button that opens it. It gets aria-haspopup, aria-expanded and the click. */\n  trigger: React.ReactElement<React.HTMLAttributes<HTMLElement>>;\n  items: MenuEntry[];\n  side?: Side;\n  align?: Align;\n  open?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  /** Accessible name of the menu. */\n  label?: string;\n  /** Minimum width in px; never narrower than the trigger. */\n  minWidth?: number;\n}\n\nexport function DropdownMenu({ trigger, items, side = \"bottom\", align = \"start\", open: controlled, onOpenChange, label, minWidth = 200 }: DropdownMenuProps) {\n  const reduced = useInteractionReducedMotion();\n  const anchor = React.useRef<HTMLSpanElement>(null);\n  const surface = React.useRef<HTMLDivElement>(null);\n  const [own, setOwn] = React.useState(false);\n  const [focusOn, setFocusOn] = React.useState<\"first\" | \"last\" | \"none\">(\"none\");\n  const open = controlled ?? own;\n  const { mounted, shown } = usePresence(open, 180);\n  const place = useFloating({ open: mounted, anchor, floating: surface, side, align, offset: 6 });\n  const [vars, setVars] = React.useState<React.CSSProperties>({});\n  const id = React.useId();\n  const triggerId = React.useId();\n\n  const setOpen = (next: boolean, refocus = false) => {\n    if (controlled === undefined) setOwn(next);\n    onOpenChange?.(next);\n    // By id rather than a ref: this runs from handlers that are created while rendering.\n    if (!next && refocus) document.getElementById(trigger.props.id ?? triggerId)?.focus();\n  };\n\n  useDismiss(open, (reason) => setOpen(false, reason === \"escape\"), [anchor, surface]);\n\n  const [width, setWidth] = React.useState(minWidth);\n  React.useLayoutEffect(() => {\n    if (!mounted) return;\n    setVars(themeVars(anchor.current));\n    setWidth(Math.max(minWidth, anchor.current?.offsetWidth ?? 0));\n  }, [mounted, minWidth]);\n\n  return (\n    <>\n      <span ref={anchor} className=\"inline-flex\">\n        {React.cloneElement(trigger, {\n          id: trigger.props.id ?? triggerId,\n          \"aria-haspopup\": \"menu\",\n          \"aria-expanded\": open,\n          \"aria-controls\": open ? id : undefined,\n          onClick: (e: React.MouseEvent<HTMLElement>) => {\n            trigger.props.onClick?.(e);\n            setFocusOn(e.detail === 0 ? \"first\" : \"none\");\n            setOpen(!open);\n          },\n          onKeyDown: (e: React.KeyboardEvent<HTMLElement>) => {\n            trigger.props.onKeyDown?.(e);\n            if (e.key === \"ArrowDown\" || e.key === \"ArrowUp\") {\n              e.preventDefault();\n              setFocusOn(e.key === \"ArrowDown\" ? \"first\" : \"last\");\n              setOpen(true);\n            }\n          },\n        })}\n      </span>\n      {mounted &&\n        createPortal(\n          <div\n            ref={surface}\n            id={id}\n            className=\"fixed left-0 top-0 z-[55] overflow-hidden rounded-[14px] bg-popover text-popover-foreground shadow-[0_0_0_1px_var(--border)]\"\n            style={{ ...vars, left: place.x, top: place.y, minWidth: width, transformOrigin: place.origin, ...enterStyle(shown, place.side, reduced) }}\n          >\n            <MenuList items={items} label={label} autoFocus={focusOn} onClose={() => setOpen(false, true)} />\n          </div>,\n          document.body,\n        )}\n    </>\n  );\n}\n\nexport default DropdownMenu;\n"},{"path":"registry/floating.ts","type":"registry:component","content":"// Hairline UI · dropdown-menu · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n\"use client\";\n\nimport * as React from \"react\";\n\n/* ─────────────────────────────────────────────────────────\n * FLOATING: what every popover, menu and tooltip shares\n *\n *   place     beside an anchor on a side, lined up start, centre\n *             or end; flips to the other side when it doesn't fit\n *             and shifts to stay `padding` inside the viewport;\n *             follows scroll and resize\n *   presence  mounts on open, shows a frame later (so the entry\n *             transitions run), hides, then unmounts after `exit`\n *   dismiss   Escape and a press outside both close it\n *   theme     the portal carries the anchor's theme tokens, so a\n *             light specimen on a dark page stays light\n * ───────────────────────────────────────────────────────── */\n\nexport type Side = \"top\" | \"bottom\" | \"left\" | \"right\";\nexport type Align = \"start\" | \"center\" | \"end\";\n\nconst TOKENS = [\"--background\", \"--foreground\", \"--popover\", \"--popover-foreground\", \"--border\", \"--accent\", \"--accent-foreground\", \"--primary\", \"--primary-foreground\", \"--muted\", \"--muted-foreground\", \"--ring\", \"--card\"];\n\n/** The anchor's theme tokens as inline styles for something portalled out of its tree. */\nexport function themeVars(anchor: Element | null): React.CSSProperties {\n  if (!anchor) return {};\n  const style = getComputedStyle(anchor);\n  const vars: Record<string, string> = {};\n  for (const t of TOKENS) {\n    const v = style.getPropertyValue(t);\n    if (v) vars[t] = v;\n  }\n  return vars as React.CSSProperties;\n}\n\nconst OPPOSITE: Record<Side, Side> = { top: \"bottom\", bottom: \"top\", left: \"right\", right: \"left\" };\n\nexport interface Placement {\n  x: number;\n  y: number;\n  side: Side;\n  /** Where the surface grows from, for transform-origin. */\n  origin: string;\n  ready: boolean;\n}\n\n/** Positions `floating` (position: fixed) next to `anchor` while `open`. Pass a point instead of an anchor for context menus. */\nexport function useFloating({\n  open,\n  anchor,\n  point,\n  floating,\n  side = \"bottom\",\n  align = \"center\",\n  offset = 8,\n  padding = 8,\n}: {\n  open: boolean;\n  anchor?: React.RefObject<Element | null>;\n  point?: { x: number; y: number } | null;\n  floating: React.RefObject<HTMLElement | null>;\n  side?: Side;\n  align?: Align;\n  offset?: number;\n  padding?: number;\n}): Placement {\n  const [place, setPlace] = React.useState<Placement>({ x: -9999, y: -9999, side, origin: \"center\", ready: false });\n\n  React.useLayoutEffect(() => {\n    if (!open) return;\n    let frame = 0;\n    const measure = () => {\n      const el = floating.current;\n      if (!el) return;\n      const a = point\n        ? { left: point.x, right: point.x, top: point.y, bottom: point.y, width: 0, height: 0 }\n        : anchor?.current?.getBoundingClientRect();\n      if (!a) return;\n      const w = el.offsetWidth;\n      const h = el.offsetHeight;\n      const vw = window.innerWidth;\n      const vh = window.innerHeight;\n      const room: Record<Side, number> = { top: a.top - padding, bottom: vh - a.bottom - padding, left: a.left - padding, right: vw - a.right - padding };\n      const need = side === \"top\" || side === \"bottom\" ? h + offset : w + offset;\n      const s = room[side] < need && room[OPPOSITE[side]] > room[side] ? OPPOSITE[side] : side;\n      let x: number;\n      let y: number;\n      if (s === \"top\" || s === \"bottom\") {\n        y = s === \"bottom\" ? a.bottom + offset : a.top - offset - h;\n        x = align === \"start\" ? a.left : align === \"end\" ? a.right - w : a.left + a.width / 2 - w / 2;\n        if (point) x = a.left;\n      } else {\n        x = s === \"right\" ? a.right + offset : a.left - offset - w;\n        y = align === \"start\" ? a.top : align === \"end\" ? a.bottom - h : a.top + a.height / 2 - h / 2;\n      }\n      x = Math.max(padding, Math.min(vw - padding - w, x));\n      y = Math.max(padding, Math.min(vh - padding - h, y));\n      // Grow from the point on the anchor's edge nearest the surface.\n      const ox = s === \"left\" ? \"100%\" : s === \"right\" ? \"0%\" : `${Math.max(0, Math.min(w, a.left + a.width / 2 - x))}px`;\n      const oy = s === \"top\" ? \"100%\" : s === \"bottom\" ? \"0%\" : `${Math.max(0, Math.min(h, a.top + a.height / 2 - y))}px`;\n      const next = { x: Math.round(x), y: Math.round(y), side: s, origin: `${ox} ${oy}`, ready: true };\n      setPlace((p) => (p.x === next.x && p.y === next.y && p.side === next.side && p.origin === next.origin && p.ready ? p : next));\n    };\n    const schedule = () => {\n      cancelAnimationFrame(frame);\n      frame = requestAnimationFrame(measure);\n    };\n    measure();\n    const ro = new ResizeObserver(schedule);\n    if (floating.current) ro.observe(floating.current);\n    window.addEventListener(\"scroll\", schedule, { capture: true, passive: true });\n    window.addEventListener(\"resize\", schedule);\n    return () => {\n      cancelAnimationFrame(frame);\n      ro.disconnect();\n      window.removeEventListener(\"scroll\", schedule, { capture: true });\n      window.removeEventListener(\"resize\", schedule);\n    };\n  }, [open, anchor, point, floating, side, align, offset, padding]);\n\n  return open ? place : { ...place, ready: false };\n}\n\n/** Mount → show next frame → hide → unmount after `exit` ms. */\nexport function usePresence(open: boolean, exit = 160) {\n  const [mounted, setMounted] = React.useState(open);\n  const [shown, setShown] = React.useState(false);\n  if (open && !mounted) setMounted(true);\n  React.useEffect(() => {\n    if (open) {\n      let inner = 0;\n      const outer = requestAnimationFrame(() => {\n        inner = requestAnimationFrame(() => setShown(true));\n      });\n      return () => {\n        cancelAnimationFrame(outer);\n        cancelAnimationFrame(inner);\n      };\n    }\n    const timer = window.setTimeout(() => {\n      setMounted(false);\n      setShown(false);\n    }, exit);\n    return () => window.clearTimeout(timer);\n  }, [open, exit]);\n  return { mounted: mounted || open, shown: open && shown };\n}\n\n/** Escape and presses outside `refs` call onDismiss while `open`. */\nexport function useDismiss(open: boolean, onDismiss: (reason: \"escape\" | \"outside\") => void, refs: React.RefObject<Element | null>[]) {\n  const latest = React.useRef({ onDismiss, refs });\n  React.useEffect(() => {\n    latest.current = { onDismiss, refs };\n  });\n  React.useEffect(() => {\n    if (!open) return;\n    const onKey = (e: KeyboardEvent) => {\n      if (e.key !== \"Escape\") return;\n      e.stopPropagation();\n      latest.current.onDismiss(\"escape\");\n    };\n    const onDown = (e: PointerEvent) => {\n      const target = e.target as Node;\n      if (latest.current.refs.some((r) => r.current?.contains(target))) return;\n      latest.current.onDismiss(\"outside\");\n    };\n    document.addEventListener(\"keydown\", onKey);\n    document.addEventListener(\"pointerdown\", onDown, true);\n    return () => {\n      document.removeEventListener(\"keydown\", onKey);\n      document.removeEventListener(\"pointerdown\", onDown, true);\n    };\n  }, [open]);\n}\n\n/** The entry pose for a surface on `side`: it comes 6px from the anchor, 0.96 and blurred. */\nexport function enterStyle(shown: boolean, side: Side, reduced: boolean, ease = \"cubic-bezier(0.16,1,0.3,1)\"): React.CSSProperties {\n  const d = 6;\n  const shift = side === \"top\" ? `0 ${d}px` : side === \"bottom\" ? `0 -${d}px` : side === \"left\" ? `${d}px 0` : `-${d}px 0`;\n  return {\n    opacity: shown ? 1 : 0,\n    translate: shown || reduced ? \"0 0\" : shift,\n    scale: shown || reduced ? \"1\" : \"0.96\",\n    filter: shown || reduced ? \"blur(0px)\" : \"blur(4px)\",\n    transition: reduced\n      ? \"opacity 120ms ease-out\"\n      : shown\n        ? `opacity 220ms ${ease}, translate 320ms ${ease}, scale 320ms ${ease}, filter 220ms ${ease}`\n        : `opacity 140ms ${ease}, translate 180ms ${ease}, scale 180ms ${ease}, filter 140ms ${ease}`,\n  };\n}\n"},{"path":"registry/menu-list.tsx","type":"registry:component","content":"// Hairline UI · dropdown-menu · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n\"use client\";\n\nimport * as React from \"react\";\nimport { EASE_OUT, useInteractionReducedMotion } from \"./ui-interaction-motion\";\n\n/* ─────────────────────────────────────────────────────────\n * MENU LIST: the rows inside a dropdown or context menu\n *\n *   highlight  one fill glides between rows as the pointer or\n *              arrow keys move; it never blinks off and on\n *   keys       arrows (wrapping), Home/End, typing a few letters\n *              jumps to a row, Enter/Space picks\n *   check      checkbox rows draw their tick in place and keep\n *              the menu open\n * ───────────────────────────────────────────────────────── */\n\nexport type MenuEntry =\n  | { type?: \"item\"; id: string; label: string; icon?: React.ReactNode; shortcut?: string; destructive?: boolean; disabled?: boolean; onSelect?: () => void }\n  | { type: \"checkbox\"; id: string; label: string; icon?: React.ReactNode; checked: boolean; onCheckedChange: (checked: boolean) => void; disabled?: boolean }\n  | { type: \"label\"; label: string }\n  | { type: \"separator\" };\n\ntype Row = Extract<MenuEntry, { id: string }>;\n\nexport interface MenuListProps {\n  items: MenuEntry[];\n  /** Called after an item (not a checkbox) is picked. */\n  onClose: () => void;\n  /** Focus the first row on mount (keyboard open). */\n  autoFocus?: \"first\" | \"last\" | \"none\";\n  label?: string;\n}\n\nfunction Tick({ on, reduced }: { on: boolean; reduced: boolean }) {\n  return (\n    <svg viewBox=\"0 0 16 16\" fill=\"none\" className=\"size-3.5\">\n      <path\n        d=\"M3.5 8.5 6.5 11.5 12.5 4.5\"\n        stroke=\"currentColor\"\n        strokeWidth=\"1.8\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n        pathLength={1}\n        strokeDasharray={1}\n        style={{ strokeDashoffset: on ? 0 : 1, transition: reduced ? \"none\" : `stroke-dashoffset ${on ? 280 : 140}ms ${EASE_OUT}` }}\n      />\n    </svg>\n  );\n}\n\nexport function MenuList({ items, onClose, autoFocus = \"none\", label }: MenuListProps) {\n  const reduced = useInteractionReducedMotion();\n  const listRef = React.useRef<HTMLDivElement>(null);\n  const rows = items.filter((i): i is Row => \"id\" in i && !i.disabled);\n  const [active, setActive] = React.useState<string | null>(() => (autoFocus === \"none\" ? null : ((autoFocus === \"first\" ? rows[0] : rows[rows.length - 1])?.id ?? null)));\n  const [mark, setMark] = React.useState<{ y: number; h: number } | null>(null);\n  const [ready, setReady] = React.useState(false);\n  const typed = React.useRef({ text: \"\", at: 0 });\n\n  const focusRow = (id: string | null) => {\n    setActive(id);\n    if (id) listRef.current?.querySelector<HTMLElement>(`[data-menu-row=\"${CSS.escape(id)}\"]`)?.focus({ preventScroll: true });\n  };\n\n  // On mount: focus the starting row (keyboard open) or the list itself.\n  React.useEffect(() => {\n    const list = listRef.current;\n    const row = active ? list?.querySelector<HTMLElement>(`[data-menu-row=\"${CSS.escape(active)}\"]`) : null;\n    (row ?? list)?.focus({ preventScroll: true });\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, []);\n\n  React.useLayoutEffect(() => {\n    const list = listRef.current;\n    if (!list) return;\n    const measure = () => {\n      const el = active ? list.querySelector<HTMLElement>(`[data-menu-row=\"${CSS.escape(active)}\"]`) : null;\n      setMark((p) => (el ? (p && p.y === el.offsetTop && p.h === el.offsetHeight ? p : { y: el.offsetTop, h: el.offsetHeight }) : null));\n    };\n    measure();\n    const ro = new ResizeObserver(measure);\n    ro.observe(list);\n    return () => ro.disconnect();\n  }, [active]);\n  React.useEffect(() => {\n    if (!mark || ready) return;\n    const frame = requestAnimationFrame(() => setReady(true));\n    return () => cancelAnimationFrame(frame);\n  }, [mark, ready]);\n\n  const pick = (row: Row) => {\n    if (row.type === \"checkbox\") return row.onCheckedChange(!row.checked);\n    onClose();\n    row.onSelect?.();\n  };\n\n  const onKeyDown = (e: React.KeyboardEvent) => {\n    const index = rows.findIndex((r) => r.id === active);\n    let next: Row | undefined;\n    if (e.key === \"ArrowDown\") next = rows[(index + 1) % rows.length];\n    else if (e.key === \"ArrowUp\") next = rows[(index - 1 + rows.length) % rows.length];\n    else if (e.key === \"Home\") next = rows[0];\n    else if (e.key === \"End\") next = rows[rows.length - 1];\n    else if ((e.key === \"Enter\" || e.key === \" \") && index >= 0) {\n      e.preventDefault();\n      return pick(rows[index]);\n    } else if (e.key === \"Tab\") return onClose();\n    else if (e.key.length === 1 && /\\S/.test(e.key)) {\n      const now = e.timeStamp;\n      typed.current = { text: (now - typed.current.at < 600 ? typed.current.text : \"\") + e.key.toLowerCase(), at: now };\n      next = rows.find((r) => r.label.toLowerCase().startsWith(typed.current.text));\n    }\n    if (!next) return;\n    e.preventDefault();\n    focusRow(next.id);\n  };\n\n  return (\n    <div ref={listRef} role=\"menu\" aria-label={label} tabIndex={-1} onKeyDown={onKeyDown} onPointerLeave={() => setActive(null)} className=\"relative flex flex-col p-1 outline-none\">\n      <span\n        aria-hidden=\"true\"\n        className=\"pointer-events-none absolute inset-x-1 top-0 rounded-[10px] bg-accent\"\n        style={{\n          transform: `translateY(${mark?.y ?? 0}px)`,\n          height: mark?.h ?? 0,\n          opacity: mark ? 1 : 0,\n          transition: reduced || !ready ? \"opacity 120ms\" : `transform 220ms ${EASE_OUT}, height 220ms ${EASE_OUT}, opacity 160ms ${EASE_OUT}`,\n        }}\n      />\n      {items.map((item, i) => {\n        if (item.type === \"separator\") return <div key={`s${i}`} role=\"separator\" className=\"mx-2 my-1 h-px bg-border\" />;\n        if (item.type === \"label\") return <div key={`l${i}`} className=\"px-2.5 pb-1 pt-2 text-[11.5px] font-medium text-muted-foreground\">{item.label}</div>;\n        const row = item as Row;\n        const check = row.type === \"checkbox\";\n        const destructive = !check && \"destructive\" in row && row.destructive;\n        return (\n          <div\n            key={row.id}\n            data-menu-row={row.id}\n            role={check ? \"menuitemcheckbox\" : \"menuitem\"}\n            aria-checked={check ? row.checked : undefined}\n            aria-disabled={row.disabled || undefined}\n            tabIndex={-1}\n            onPointerMove={() => !row.disabled && active !== row.id && focusRow(row.id)}\n            onClick={() => !row.disabled && pick(row)}\n            className={`relative flex h-8 select-none items-center gap-2.5 rounded-[10px] px-2.5 text-[13px] outline-none ${\n              row.disabled ? \"cursor-default opacity-40\" : \"cursor-pointer\"\n            } ${destructive ? \"text-red-600 dark:text-red-400\" : active === row.id ? \"text-foreground\" : \"text-foreground/85\"}`}\n          >\n            {check ? (\n              <span className=\"flex size-4 items-center justify-center text-foreground\">\n                <Tick on={row.checked} reduced={reduced} />\n              </span>\n            ) : (\n              row.icon && <span className={`flex size-4 items-center justify-center [&_svg]:size-3.5 ${destructive ? \"\" : \"text-muted-foreground\"}`}>{row.icon}</span>\n            )}\n            <span className=\"flex-1 truncate\">{row.label}</span>\n            {!check && \"shortcut\" in row && row.shortcut && <kbd className=\"font-mono text-[11px] text-muted-foreground\">{row.shortcut}</kbd>}\n          </div>\n        );\n      })}\n    </div>\n  );\n}\n\nexport default MenuList;\n"}],"css":{},"docs":"Docs and live preview: https://hairlineui.com/ui/dropdown-menu. MIT licensed: https://hairlineui.com/license#components","meta":{"license":"MIT"}}