{
  "name": "marking-menu",
  "type": "registry:component",
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "files": [
    {
      "type": "registry:component",
      "content": "\"use client\";\n\n/**\n * @author: @dorianbaffier\n * @description: Marking Menu\n * @version: 1.0.0\n * @date: 2026-10-14\n * @license: MIT\n * @website: https://kokonutui.com\n * @github: https://github.com/kokonut-labs/kokonutui\n */\n\nimport {\n  CalendarDays,\n  Folder,\n  House,\n  Inbox,\n  type LucideIcon,\n  PenLine,\n  Search,\n  Settings,\n  User,\n} from \"lucide-react\";\nimport { AnimatePresence, animate, motion, useMotionValue } from \"motion/react\";\nimport {\n  type KeyboardEvent as ReactKeyboardEvent,\n  type MouseEvent as ReactMouseEvent,\n  type ReactNode,\n  type PointerEvent as ReactPointerEvent,\n  useCallback,\n  useEffect,\n  useId,\n  useRef,\n  useState,\n} from \"react\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface MarkingMenuItem {\n  id: string;\n  label: string;\n  icon: LucideIcon;\n}\n\nexport interface MarkingMenuProps {\n  /** Up to eight destinations, laid out clockwise from the top. */\n  items?: MarkingMenuItem[];\n  /** Called with the item picked, by menu, flick or keyboard. */\n  onSelect?: (item: MarkingMenuItem) => void;\n  /** The surface the menu opens over. Defaults to a small demo page. */\n  children?: ReactNode;\n  className?: string;\n}\n\nconst DEFAULT_ITEMS: MarkingMenuItem[] = [\n  { id: \"home\", label: \"Home\", icon: House },\n  { id: \"inbox\", label: \"Inbox\", icon: Inbox },\n  { id: \"calendar\", label: \"Calendar\", icon: CalendarDays },\n  { id: \"files\", label: \"Files\", icon: Folder },\n  { id: \"search\", label: \"Search\", icon: Search },\n  { id: \"compose\", label: \"Compose\", icon: PenLine },\n  { id: \"profile\", label: \"Profile\", icon: User },\n  { id: \"settings\", label: \"Settings\", icon: Settings },\n];\n\n// Hold this long without moving and the menu appears under the finger.\n// Move further than FLICK first and it never does: the stroke alone picks.\nconst HOLD_MS = 180;\nconst FLICK = 36;\n// Inside this radius nothing is picked; letting go there cancels.\nconst DEAD_ZONE = 24;\n// Geometry of the disc, in px.\nconst DISC = 112;\nconst RING = 78;\nconst HUB = 30;\n// Selected items lean towards the pointer by this much.\nconst LEAN = 7;\n// How long the stroke's tail stays on screen.\nconst TRAIL_MS = 260;\nconst EASE_OUT = [0.23, 1, 0.32, 1] as const;\n\n// Arrow keys walk round the ring: right and down go clockwise.\nconst MOVE_KEYS: Record<string, number> = {\n  ArrowRight: 1,\n  ArrowDown: 1,\n  ArrowLeft: -1,\n  ArrowUp: -1,\n};\n\ntype Mode = \"press\" | \"sticky\";\n\ninterface OpenMenu {\n  // Where the disc is drawn: the press point, nudged inside the surface.\n  x: number;\n  y: number;\n  mode: Mode;\n  fromKeyboard: boolean;\n}\n\ninterface Gesture {\n  id: number;\n  x: number;\n  y: number;\n  stage: \"pending\" | \"menu\" | \"flick\";\n  timer: number;\n}\n\nconst round = (n: number) => Math.round(n * 100) / 100;\n\n/** Item index for a direction, clockwise from the top, or -1 if too short. */\nfunction pick(dx: number, dy: number, count: number) {\n  if (Math.hypot(dx, dy) < DEAD_ZONE) {\n    return -1;\n  }\n  const angle = (Math.atan2(dy, dx) * 180) / Math.PI + 90;\n  const step = 360 / count;\n  return Math.round((((angle % 360) + 360) % 360) / step) % count;\n}\n\n/** A ring sector pointing up, spanning `span` degrees. */\nfunction sectorPath(span: number) {\n  const half = ((span / 2 - 1.5) * Math.PI) / 180;\n  const inner = HUB + 5;\n  const outer = DISC - 5;\n  const point = (r: number, a: number) =>\n    `${round(r * Math.sin(a))} ${round(-r * Math.cos(a))}`;\n  return [\n    `M${point(inner, -half)}`,\n    `L${point(outer, -half)}`,\n    `A${outer} ${outer} 0 0 1 ${point(outer, half)}`,\n    `L${point(inner, half)}`,\n    `A${inner} ${inner} 0 0 0 ${point(inner, -half)}`,\n    \"Z\",\n  ].join(\" \");\n}\n\nfunction haptic() {\n  if (typeof navigator !== \"undefined\" && \"vibrate\" in navigator) {\n    navigator.vibrate(4);\n  }\n}\n\nexport default function MarkingMenu({\n  items = DEFAULT_ITEMS,\n  onSelect,\n  children,\n  className,\n}: MarkingMenuProps) {\n  const list = items.slice(0, 8);\n  const count = list.length;\n  const step = 360 / count;\n  const menuId = useId();\n\n  const surfaceRef = useRef<HTMLDivElement>(null);\n  const triggerRef = useRef<HTMLButtonElement>(null);\n  const menuRef = useRef<HTMLDivElement>(null);\n  const aimRef = useRef<HTMLDivElement>(null);\n  const trailRef = useRef<SVGPathElement>(null);\n  const gestureRef = useRef<Gesture | null>(null);\n  const trailPoints = useRef<{ x: number; y: number; t: number }[]>([]);\n  const trailFrame = useRef(0);\n\n  const [open, setOpen] = useState<OpenMenu | null>(null);\n  const [selected, setSelected] = useState(-1);\n  const [current, setCurrent] = useState(list[0]);\n  const [flash, setFlash] = useState<{\n    item: MarkingMenuItem;\n    x: number;\n    y: number;\n    key: number;\n  } | null>(null);\n\n  // Live mirrors for pointer handlers, which must not wait for a render.\n  const openRef = useRef(open);\n  openRef.current = open;\n  const selectedRef = useRef(selected);\n  selectedRef.current = selected;\n\n  // The highlight swings round to the picked slice on a spring, always the\n  // short way.\n  const wedge = useMotionValue(0);\n\n  useEffect(() => {\n    if (selected < 0) {\n      return;\n    }\n    const from = wedge.get();\n    const to = selected * step;\n    const delta = ((((to - from) % 360) + 540) % 360) - 180;\n    const controls = animate(wedge, from + delta, {\n      type: \"spring\",\n      stiffness: 520,\n      damping: 36,\n    });\n    return () => controls.stop();\n  }, [selected, step, wedge]);\n\n  const local = useCallback((event: { clientX: number; clientY: number }) => {\n    const rect = surfaceRef.current?.getBoundingClientRect();\n    return {\n      x: event.clientX - (rect?.left ?? 0),\n      y: event.clientY - (rect?.top ?? 0),\n    };\n  }, []);\n\n  /** Where to draw the disc so it stays inside the surface. */\n  const place = useCallback((x: number, y: number) => {\n    const rect = surfaceRef.current?.getBoundingClientRect();\n    const w = rect?.width ?? 0;\n    const h = rect?.height ?? 0;\n    const pad = DISC + 8;\n    return {\n      x: w > pad * 2 ? Math.min(Math.max(x, pad), w - pad) : w / 2,\n      y: h > pad * 2 ? Math.min(Math.max(y, pad), h - pad) : h / 2,\n    };\n  }, []);\n\n  const select = useCallback((index: number) => {\n    if (index !== selectedRef.current) {\n      selectedRef.current = index;\n      setSelected(index);\n      if (index >= 0) {\n        haptic();\n      }\n    }\n  }, []);\n\n  /** Point the aim tick at the pointer and pick the slice it is in. */\n  const aim = useCallback(\n    (x: number, y: number) => {\n      const menu = openRef.current;\n      if (!menu) {\n        return;\n      }\n      const dx = x - menu.x;\n      const dy = y - menu.y;\n      const tick = aimRef.current;\n      if (tick) {\n        const away = Math.hypot(dx, dy) >= DEAD_ZONE;\n        tick.style.opacity = away ? \"1\" : \"0\";\n        tick.style.transform = `rotate(${Math.atan2(dy, dx) + Math.PI / 2}rad)`;\n      }\n      select(pick(dx, dy, count));\n    },\n    [count, select]\n  );\n\n  const close = useCallback(() => {\n    const menu = openRef.current;\n    openRef.current = null;\n    setOpen(null);\n    select(-1);\n    if (menu?.fromKeyboard) {\n      triggerRef.current?.focus();\n    }\n  }, [select]);\n\n  const commit = useCallback(\n    (index: number, at?: { x: number; y: number }) => {\n      const item = list[index];\n      if (item) {\n        setCurrent(item);\n        onSelect?.(item);\n        if (at) {\n          setFlash({ item, ...at, key: performance.now() });\n        }\n      }\n      close();\n    },\n    [list, onSelect, close]\n  );\n\n  const show = useCallback(\n    (x: number, y: number, mode: Mode, fromKeyboard = false) => {\n      const at = place(x, y);\n      const menu = { ...at, mode, fromKeyboard };\n      openRef.current = menu;\n      setOpen(menu);\n      select(fromKeyboard ? 0 : -1);\n    },\n    [place, select]\n  );\n\n  // The stroke the pointer leaves while pressed, eaten from its tail.\n  const drawTrail = useCallback(() => {\n    trailFrame.current = 0;\n    const now = performance.now();\n    const points = trailPoints.current;\n    while (points.length > 0 && now - points[0].t > TRAIL_MS) {\n      points.shift();\n    }\n    const path = trailRef.current;\n    if (path) {\n      path.setAttribute(\n        \"d\",\n        points\n          .map((p, i) => `${i === 0 ? \"M\" : \"L\"}${round(p.x)} ${round(p.y)}`)\n          .join(\"\")\n      );\n    }\n    if (points.length > 0) {\n      trailFrame.current = requestAnimationFrame(drawTrail);\n    }\n  }, []);\n\n  const addTrail = useCallback(\n    (x: number, y: number) => {\n      trailPoints.current.push({ x, y, t: performance.now() });\n      if (!trailFrame.current) {\n        trailFrame.current = requestAnimationFrame(drawTrail);\n      }\n    },\n    [drawTrail]\n  );\n\n  useEffect(\n    () => () => {\n      cancelAnimationFrame(trailFrame.current);\n      window.clearTimeout(gestureRef.current?.timer);\n    },\n    []\n  );\n\n  const onPointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {\n    if (event.button !== 0 || gestureRef.current) {\n      return;\n    }\n    if ((event.target as HTMLElement).closest(\"[data-marking-trigger]\")) {\n      return;\n    }\n    const p = local(event);\n    const menu = openRef.current;\n    if (menu?.mode === \"sticky\") {\n      // A sticky menu (right click, keyboard) is picked with a click.\n      aim(p.x, p.y);\n      commit(selectedRef.current);\n      return;\n    }\n    event.preventDefault();\n    event.currentTarget.setPointerCapture(event.pointerId);\n    const gesture: Gesture = {\n      id: event.pointerId,\n      x: p.x,\n      y: p.y,\n      stage: \"pending\",\n      timer: window.setTimeout(() => {\n        if (gestureRef.current === gesture && gesture.stage === \"pending\") {\n          gesture.stage = \"menu\";\n          show(gesture.x, gesture.y, \"press\");\n        }\n      }, HOLD_MS),\n    };\n    gestureRef.current = gesture;\n    addTrail(p.x, p.y);\n  };\n\n  const onPointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {\n    const p = local(event);\n    const gesture = gestureRef.current;\n    if (!gesture) {\n      if (openRef.current?.mode === \"sticky\") {\n        aim(p.x, p.y);\n      }\n      return;\n    }\n    if (event.pointerId !== gesture.id) {\n      return;\n    }\n    addTrail(p.x, p.y);\n    if (\n      gesture.stage === \"pending\" &&\n      Math.hypot(p.x - gesture.x, p.y - gesture.y) > FLICK\n    ) {\n      // Moving off before the menu shows: an expert stroke. No menu, the\n      // direction alone decides.\n      window.clearTimeout(gesture.timer);\n      gesture.stage = \"flick\";\n    }\n    if (gesture.stage === \"menu\") {\n      aim(p.x, p.y);\n    }\n  };\n\n  const onPointerUp = (event: ReactPointerEvent<HTMLDivElement>) => {\n    const gesture = gestureRef.current;\n    if (!gesture || event.pointerId !== gesture.id) {\n      return;\n    }\n    gestureRef.current = null;\n    window.clearTimeout(gesture.timer);\n    const p = local(event);\n    if (gesture.stage === \"menu\") {\n      aim(p.x, p.y);\n      commit(selectedRef.current);\n    } else if (gesture.stage === \"flick\") {\n      commit(pick(p.x - gesture.x, p.y - gesture.y, count), p);\n    }\n  };\n\n  const onPointerCancel = (event: ReactPointerEvent<HTMLDivElement>) => {\n    const gesture = gestureRef.current;\n    if (gesture && event.pointerId === gesture.id) {\n      window.clearTimeout(gesture.timer);\n      gestureRef.current = null;\n      close();\n    }\n  };\n\n  const onContextMenu = (event: ReactMouseEvent<HTMLDivElement>) => {\n    event.preventDefault();\n    const gesture = gestureRef.current;\n    if (gesture) {\n      window.clearTimeout(gesture.timer);\n      gestureRef.current = null;\n    }\n    const p = local(event);\n    show(p.x, p.y, \"sticky\");\n  };\n\n  const openFromKeyboard = () => {\n    const rect = surfaceRef.current?.getBoundingClientRect();\n    show((rect?.width ?? 0) / 2, (rect?.height ?? 0) / 2, \"sticky\", true);\n  };\n\n  useEffect(() => {\n    if (open?.fromKeyboard) {\n      menuRef.current?.focus();\n    }\n  }, [open]);\n\n  const onMenuKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {\n    const index = selectedRef.current;\n    const move = MOVE_KEYS[event.key];\n    const digit = Number.parseInt(event.key, 10);\n    if (move) {\n      event.preventDefault();\n      // From nothing picked, the first step lands on the top item.\n      let from = index;\n      if (index < 0) {\n        from = move > 0 ? -1 : 1;\n      }\n      select((from + move + count) % count);\n    } else if (event.key === \"Enter\" || event.key === \" \") {\n      event.preventDefault();\n      commit(index);\n    } else if (event.key === \"Escape\" || event.key === \"Tab\") {\n      close();\n    } else if (digit >= 1 && digit <= count) {\n      event.preventDefault();\n      commit(digit - 1);\n    }\n  };\n\n  const Current = current?.icon ?? House;\n  const active = selected >= 0 ? list[selected] : undefined;\n\n  return (\n    // biome-ignore lint/a11y/noStaticElementInteractions: a gesture surface; the Menu button is its keyboard path.\n    // biome-ignore lint/a11y/noNoninteractiveElementInteractions: same as above.\n    <div\n      className={cn(\n        \"relative h-[440px] w-full touch-none select-none overflow-hidden rounded-2xl border border-zinc-200 bg-zinc-50 text-zinc-900 dark:border-zinc-800 dark:bg-zinc-950 dark:text-zinc-100\",\n        open ? \"cursor-default\" : \"cursor-crosshair\",\n        className\n      )}\n      onContextMenu={onContextMenu}\n      onPointerCancel={onPointerCancel}\n      onPointerDown={onPointerDown}\n      onPointerMove={onPointerMove}\n      onPointerUp={onPointerUp}\n      ref={surfaceRef}\n    >\n      {children ?? (\n        <div className=\"pointer-events-none absolute inset-0 flex flex-col p-5\">\n          <div className=\"flex items-center gap-2.5\">\n            <span className=\"flex size-8 items-center justify-center rounded-lg border border-zinc-200 bg-white dark:border-zinc-800 dark:bg-zinc-900\">\n              <Current className=\"size-4\" strokeWidth={1.75} />\n            </span>\n            <AnimatePresence initial={false} mode=\"popLayout\">\n              <motion.span\n                animate={{ opacity: 1, filter: \"blur(0px)\" }}\n                className=\"font-medium text-sm\"\n                exit={{ opacity: 0, filter: \"blur(2px)\" }}\n                initial={{ opacity: 0, filter: \"blur(2px)\" }}\n                key={current?.id}\n                transition={{ duration: 0.18, ease: EASE_OUT }}\n              >\n                {current?.label}\n              </motion.span>\n            </AnimatePresence>\n          </div>\n          <div className=\"flex flex-1 flex-col items-center justify-center gap-1.5 text-center\">\n            <p className=\"text-sm text-zinc-500 dark:text-zinc-400\">\n              Press and hold anywhere, or flick\n            </p>\n            <p className=\"text-xs text-zinc-400 dark:text-zinc-500\">\n              Right click opens it too\n            </p>\n          </div>\n        </div>\n      )}\n\n      <svg\n        aria-hidden=\"true\"\n        className=\"pointer-events-none absolute inset-0 h-full w-full text-zinc-400 dark:text-zinc-600\"\n      >\n        <path\n          fill=\"none\"\n          ref={trailRef}\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth={2}\n        />\n      </svg>\n\n      <button\n        aria-controls={open ? menuId : undefined}\n        aria-expanded={Boolean(open)}\n        aria-haspopup=\"menu\"\n        className=\"absolute top-4 right-4 rounded-lg border border-zinc-200 bg-white px-2.5 py-1.5 font-medium text-xs text-zinc-600 transition-colors duration-150 ease-out hover:text-zinc-900 focus-visible:outline-2 focus-visible:outline-zinc-400 focus-visible:outline-offset-2 dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100\"\n        data-marking-trigger=\"\"\n        onClick={openFromKeyboard}\n        ref={triggerRef}\n        type=\"button\"\n      >\n        Menu\n      </button>\n\n      <AnimatePresence>\n        {open ? (\n          <motion.div\n            animate={{ opacity: 1, scale: 1 }}\n            aria-activedescendant={\n              active ? `${menuId}-${active.id}` : undefined\n            }\n            aria-label=\"Go to\"\n            className=\"absolute outline-none\"\n            exit={{ opacity: 0, scale: 0.97, transition: { duration: 0.12 } }}\n            id={menuId}\n            initial={{ opacity: 0, scale: 0.94 }}\n            key=\"menu\"\n            onKeyDown={onMenuKeyDown}\n            ref={menuRef}\n            role=\"menu\"\n            style={{\n              left: open.x - DISC,\n              top: open.y - DISC,\n              width: DISC * 2,\n              height: DISC * 2,\n            }}\n            tabIndex={-1}\n            transition={{ duration: 0.16, ease: EASE_OUT }}\n          >\n            <div className=\"absolute inset-0 rounded-full border border-zinc-200 bg-white shadow-[0_8px_30px_rgb(0_0_0/0.08)] dark:border-zinc-800 dark:bg-zinc-900 dark:shadow-[0_8px_30px_rgb(0_0_0/0.5)]\" />\n            <motion.div\n              animate={{ opacity: selected >= 0 ? 1 : 0 }}\n              aria-hidden=\"true\"\n              className=\"absolute inset-0\"\n              initial={{ opacity: 0 }}\n              style={{ rotate: wedge }}\n              transition={{ duration: 0.12 }}\n            >\n              <svg\n                aria-hidden=\"true\"\n                className=\"overflow-visible\"\n                height={DISC * 2}\n                viewBox={`${-DISC} ${-DISC} ${DISC * 2} ${DISC * 2}`}\n                width={DISC * 2}\n              >\n                <path\n                  className=\"fill-zinc-100 dark:fill-zinc-800\"\n                  d={sectorPath(step)}\n                />\n              </svg>\n            </motion.div>\n\n            {list.map((item, index) => {\n              const angle = ((index * step - 90) * Math.PI) / 180;\n              const cos = Math.cos(angle);\n              const sin = Math.sin(angle);\n              const isSelected = index === selected;\n              const Icon = item.icon;\n              return (\n                <motion.div\n                  animate={{\n                    opacity: 1,\n                    x: round(cos * (RING + (isSelected ? LEAN : 0))),\n                    y: round(sin * (RING + (isSelected ? LEAN : 0))),\n                  }}\n                  aria-label={item.label}\n                  className={cn(\n                    \"absolute top-1/2 left-1/2 -mt-5 -ml-5 flex size-10 items-center justify-center rounded-full transition-colors duration-150 ease-out\",\n                    isSelected\n                      ? \"text-zinc-900 dark:text-white\"\n                      : \"text-zinc-400 dark:text-zinc-500\"\n                  )}\n                  id={`${menuId}-${item.id}`}\n                  initial={{\n                    opacity: 0,\n                    x: round(cos * RING * 0.55),\n                    y: round(sin * RING * 0.55),\n                  }}\n                  key={item.id}\n                  role=\"menuitem\"\n                  transition={{\n                    type: \"spring\",\n                    stiffness: 600,\n                    damping: 34,\n                    delay: open.mode === \"press\" ? index * 0.015 : 0,\n                  }}\n                >\n                  <Icon className=\"size-[18px]\" strokeWidth={1.75} />\n                </motion.div>\n              );\n            })}\n\n            {/* The hub names the slice; the tick on its rim follows the\n                pointer exactly, while the highlight springs after it. */}\n            <div\n              className=\"absolute top-1/2 left-1/2 flex items-center justify-center rounded-full border border-zinc-200 bg-zinc-50 dark:border-zinc-800 dark:bg-zinc-950\"\n              style={{\n                width: HUB * 2,\n                height: HUB * 2,\n                marginLeft: -HUB,\n                marginTop: -HUB,\n              }}\n            >\n              <div\n                className=\"absolute inset-0 opacity-0 transition-opacity duration-100\"\n                ref={aimRef}\n              >\n                <span className=\"absolute top-[3px] left-1/2 size-1 -translate-x-1/2 rounded-full bg-zinc-900 dark:bg-zinc-100\" />\n              </div>\n              <AnimatePresence initial={false} mode=\"popLayout\">\n                <motion.span\n                  animate={{ opacity: 1, filter: \"blur(0px)\" }}\n                  className=\"font-medium text-[11px] text-zinc-600 dark:text-zinc-300\"\n                  exit={{ opacity: 0, filter: \"blur(2px)\" }}\n                  initial={{ opacity: 0, filter: \"blur(2px)\" }}\n                  key={active?.id ?? \"none\"}\n                  transition={{ duration: 0.12, ease: EASE_OUT }}\n                >\n                  {active?.label ?? \"\"}\n                </motion.span>\n              </AnimatePresence>\n            </div>\n          </motion.div>\n        ) : null}\n      </AnimatePresence>\n\n      {/* After a flick, the pick shows briefly where the stroke ended, so\n          the hand learns the direction without ever seeing the menu. */}\n      <AnimatePresence>\n        {flash ? (\n          <motion.div\n            animate={{ opacity: [0, 1, 1, 0], y: [4, 0, 0, -4] }}\n            className=\"pointer-events-none absolute flex items-center gap-1.5 rounded-full border border-zinc-200 bg-white px-2.5 py-1 font-medium text-xs shadow-sm dark:border-zinc-800 dark:bg-zinc-900\"\n            key={flash.key}\n            onAnimationComplete={() => setFlash(null)}\n            style={{ left: flash.x + 12, top: flash.y - 32 }}\n            transition={{ duration: 0.9, times: [0, 0.15, 0.7, 1] }}\n          >\n            <flash.item.icon className=\"size-3.5\" strokeWidth={1.75} />\n            {flash.item.label}\n          </motion.div>\n        ) : null}\n      </AnimatePresence>\n    </div>\n  );\n}\n",
      "path": "/components/kokonutui/marking-menu.tsx",
      "target": "components/kokonutui/marking-menu.tsx"
    }
  ]
}