{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "menu",
  "type": "registry:ui",
  "title": "Menu",
  "description": "A glass dropdown with one highlight that glides from row to row. It grows from its trigger and travels from the side it really opens on.",
  "dependencies": [
    "@radix-ui/react-dropdown-menu",
    "motion"
  ],
  "registryDependencies": [
    "https://littmd.vercel.app/r/litt.json",
    "https://littmd.vercel.app/r/glide.json",
    "https://littmd.vercel.app/r/icons.json",
    "https://littmd.vercel.app/r/motion.json"
  ],
  "files": [
    {
      "path": "registry/litt/ui/menu.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n// @litt/menu 2026.11.06. What changed: https://littmd.vercel.app/changelog.md\n\n/**\n * Menu: a dropdown on @radix-ui/react-dropdown-menu (roles, focus, typeahead,\n * submenus) with everything visual written here.\n *\n * - Flat: a solid surface-1 card (no glass, no hairline), the float shadow; rows are grey bubbles at the panel\n *   radius less its 4px padding, and a separator is a gap, not a line.\n * - Surface: floating glass (litt-glass, default float shadow), rounded-litt-surface,\n *   p-1 (4px), min width 240. Rows are 44px and rounded-litt-control, which sits\n *   concentric inside the 4px inset. A submenu is opened from a floating\n *   surface, so it is solid (bg-litt-surface-2 + shadow-litt-float), never glass on glass.\n * - Motion, responsive tier, in the material's language (useOverlayMotion, @/motion/deal): it grows from the trigger\n *   from 0.9 under Flat and 0.98 under Press, and under Device it switches on in steps with no slide. Radix gives\n *   --radix-dropdown-menu-content-transform-origin, and it travels a quarter of\n *   the travel token from the side it actually opens on (after collision\n *   flipping). We wait for Radix to place it, then read data-side. Exit uses\n *   useExit. AnimatePresence + forceMount + controlled open lets the exit play.\n * - The highlight inside is the shared glide (glide.tsx). Radix keeps the active\n *   row on DOM focus, so rows report onFocus and the glide follows it.\n * - Focus by modality: opened with the pointer, no row is highlighted until the\n *   pointer moves or a key is pressed. Opened with Enter, Space or ArrowDown,\n *   Radix focuses the first row and it shows the keyboard ring.\n * - Checkbox and radio rows keep a fixed 16px cell, so toggling never shifts text.\n *\n * On a phone (foundations.md section 6; useDevice().compact, so a desktop can preview it with\n * <DeviceProvider mode=\"phone\">)\n * - The menu stays beside its button, but a submenu never flies out sideways, where it would\n *   land off the screen. It replaces the list inside the same panel, with a Back row first.\n *   The rows of both lists are real menu items, so the arrow keys, Home, End and typeahead\n *   work as before: the list that is not showing is disabled for Radix, and hidden.\n * - The two lists cross-fade and slide as Tabs panels do: the new one enters half of the travel\n *   token from the side it comes from, the old one leaves a quarter of it the other way.\n *   Responsive tier, transform and opacity only. The panel's height snaps to the new list.\n *   (A glass panel cannot clip its content, because its shadow lives on ::before, so an inner\n *   box does it.)\n * - Focus: after a keyboard drill the Back row has it, after Back the row that opened the\n *   submenu has it again, and after a tap or click the panel has it, so nothing is lit.\n *   ArrowRight drills in, ArrowLeft goes back, and Escape still closes the whole menu.\n * - Touch rows fill when pressed (pointer-coarse:active:, instant tier, color only) and the\n *   shortcut hints are hidden. On coarse the glide does not light a row on touch, so the press\n *   fill is the one feedback.\n *\n * Shared with the context menu (context-menu.tsx)\n * - Radix scopes each menu family to its own root, so a DropdownMenu.Item cannot live inside a\n *   ContextMenu. The rows and the panel take their Radix parts from a context instead (MenuParts):\n *   DropdownMenu's by default and in Menu, ContextMenu's in ContextMenu. So the same rows work in\n *   both, with the same panel, glide, focus by modality, solid submenus and phone drill-down.\n * - MenuPanel is the panel itself, exported for ContextMenu. Menu and ContextMenu each pass their\n *   parts to it, so a Menu inside a context menu's target (a row's \"⋯\" button) keeps its own.\n */\nimport {\n  createContext,\n  useCallback,\n  useContext,\n  useEffect,\n  useId,\n  useLayoutEffect,\n  useRef,\n  useState,\n  type KeyboardEvent,\n  type MouseEvent,\n  type PointerEvent,\n  type ReactElement,\n  type ReactNode,\n} from \"react\";\nimport { createPortal } from \"react-dom\";\nimport * as DropdownMenu from \"@radix-ui/react-dropdown-menu\";\nimport { AnimatePresence, animate, motion, useMotionValue, usePresence } from \"motion/react\";\nimport { useDevice, useExit, useTransition, useTravel } from \"../../lib/litt/motion\";\nimport { useOverlayMotion } from \"../../lib/litt/motion/deal\";\nimport { CheckIcon, ChevronRightIcon } from \"./icons\";\nimport { GLIDE_ROW, useGlide, type Glide } from \"../../lib/litt/glide\";\n\n/* Radix's asChild, spread in: shadcn's CLI rewrites a written asChild to Base UI's render prop in base-* hosts. */\nconst asChildProps = { asChild: true } as const;\n\ntype Side = \"top\" | \"right\" | \"bottom\" | \"left\";\n\n/**\n * The Radix parts the rows and the panel are built from, and the classes that read the Radix variables of\n * that family. Menu uses DropdownMenu's; ContextMenu passes ContextMenu's to MenuPanel.\n */\nexport type MenuParts = {\n  Item: typeof DropdownMenu.Item;\n  CheckboxItem: typeof DropdownMenu.CheckboxItem;\n  ItemIndicator: typeof DropdownMenu.ItemIndicator;\n  RadioGroup: typeof DropdownMenu.RadioGroup;\n  RadioItem: typeof DropdownMenu.RadioItem;\n  Label: typeof DropdownMenu.Label;\n  Group: typeof DropdownMenu.Group;\n  Separator: typeof DropdownMenu.Separator;\n  Sub: typeof DropdownMenu.Sub;\n  SubTrigger: typeof DropdownMenu.SubTrigger;\n  SubContent: typeof DropdownMenu.SubContent;\n  Portal: typeof DropdownMenu.Portal;\n  /** The root panel. A context menu's ignores side, align and sideOffset: it opens at the pointer. */\n  Content: typeof DropdownMenu.Content;\n  /** The transform origin Radix measures, so the panel grows from its trigger or the pointer. */\n  origin: string;\n  /** The height left on screen, which caps a phone panel's scrolling list. */\n  fit: string;\n};\n\nconst DROPDOWN_PARTS: MenuParts = {\n  Item: DropdownMenu.Item,\n  CheckboxItem: DropdownMenu.CheckboxItem,\n  ItemIndicator: DropdownMenu.ItemIndicator,\n  RadioGroup: DropdownMenu.RadioGroup,\n  RadioItem: DropdownMenu.RadioItem,\n  Label: DropdownMenu.Label,\n  Group: DropdownMenu.Group,\n  Separator: DropdownMenu.Separator,\n  Sub: DropdownMenu.Sub,\n  SubTrigger: DropdownMenu.SubTrigger,\n  SubContent: DropdownMenu.SubContent,\n  Portal: DropdownMenu.Portal,\n  Content: DropdownMenu.Content,\n  origin: \"origin-(--radix-dropdown-menu-content-transform-origin)\",\n  fit: \"max-h-(--radix-dropdown-menu-content-available-height)\",\n};\nconst PartsCtx = createContext<MenuParts>(DROPDOWN_PARTS);\nconst useParts = () => useContext(PartsCtx);\n\n/**\n * On a phone the submenus are lists inside the one panel, not panels of their own. `stack` holds\n * the ids of the submenus that are open, outermost first, and `slot` is the box they render into.\n */\ntype Drill = {\n  stack: string[];\n  /** +1 while going in, -1 while going back: the side the new list comes from. */\n  dir: 1 | -1;\n  slot: HTMLElement | null;\n  backLabel: string;\n  push: (id: string, trigger: HTMLElement | null) => void;\n  pop: () => void;\n};\n\ntype PanelContext = {\n  glide: Glide;\n  setSubOpen: (open: boolean) => void;\n  releaseSub: () => void;\n  /** Null on a wide screen, where a submenu is a fly-out panel. */\n  drill: Drill | null;\n  /** The primary pointer is a finger. */\n  coarse: boolean;\n};\nconst PanelCtx = createContext<PanelContext | null>(null);\nfunction usePanel() {\n  const ctx = useContext(PanelCtx);\n  if (!ctx) throw new Error(\"Menu items must be rendered inside <Menu>\");\n  return ctx;\n}\n\n/**\n * Where the list a row sits in is in a drill. A list that has finished leaving is \"hidden\": display\n * none, and its rows are disabled for Radix, so the arrow keys and typeahead only meet the visible list.\n * A list that is leaving is still whole, so its rows do not flash gray while they fade.\n */\ntype Phase = \"current\" | \"leaving\" | \"hidden\";\nconst ViewCtx = createContext<Phase>(\"current\");\n\n/** A tap would focus the row it lands on, and a focused row looks keyboard-lit. */\nconst keepFocus = (e: MouseEvent) => e.preventDefault();\n\n/**\n * What every row needs from its list and its device: whether Radix may reach it, whether the glide may light\n * it, and, for a finger, not to take focus. A finger shows the press on the row itself (its fill). Letting the\n * tap focus the row too would leave it lit, with the keyboard ring, long after the finger has gone.\n */\nfunction useRowView() {\n  const phase = useContext(ViewCtx);\n  const { coarse } = usePanel();\n  return {\n    off: phase === \"hidden\",\n    glideRow: phase === \"current\" ? GLIDE_ROW : undefined,\n    onMouseDown: coarse ? keepFocus : undefined,\n  };\n}\n\n/** Flat: the glass panel becomes a solid surface-1 card with the float shadow and no hairline. */\nconst FLAT_PANEL =\n  \"in-[[data-material=flat]]:[--litt-glass-bg:var(--litt-surface-1)] in-[[data-material=flat]]:[--litt-glass-border:transparent] \" +\n  \"in-[[data-material=flat]]:[--litt-highlight:0_0_0_0_transparent] in-[[data-material=flat]]:[--litt-glass-blur:0px]\";\n\n/* ------------------------------------------------------------------ rows */\n\nconst row =\n  \"relative flex h-litt-control-md w-full scroll-my-1 cursor-pointer select-none items-center gap-3 rounded-litt-control px-3 text-litt-small outline-none in-[[data-material=flat]]:rounded-[calc(var(--litt-radius-surface)-4px)] \" +\n  \"[-webkit-tap-highlight-color:transparent] \" +\n  \"transition-colors duration-(--litt-dur-instant) ease-litt data-[disabled]:pointer-events-none data-[disabled]:text-litt-fg-disabled \" +\n  \"pointer-coarse:active:bg-[color-mix(in_oklab,var(--litt-fg)_7%,transparent)] forced-colors:data-[highlighted]:[forced-color-adjust:none] forced-colors:data-[highlighted]:text-[HighlightText] forced-colors:data-[highlighted]:**:text-[HighlightText]\";\nconst tone = \"text-litt-fg-muted data-[highlighted]:text-litt-fg pointer-coarse:active:text-litt-fg\";\nconst toneDanger = \"text-litt-danger\";\n/** The group label row, and the caption that names a submenu's list. */\nconst labelRow = \"flex h-litt-control-sm items-center px-3 text-litt-caption text-litt-fg-subtle litt-legend\";\n\n/** The fixed 16px cell at the start of every row. */\nfunction Cell({ children }: { children?: ReactNode }) {\n  return <span className=\"grid size-4 shrink-0 place-items-center\">{children}</span>;\n}\n\n/** A hint, so it is for a keyboard: a finger has no use for it, and it hides on coarse screens. */\nfunction Shortcut({ children }: { children: ReactNode }) {\n  return <span className=\"ml-auto shrink-0 pl-4 text-litt-caption font-normal text-litt-fg-subtle pointer-coarse:hidden\">{children}</span>;\n}\n\ntype ItemProps = {\n  /** 16px icon in a fixed cell before the label. A row without one has no cell, so give every row an icon or none, to keep the labels in line. */\n  icon?: ReactNode;\n  /** Mono hint on the right, for example ⌘D. It is a hint only: it does not bind the key. */\n  shortcut?: string;\n  /** Danger color, for actions that delete something. */\n  destructive?: boolean;\n  /** Dims the row and skips it for the pointer and the arrow keys. */\n  disabled?: boolean;\n  /** Runs on click, Enter or Space. The menu closes afterward; call event.preventDefault() to keep it open. */\n  onSelect?: (event: Event) => void;\n  /** Makes the row a real link, so a modifier key or a middle click opens it in a new tab. */\n  href?: string;\n  /** The row label. It truncates with an ellipsis instead of wrapping. */\n  children: ReactNode;\n};\n\n/** A row that does something when it is chosen, or goes somewhere when it has an href. */\nexport function MenuItem({ icon, shortcut, destructive, disabled, onSelect, href, children }: ItemProps) {\n  const P = useParts();\n  const { glide } = usePanel();\n  const { off, glideRow, onMouseDown } = useRowView();\n  const content = (\n    <>\n      {icon !== undefined && <Cell>{icon}</Cell>}\n      <span className=\"min-w-0 truncate\">{children}</span>\n      {shortcut && <Shortcut>{shortcut}</Shortcut>}\n    </>\n  );\n  return (\n    <P.Item\n      {...glideRow}\n      onMouseDown={onMouseDown}\n      {...{ asChild: href !== undefined }}\n      disabled={disabled || off}\n      onSelect={onSelect}\n      onFocus={(e) => glide.focusRow(e.currentTarget)}\n      className={row + \" \" + (destructive ? toneDanger : tone)}\n    >\n      {href !== undefined ? <a href={href}>{content}</a> : content}\n    </P.Item>\n  );\n}\n\nconst indicator =\n  \"grid size-4 shrink-0 place-items-center opacity-0 transition-opacity duration-(--litt-dur-instant) ease-litt data-[state=checked]:opacity-100\";\n\ntype CheckboxProps = {\n  /** Whether the option is on. You own the state: pass it in and update it in onCheckedChange. */\n  checked: boolean;\n  /** Called with the new value when the row is toggled. */\n  onCheckedChange: (checked: boolean) => void;\n  /** Mono hint on the right, for example ⌘T. It is a hint only: it does not bind the key. */\n  shortcut?: string;\n  /** Close the menu after toggling. Off by default, so several options can change in one visit. */\n  closeOnSelect?: boolean;\n  /** The option label. It truncates with an ellipsis instead of wrapping. */\n  children: ReactNode;\n};\n\n/** A row that turns one option on or off. The check sits in a fixed 16px cell, so toggling never shifts the label. */\nexport function MenuCheckboxItem({ checked, onCheckedChange, shortcut, closeOnSelect = false, children }: CheckboxProps) {\n  const P = useParts();\n  const { glide } = usePanel();\n  const { off, glideRow, onMouseDown } = useRowView();\n  return (\n    <P.CheckboxItem\n      {...glideRow}\n      onMouseDown={onMouseDown}\n      disabled={off}\n      checked={checked}\n      onCheckedChange={onCheckedChange}\n      onSelect={(e) => !closeOnSelect && e.preventDefault()}\n      onFocus={(e) => glide.focusRow(e.currentTarget)}\n      className={row + \" \" + tone}\n    >\n      <P.ItemIndicator forceMount className={indicator}>\n        <CheckIcon />\n      </P.ItemIndicator>\n      <span className=\"min-w-0 truncate\">{children}</span>\n      {shortcut && <Shortcut>{shortcut}</Shortcut>}\n    </P.CheckboxItem>\n  );\n}\n\n/** Holds the single choice for a set of MenuRadioItem rows. */\nexport function MenuRadioGroup({\n  value,\n  onValueChange,\n  children,\n}: {\n  /** The value of the selected row. You own the state: pass it in and update it in onValueChange. */\n  value: string;\n  /** Called with the value of the row that was picked. */\n  onValueChange: (value: string) => void;\n  /** The MenuRadioItem rows. */\n  children: ReactNode;\n}) {\n  const P = useParts();\n  return (\n    <P.RadioGroup value={value} onValueChange={onValueChange}>\n      {children}\n    </P.RadioGroup>\n  );\n}\n\n/** One choice inside a MenuRadioGroup. It shows the check when its value matches the group's value. */\nexport function MenuRadioItem({\n  value,\n  closeOnSelect = false,\n  children,\n}: {\n  /** What this row stands for. It is passed to onValueChange when the row is picked. */\n  value: string;\n  /** Close the menu after picking. Off by default, so several options can change in one visit. */\n  closeOnSelect?: boolean;\n  /** The choice label. It truncates with an ellipsis instead of wrapping. */\n  children: ReactNode;\n}) {\n  const P = useParts();\n  const { glide } = usePanel();\n  const { off, glideRow, onMouseDown } = useRowView();\n  return (\n    <P.RadioItem\n      {...glideRow}\n      onMouseDown={onMouseDown}\n      disabled={off}\n      value={value}\n      onSelect={(e) => !closeOnSelect && e.preventDefault()}\n      onFocus={(e) => glide.focusRow(e.currentTarget)}\n      className={row + \" \" + tone}\n    >\n      <P.ItemIndicator forceMount className={indicator}>\n        <CheckIcon />\n      </P.ItemIndicator>\n      <span className=\"min-w-0 truncate\">{children}</span>\n    </P.RadioItem>\n  );\n}\n\n/** A group label: caption size, fg-subtle. It is not a row, so the arrow keys skip it. */\nexport function MenuLabel({\n  children,\n}: {\n  /** The label text. */\n  children: ReactNode;\n}) {\n  const P = useParts();\n  return <P.Label className={labelRow}>{children}</P.Label>;\n}\n\n/** Wraps related rows in a role=\"group\". It adds no visual of its own; use MenuLabel and MenuSeparator for that. */\nexport function MenuGroup({\n  children,\n}: {\n  /** The rows in the group. */\n  children: ReactNode;\n}) {\n  const P = useParts();\n  return <P.Group>{children}</P.Group>;\n}\n\n/** Full-bleed 1px hairline: it ignores the 4px inset of the panel. */\nexport function MenuSeparator() {\n  const P = useParts();\n  return <P.Separator className=\"-mx-1 my-1 h-px bg-litt-line in-[[data-material=flat]]:h-0 in-[[data-material=flat]]:bg-transparent\" />;\n}\n\n/** What the two submenu implementations share. */\ntype SubProps = {\n  icon?: ReactNode;\n  label: string;\n  children: ReactNode;\n};\n\n/** A submenu. On a wide screen its panel flies out beside the row and is solid, because it floats over a floating surface. On a phone it replaces the list in the same panel, with a Back row first. It opens on hover, click, Enter, Space or ArrowRight. */\nexport function MenuSub({\n  icon,\n  label,\n  children,\n}: {\n  /** 16px icon in a fixed cell before the label, like MenuItem. */\n  icon?: ReactNode;\n  /** Text of the row that opens the submenu. */\n  label: string;\n  /** The submenu rows. Use the same parts as Menu. */\n  children: ReactNode;\n}) {\n  const { drill } = usePanel();\n  return drill ? (\n    <DrillSub drill={drill} icon={icon} label={label}>\n      {children}\n    </DrillSub>\n  ) : (\n    <FlySub icon={icon} label={label}>\n      {children}\n    </FlySub>\n  );\n}\n\n/** The wide-screen submenu: a solid panel that flies out beside its row. */\nfunction FlySub({ icon, label, children }: SubProps) {\n  const P = useParts();\n  const parent = usePanel();\n  const { off, glideRow, onMouseDown } = useRowView();\n  const [open, setOpen] = useState(false);\n  const change = (next: boolean) => {\n    setOpen(next);\n    parent.setSubOpen(next);\n    if (next) parent.glide.setRing(false);\n    else parent.releaseSub();\n  };\n  return (\n    <P.Sub open={open} onOpenChange={change}>\n      <P.SubTrigger\n        {...glideRow}\n        onMouseDown={onMouseDown}\n        disabled={off}\n        onFocus={(e) => parent.glide.focusRow(e.currentTarget)}\n        className={row + \" \" + tone + \" data-[state=open]:text-litt-fg\"}\n      >\n        {icon !== undefined && <Cell>{icon}</Cell>}\n        <span className=\"min-w-0 truncate\">{label}</span>\n        <span className=\"ml-auto shrink-0 text-litt-fg-subtle\">\n          <ChevronRightIcon />\n        </span>\n      </P.SubTrigger>\n      <AnimatePresence>\n        {open && (\n          <P.Portal forceMount key=\"sub\">\n            <MenuPanel sub>{children}</MenuPanel>\n          </P.Portal>\n        )}\n      </AnimatePresence>\n    </P.Sub>\n  );\n}\n\n/**\n * The phone submenu. Its row stays in the parent list and is a plain menu item that drills in; its\n * rows render into the panel's slot, as a list of their own with a Back row first. They stay\n * children of this component, so state in them stays live while another list shows.\n */\nfunction DrillSub({ drill, icon, label, children }: SubProps & { drill: Drill }) {\n  const P = useParts();\n  const { glide } = usePanel();\n  const { off, glideRow, onMouseDown } = useRowView();\n  const id = useId();\n  const heading = useId();\n  const trigger = useRef<HTMLDivElement | null>(null);\n  const open = drill.stack.includes(id);\n  const top = drill.stack[drill.stack.length - 1] === id;\n  return (\n    <>\n      <P.Item\n        ref={trigger}\n        {...glideRow}\n        onMouseDown={onMouseDown}\n        disabled={off}\n        aria-haspopup=\"menu\"\n        /* Keep the menu open: the list changes, the panel stays. */\n        onSelect={(e) => {\n          e.preventDefault();\n          drill.push(id, trigger.current);\n        }}\n        onKeyDown={(e) => {\n          if (e.key !== \"ArrowRight\") return;\n          e.preventDefault();\n          drill.push(id, trigger.current);\n        }}\n        onFocus={(e) => glide.focusRow(e.currentTarget)}\n        className={row + \" \" + tone}\n      >\n        {icon !== undefined && <Cell>{icon}</Cell>}\n        <span className=\"min-w-0 truncate\">{label}</span>\n        <span className=\"ml-auto shrink-0 text-litt-fg-subtle\">\n          <ChevronRightIcon />\n        </span>\n      </P.Item>\n      {drill.slot &&\n        createPortal(\n          <AnimatePresence>\n            {open && (\n              <SubView key={id} current={top}>\n                <BackItem />\n                <P.Group aria-labelledby={heading}>\n                  <P.Label id={heading} className={labelRow}>\n                    {label}\n                  </P.Label>\n                  {children}\n                </P.Group>\n              </SubView>\n            )}\n          </AnimatePresence>,\n          drill.slot,\n        )}\n    </>\n  );\n}\n\n/** The first row of a submenu's list: a real menu item, so the arrow keys reach it like any other. */\nfunction BackItem() {\n  const P = useParts();\n  const { glide, drill } = usePanel();\n  const { off, glideRow, onMouseDown } = useRowView();\n  if (!drill) return null;\n  return (\n    <P.Item\n      {...glideRow}\n      onMouseDown={onMouseDown}\n      disabled={off}\n      onSelect={(e) => {\n        e.preventDefault();\n        drill.pop();\n      }}\n      onFocus={(e) => glide.focusRow(e.currentTarget)}\n      className={row + \" \" + tone}\n    >\n      <Cell>\n        <span className=\"grid rotate-180\">\n          <ChevronRightIcon />\n        </span>\n      </Cell>\n      <span className=\"min-w-0 truncate\">{drill.backLabel}</span>\n    </P.Item>\n  );\n}\n\n/** A submenu's list, which AnimatePresence keeps mounted while it leaves. */\nfunction SubView({ current, children }: { current: boolean; children: ReactNode }) {\n  const [present, safeToRemove] = usePresence();\n  return (\n    <DrillView current={current} present={present} onGone={safeToRemove ?? undefined} born>\n      {children}\n    </DrillView>\n  );\n}\n\n/**\n * One list of a drill: the menu's own rows, or a submenu's. The one that shows sits in the flow, so the\n * panel is exactly as tall as it. It enters half of the travel token from the side it comes from, and the\n * one it replaces leaves a quarter of it the other way, taken out of the flow while it goes (as a Tabs\n * panel is), so the height snaps and never animates. Responsive tier, transform and opacity only.\n */\nfunction DrillView({\n  current,\n  present = true,\n  onGone,\n  born = false,\n  children,\n}: {\n  /** This is the list the stack ends on. */\n  current: boolean;\n  /** False while its AnimatePresence is removing it. */\n  present?: boolean;\n  /** Tells AnimatePresence the exit is done. */\n  onGone?: () => void;\n  /** Added by a drill, so it enters. The menu's own first list is simply there. */\n  born?: boolean;\n  children: ReactNode;\n}) {\n  const enter = useTransition(\"responsive\");\n  const exit = useExit(\"responsive\");\n  const travel = useTravel();\n  /* From context, not a prop: AnimatePresence keeps a leaving list's old props, and the way it leaves is the way back. */\n  const dir = usePanel().drill?.dir ?? 1;\n  const shown = current && present;\n  /* The exit has finished: the list is display none until it is shown again. */\n  const [settled, setSettled] = useState(false);\n  const x = useMotionValue(born ? dir * (travel / 2) : 0);\n  const opacity = useMotionValue(born ? 0 : 1);\n  const latest = useRef({ enter, exit, travel, dir, onGone });\n  latest.current = { enter, exit, travel, dir, onGone };\n\n  useEffect(() => {\n    const { enter, exit, travel, dir } = latest.current;\n    let alive = true;\n    const runs: { stop: () => void }[] = [];\n    if (shown) {\n      setSettled(false);\n      /* Coming back from the side, or from a half-finished exit: start from where a new list starts. */\n      if (opacity.get() < 1) {\n        x.jump(dir * (travel / 2));\n        opacity.jump(0);\n      }\n      runs.push(animate(x, 0, enter), animate(opacity, 1, enter));\n    } else {\n      runs.push(animate(x, -dir * (travel / 4), exit));\n      runs.push(\n        animate(opacity, 0, {\n          ...exit,\n          onComplete: () => {\n            if (!alive) return;\n            setSettled(true);\n            latest.current.onGone?.();\n          },\n        }),\n      );\n    }\n    return () => {\n      alive = false;\n      runs.forEach((r) => r.stop());\n    };\n  }, [shown, present, x, opacity]);\n\n  const phase: Phase = shown ? \"current\" : settled ? \"hidden\" : \"leaving\";\n  return (\n    <ViewCtx.Provider value={phase}>\n      <motion.div\n        style={{ x, opacity }}\n        inert={phase !== \"current\"}\n        className={\n          \"p-1 \" + (phase === \"hidden\" ? \"hidden\" : phase === \"leaving\" ? \"pointer-events-none absolute inset-x-0 top-0\" : \"\")\n        }\n      >\n        {children}\n      </motion.div>\n    </ViewCtx.Provider>\n  );\n}\n\n/* ----------------------------------------------------------------- panel */\n\nexport type MenuPanelProps = {\n  /** The Radix parts to build from: a root panel passes its family's, a submenu's panel inherits them. */\n  parts?: MenuParts;\n  /** A submenu's panel: solid, never glass on glass, and never a drill of its own. */\n  sub?: boolean;\n  /** The menu's accessible name. */\n  label?: string;\n  /** The text of the Back row that leaves a submenu on a phone. */\n  backLabel?: string;\n  /** The side of the trigger it opens on first. A context menu ignores it. */\n  side?: \"top\" | \"bottom\";\n  /** Which edge of the trigger it lines up with. A context menu ignores it. */\n  align?: \"start\" | \"center\" | \"end\";\n  /** Gap between the trigger and the panel, in px. A context menu ignores it. */\n  sideOffset?: number;\n  /** Flip against this element instead of the viewport. */\n  collisionBoundary?: Element | null;\n  /** The rows. */\n  children: ReactNode;\n};\n\n/**\n * The animated surface for a menu, a context menu and a submenu. Render it inside its family's Portal with\n * forceMount, under AnimatePresence, as Menu and ContextMenu do.\n */\nexport function MenuPanel({\n  parts: own,\n  sub = false,\n  label,\n  backLabel = \"Back\",\n  side,\n  align,\n  sideOffset,\n  collisionBoundary,\n  children,\n}: MenuPanelProps) {\n  const inherited = useParts();\n  const P = own ?? inherited;\n  /* The material's motion language (@/motion/deal): it grows from om.grow, and under Device it switches on and off\n     in steps where it stands, with no slide. */\n  const om = useOverlayMotion();\n  const springIn = useTransition(\"responsive\");\n  const springOut = useExit(\"responsive\");\n  const enter = om.switches ? om.switchIn : springIn;\n  const exit = om.switches ? om.switchOut : springOut;\n  const fullTravel = useTravel();\n  const travel = om.slides ? fullTravel : 0;\n  const latest = useRef(enter);\n  latest.current = enter;\n  const { compact, coarse } = useDevice();\n  /* On a phone the menu's submenus are lists inside this panel, not panels of their own. */\n  const drilling = compact && !sub;\n\n  const x = useMotionValue(0);\n  const y = useMotionValue(0);\n  const el = useRef<HTMLDivElement | null>(null);\n  /* null until Radix has placed the panel; then the side it really opened on. */\n  const [placed, setPlaced] = useState<Side | null>(null);\n  const subOpen = useRef(false);\n\n  const glide = useGlide({ focusRows: true, keepOnLeave: () => subOpen.current });\n  const glideRef = glide.ref;\n\n  /* The glide measures rows against the box that holds them: the panel, or on a phone the inner box that clips. */\n  const scroller = useRef<HTMLDivElement | null>(null);\n  const setPanel = useCallback(\n    (node: HTMLDivElement | null) => {\n      el.current = node;\n      if (!drilling) glideRef(node);\n    },\n    [glideRef, drilling],\n  );\n  const setScroller = useCallback(\n    (node: HTMLDivElement | null) => {\n      scroller.current = node;\n      if (drilling) glideRef(node);\n    },\n    [glideRef, drilling],\n  );\n\n  /* The drill: which submenus are open, which way the last move went, and where the next list renders. */\n  const [slot, setSlot] = useState<HTMLDivElement | null>(null);\n  const [stack, setStack] = useState<string[]>([]);\n  const [dir, setDir] = useState<1 | -1>(1);\n  const stackRef = useRef(stack);\n  stackRef.current = stack;\n  const triggers = useRef(new Map<string, HTMLElement>());\n  const input = useRef<\"key\" | \"pointer\">(\"pointer\");\n  const moved = useRef<{ via: \"key\" | \"pointer\"; to: HTMLElement | null } | null>(null);\n\n  const push = useCallback((id: string, trigger: HTMLElement | null) => {\n    if (trigger) triggers.current.set(id, trigger);\n    moved.current = { via: input.current, to: null };\n    setDir(1);\n    setStack((s) => [...s, id]);\n  }, []);\n  const pop = useCallback(() => {\n    const id = stackRef.current[stackRef.current.length - 1];\n    if (id === undefined) return;\n    moved.current = { via: input.current, to: triggers.current.get(id) ?? null };\n    setDir(-1);\n    setStack((s) => s.slice(0, -1));\n  }, []);\n\n  useEffect(() => {\n    if (!drilling) setStack([]);\n  }, [drilling]);\n\n  /* After the lists have swapped: start at the top, let go of the old highlight, and put focus where it\n     belongs. A key moved us, so a row has it (the Back row going in, the opening row going back) and shows\n     the ring. A finger or a mouse moved us, so the panel has it and nothing is lit. */\n  useLayoutEffect(() => {\n    const m = moved.current;\n    if (!m) return;\n    moved.current = null;\n    if (scroller.current) scroller.current.scrollTop = 0;\n    glide.hide();\n    if (m.via === \"pointer\") el.current?.focus({ preventScroll: true });\n    else (m.to ?? scroller.current?.querySelector<HTMLElement>(\"[data-glide-row]\"))?.focus({ preventScroll: true });\n  }, [stack]);\n\n  /* A quarter of the travel, starting nearer the trigger than the resting place. */\n  const offset = (s: Side) => {\n    const d = travel / 4;\n    return { x: s === \"left\" ? d : s === \"right\" ? -d : 0, y: s === \"top\" ? d : s === \"bottom\" ? -d : 0 };\n  };\n\n  const onPlaced = () => {\n    const s = (el.current?.dataset.side as Side | undefined) ?? \"bottom\";\n    const o = offset(s);\n    x.jump(o.x);\n    y.jump(o.y);\n    setPlaced(s);\n  };\n\n  useEffect(() => {\n    if (!placed) return;\n    animate(x, 0, latest.current);\n    animate(y, 0, latest.current);\n  }, [placed, x, y]);\n\n  const out = offset(placed ?? (sub ? \"right\" : \"bottom\"));\n  const drill: Drill | null = drilling ? { stack, dir, slot, backLabel, push, pop } : null;\n  const ctx: PanelContext = {\n    glide,\n    setSubOpen: (open) => {\n      subOpen.current = open;\n    },\n    releaseSub: glide.release,\n    drill,\n    coarse,\n  };\n\n  /* Radix forwards onPlaced to its popper, but the menu types omit it. */\n  const placedProp = { onPlaced } as Record<string, unknown>;\n\n  const gp = glide.props;\n  const motionProps = {\n    ref: setPanel,\n    ...gp,\n    onKeyDownCapture: (e: KeyboardEvent) => {\n      input.current = \"key\";\n      gp.onKeyDownCapture(e);\n    },\n    onPointerDownCapture: (e: PointerEvent) => {\n      input.current = \"pointer\";\n      /* A finger's press shows on the row itself (its own fill), so the glide does not light it as well. */\n      if (coarse && e.pointerType === \"touch\") return;\n      gp.onPointerDownCapture(e);\n    },\n    /* ArrowLeft goes back, as it closes a fly-out submenu. */\n    onKeyDown: (e: KeyboardEvent) => {\n      if (!drilling || e.key !== \"ArrowLeft\" || stackRef.current.length === 0) return;\n      e.preventDefault();\n      pop();\n    },\n    initial: { opacity: 0, scale: om.grow },\n    animate: { opacity: placed ? 1 : 0, scale: placed ? 1 : om.grow, transition: enter },\n    exit: { opacity: 0, scale: om.grow, x: out.x, y: out.y, transition: exit },\n    style: { x, y },\n  };\n  const surface =\n    \"relative z-50 min-w-60 rounded-litt-surface outline-none in-[[data-material=flat]]:[&>[data-ring]]:rounded-[calc(var(--litt-radius-surface)-4px)] \" +\n    P.origin +\n    \" data-[state=closed]:pointer-events-none \" +\n    (drilling ? \"\" : \"p-1 \");\n\n  /* A glass surface cannot clip: its shadow lives on ::before. So on a phone an inner box clips the list that is\n     leaving, and scrolls a list taller than the screen. The lists carry the 4px inset, so the panel has none. */\n  const body = drill ? (\n    <div\n      ref={setScroller}\n      className={\"relative \" + P.fit + \" overflow-x-hidden overflow-y-auto overscroll-contain rounded-litt-surface\"}\n    >\n      {glide.highlight}\n      <DrillView current={stack.length === 0}>\n        {children}\n      </DrillView>\n      <div ref={setSlot} />\n    </div>\n  ) : (\n    <>\n      {glide.highlight}\n      {children}\n    </>\n  );\n\n  return (\n    <PartsCtx.Provider value={P}>\n      <PanelCtx.Provider value={ctx}>\n        {sub ? (\n          <P.SubContent\n            forceMount\n            {...asChildProps}\n            sideOffset={4}\n            alignOffset={-4}\n            collisionPadding={8}\n            loop\n            {...placedProp}\n          >\n            <motion.div {...motionProps} className={surface + \"bg-litt-surface-2 shadow-litt-float in-[[data-material=flat]]:bg-litt-surface-1\"}>\n              {body}\n            </motion.div>\n          </P.SubContent>\n        ) : (\n          <P.Content\n            forceMount\n            {...asChildProps}\n            aria-label={label}\n            /* Radix names the menu by its trigger (aria-labelledby), which beats aria-label.\n               A label you pass should win, so clear it then. */\n            {...(label ? { \"aria-labelledby\": undefined } : {})}\n            /* Only what is set: ContextMenu passes none, so Radix's own \"right of the pointer\" stands. Radix 2.4\n               spreads our props after its defaults, so an explicit undefined would erase them and centre the panel\n               on the pointer, where releasing the right button picks a row. */\n            {...(side ? { side } : {})}\n            {...(align ? { align } : {})}\n            {...(sideOffset !== undefined ? { sideOffset } : {})}\n            collisionPadding={8}\n            collisionBoundary={collisionBoundary ?? undefined}\n            loop\n            {...placedProp}\n          >\n            <motion.div {...motionProps} className={surface + \"litt-glass \" + FLAT_PANEL}>\n              {body}\n            </motion.div>\n          </P.Content>\n        )}\n      </PanelCtx.Provider>\n    </PartsCtx.Provider>\n  );\n}\n\n/* ------------------------------------------------------------------ root */\n\nexport type MenuProps = {\n  /** The element that opens the menu. It receives the Radix trigger props. */\n  trigger: ReactElement;\n  /** The menu's accessible name. Without it, the menu is named by its trigger, so name the trigger either way. */\n  label?: string;\n  /** The side of the trigger the menu opens on first. It flips to the other side when there is no room. */\n  side?: \"top\" | \"bottom\";\n  /** Which edge of the trigger the menu lines up with. */\n  align?: \"start\" | \"center\" | \"end\";\n  /** Gap between the trigger and the menu, in px. */\n  sideOffset?: number;\n  /** Flip against this element instead of the viewport (a stage, a card). */\n  collisionBoundary?: Element | null;\n  /** The text of the Back row that leaves a submenu on a phone, where a submenu replaces the list instead of flying out. Translate it with the rest of your labels. */\n  backLabel?: string;\n  /** The rows: MenuItem, MenuSub, MenuCheckboxItem, MenuRadioGroup, MenuLabel, MenuGroup and MenuSeparator. */\n  children: ReactNode;\n};\n\n/** The root. It wraps your trigger and the rows, and owns its open state, so there is no open prop. */\nexport function Menu({\n  trigger,\n  label,\n  side = \"bottom\",\n  align = \"start\",\n  sideOffset = 8,\n  collisionBoundary,\n  backLabel = \"Back\",\n  children,\n}: MenuProps) {\n  const [open, setOpen] = useState(false);\n  return (\n    /* Not modal: a modal menu locks the page's scroll and stops pointer events on the body, and either one makes the\n       browser restyle the whole page as the menu opens, which stalls its first frames. A press outside closes it\n       (and reaches what is there); focus, Esc and typeahead are as before. */\n    <DropdownMenu.Root open={open} onOpenChange={setOpen} modal={false}>\n      <DropdownMenu.Trigger {...asChildProps}>{trigger}</DropdownMenu.Trigger>\n      <AnimatePresence>\n        {open && (\n          <DropdownMenu.Portal forceMount key=\"menu\">\n            <MenuPanel\n              parts={DROPDOWN_PARTS}\n              label={label}\n              backLabel={backLabel}\n              side={side}\n              align={align}\n              sideOffset={sideOffset}\n              collisionBoundary={collisionBoundary}\n            >\n              {children}\n            </MenuPanel>\n          </DropdownMenu.Portal>\n        )}\n      </AnimatePresence>\n    </DropdownMenu.Root>\n  );\n}\n",
      "target": "components/litt/menu.tsx"
    }
  ],
  "docs": "Import Menu and MenuItem from components/litt/menu. Its submenus are solid, never glass on glass.",
  "meta": {
    "version": "2026.11.06",
    "released": "2026-11-06",
    "changelog": "https://littmd.vercel.app/changelog.md"
  }
}
