{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"menu-wheel","type":"registry:block","title":"Menu wheel","description":"An interactive, animated radial menu that smoothly expands to reveal options in a circular layout. Powered by Motion, it features a fluid 'press, hover, and release' interaction model, making it perfect for housing grouped actions or quick-access settings in a compact, modern UI.","dependencies":["motion"],"files":[{"path":"registry/100xui/blocks/menu-wheel/components/menu-wheel.tsx","content":"\"use client\";\r\n\r\nimport {\r\n  AnimatePresence,\r\n  motion,\r\n  MotionConfig,\r\n  type MotionProps,\r\n  type Transition,\r\n  useAnimate,\r\n} from \"motion/react\";\r\nimport React, {\r\n  type ComponentProps,\r\n  createContext,\r\n  useContext,\r\n  useEffect,\r\n  useRef,\r\n  useState,\r\n} from \"react\";\r\nimport { XIcon } from \"lucide-react\";\r\n\r\nimport { cn } from \"@/lib/utils\";\r\n\r\nconst TRANSITION: Transition = {\r\n  ease: \"easeOut\",\r\n  duration: 0.25,\r\n};\r\n\r\ntype MenuWheelContextType = {\r\n  open: boolean;\r\n  setOpen: React.Dispatch<React.SetStateAction<boolean>>;\r\n  activeValue: string | null;\r\n  setActiveValue: React.Dispatch<React.SetStateAction<string | null>>;\r\n  showCurrent: boolean;\r\n  onValueChange?: (value: string) => void;\r\n};\r\n\r\nconst MenuWheelContext = createContext<MenuWheelContextType | null>(null);\r\n\r\nexport function useMenuWheelContext() {\r\n  const ctx = useContext(MenuWheelContext);\r\n  if (!ctx) {\r\n    throw new Error(\"MenuWheel components must be used within a <MenuWheel/>\");\r\n  }\r\n  return ctx;\r\n}\r\n\r\ntype MenuWheelContainerContextType = {\r\n  totalItems: number;\r\n  moveHighlight: (index: number) => void;\r\n  clearHighlight: () => void;\r\n};\r\n\r\nconst MenuWheelContainerContext =\r\n  createContext<MenuWheelContainerContextType | null>(null);\r\n\r\nexport function useMenuWheelContainerContext() {\r\n  const ctx = useContext(MenuWheelContainerContext);\r\n  if (!ctx) {\r\n    throw new Error(\r\n      \"MenuWheelItem components must be used within a <MenuWheelContainer/>\"\r\n    );\r\n  }\r\n  return ctx;\r\n}\r\n\r\ninterface MenuWheelProps extends ComponentProps<\"div\"> {\r\n  showCurrent?: boolean; // Whether to highlight the currently selected item\r\n  defaultValue?: string; // The default value to be selected when the component mounts\r\n  onValueChange?: (value: string) => void; // Callback function to handle value changes\r\n}\r\n\r\nexport function MenuWheel({\r\n  children,\r\n  className,\r\n  showCurrent = true,\r\n  defaultValue,\r\n  onValueChange,\r\n  ...props\r\n}: MenuWheelProps) {\r\n  const [open, setOpen] = useState(false);\r\n  const [activeValue, setActiveValue] = useState<string | null>(\r\n    defaultValue ?? null\r\n  );\r\n\r\n  useEffect(() => {\r\n    const handleMouseUp = () => {\r\n      setOpen(false);\r\n    };\r\n    if (open) {\r\n      window.addEventListener(\"mouseup\", handleMouseUp);\r\n    }\r\n    return () => window.removeEventListener(\"mouseup\", handleMouseUp);\r\n  }, [open]);\r\n\r\n  return (\r\n    <MenuWheelContext.Provider\r\n      value={{\r\n        open,\r\n        setOpen,\r\n        activeValue,\r\n        setActiveValue,\r\n        showCurrent,\r\n        onValueChange,\r\n      }}\r\n    >\r\n      <MotionConfig transition={TRANSITION}>\r\n        <div\r\n          onPointerDown={(e) => {\r\n            setOpen((prev) => !prev);\r\n            props.onPointerDown?.(e);\r\n          }}\r\n          onTouchEnd={(e) => {\r\n            // prevent onMouseUp trigger.\r\n            e.preventDefault();\r\n          }}\r\n          onMouseUp={(e) => {\r\n            setOpen(false);\r\n            props.onMouseUp?.(e);\r\n          }}\r\n          className={cn(\r\n            \"relative isolate grid size-12 place-items-center rounded-full\",\r\n            open ? \"*:cursor-grabbing\" : \"*:cursor-grab\",\r\n            className\r\n          )}\r\n          {...props}\r\n        >\r\n          {children}\r\n        </div>\r\n      </MotionConfig>\r\n    </MenuWheelContext.Provider>\r\n  );\r\n}\r\n\r\nexport function MenuWheelTrigger({\r\n  cancel = <XIcon />,\r\n  children,\r\n  className,\r\n  ...props\r\n}: {\r\n  cancel?: React.ReactNode; // The content to display when the menu wheel is open (default is an X Icon)\r\n} & React.ComponentProps<\"div\"> &\r\n  MotionProps) {\r\n  const { open } = useMenuWheelContext();\r\n  return (\r\n    <AnimatePresence mode=\"popLayout\" initial={false}>\r\n      {!open ? (\r\n        <motion.div\r\n          key=\"open\"\r\n          aria-label=\"open menu wheel\"\r\n          initial={{ scale: 0, opacity: 0.5 }}\r\n          animate={{ scale: 1, opacity: 1 }}\r\n          exit={{ scale: 0, opacity: 0.5 }}\r\n          className={cn(\r\n            \"bg-secondary text-secondary-foreground relative z-20 grid size-full place-items-center rounded-full\",\r\n            className\r\n          )}\r\n          {...props}\r\n        >\r\n          {children}\r\n        </motion.div>\r\n      ) : (\r\n        <motion.div\r\n          key=\"close\"\r\n          aria-label=\"close menu wheel\"\r\n          initial={{ scale: 0, opacity: 0.5 }}\r\n          animate={{ scale: 1, opacity: 1 }}\r\n          exit={{ scale: 0, opacity: 0.5 }}\r\n          className=\"bg-destructive text-destructive-foreground z-20 grid size-full place-items-center rounded-full\"\r\n        >\r\n          {cancel}\r\n        </motion.div>\r\n      )}\r\n    </AnimatePresence>\r\n  );\r\n}\r\n\r\nexport function MenuWheelContainer({\r\n  children,\r\n  className,\r\n  ...props\r\n}: React.ComponentProps<\"div\"> & MotionProps) {\r\n  const { open } = useMenuWheelContext();\r\n  const [scope, animate] = useAnimate();\r\n  const maybeHoveredItem = useRef<\r\n    { exists: false } | { exists: true; index: number; angle: number }\r\n  >({ exists: false });\r\n\r\n  const validChildren = React.Children.toArray(children).filter(\r\n    React.isValidElement\r\n  );\r\n  const totalItems = validChildren.length;\r\n\r\n  const moveHighlight = (newHoveredItem: number) => {\r\n    if (!maybeHoveredItem.current.exists) {\r\n      const rotate = (newHoveredItem * 360) / totalItems;\r\n      const enterAnimation = async () => {\r\n        await animate(scope.current, { rotate }, { duration: 0 });\r\n        animate(scope.current, { opacity: 1 }, TRANSITION);\r\n      };\r\n      enterAnimation();\r\n      maybeHoveredItem.current = {\r\n        exists: true,\r\n        index: newHoveredItem,\r\n        angle: rotate,\r\n      };\r\n    } else {\r\n      let forwardPath = 0,\r\n        backwardPath = 0;\r\n      const currentAngle = maybeHoveredItem.current.angle;\r\n      const currentIndex = maybeHoveredItem.current.index;\r\n\r\n      for (\r\n        let i = currentIndex;\r\n        i != newHoveredItem;\r\n        i = (i + 1) % totalItems\r\n      ) {\r\n        forwardPath += 360 / totalItems;\r\n      }\r\n      for (\r\n        let j = currentIndex;\r\n        j != newHoveredItem;\r\n        j = (j - 1 + totalItems) % totalItems\r\n      ) {\r\n        backwardPath -= 360 / totalItems;\r\n      }\r\n\r\n      const shortestPath =\r\n        Math.abs(forwardPath) <= Math.abs(backwardPath)\r\n          ? forwardPath\r\n          : backwardPath;\r\n      const rotate = currentAngle + shortestPath;\r\n      maybeHoveredItem.current.index = newHoveredItem;\r\n      maybeHoveredItem.current.angle = rotate;\r\n      animate(scope.current, { rotate }, TRANSITION);\r\n    }\r\n  };\r\n\r\n  const clearHighlight = () => {\r\n    maybeHoveredItem.current = { exists: false };\r\n    animate(scope.current, { opacity: 0 });\r\n  };\r\n\r\n  return (\r\n    <MenuWheelContainerContext.Provider\r\n      value={{ totalItems, moveHighlight, clearHighlight }}\r\n    >\r\n      <AnimatePresence>\r\n        {open && (\r\n          <motion.div\r\n            onMouseLeave={clearHighlight}\r\n            initial={{\r\n              maskImage:\r\n                \"conic-gradient(rgba(0,0,0,1) 0%,rgba(0,0,0,1) 0%,rgba(0,0,0,0) 0%,rgba(0,0,0,0) 100%)\",\r\n              scale: 0.9,\r\n            }}\r\n            animate={{\r\n              maskImage:\r\n                \"conic-gradient(rgba(0,0,0,1) 0%,rgba(0,0,0,1) 100%,rgba(0,0,0,0) 100%,rgba(0,0,0,0) 100%)\",\r\n              scale: 1,\r\n            }}\r\n            exit={{\r\n              maskImage:\r\n                \"conic-gradient(rgba(0,0,0,1) 0%,rgba(0,0,0,1) 0%,rgba(0,0,0,0) 0%,rgba(0,0,0,0) 100%)\",\r\n              scale: 0.9,\r\n            }}\r\n            className={cn(\r\n              \"bg-border text-secondary-foreground absolute isolate z-10 flex size-[325%] rounded-full\",\r\n              className\r\n            )}\r\n            {...props}\r\n          >\r\n            {children}\r\n            <div\r\n              ref={scope}\r\n              className=\"bg-primary absolute inset-y-0 left-0 z-10 w-1/2 origin-right opacity-0\"\r\n              style={{\r\n                clipPath: `polygon(100% 50%,100% 0%,0% 0%,0% ${\r\n                  50 - Math.tan(((90 - 360 / totalItems) * Math.PI) / 180) * 50\r\n                }%)`,\r\n              }}\r\n            />\r\n          </motion.div>\r\n        )}\r\n      </AnimatePresence>\r\n    </MenuWheelContainerContext.Provider>\r\n  );\r\n}\r\n\r\nexport function MenuWheelItem({\r\n  className,\r\n  index, // The index of the item in the menu wheel\r\n  value, // The value associated with the item, used for selection\r\n  children,\r\n  ...props\r\n}: React.ComponentProps<\"button\"> & { value?: string; index: number }) {\r\n  const { setActiveValue, onValueChange, showCurrent, activeValue } =\r\n    useMenuWheelContext();\r\n  const { totalItems, moveHighlight, clearHighlight } =\r\n    useMenuWheelContainerContext();\r\n\r\n  return (\r\n    <button\r\n      onMouseEnter={(e) => {\r\n        moveHighlight(index);\r\n        props.onMouseEnter?.(e);\r\n      }}\r\n      onTouchStart={(e) => {\r\n        if (!value) return;\r\n        setActiveValue(value);\r\n        clearHighlight();\r\n        onValueChange?.(value);\r\n        props.onTouchStart?.(e);\r\n      }}\r\n      onTouchEnd={(e) => {\r\n        // prevent onMouseUp trigger.\r\n        e.preventDefault();\r\n      }}\r\n      onMouseUp={(e) => {\r\n        if (!value) return;\r\n        setActiveValue(value);\r\n        clearHighlight();\r\n        onValueChange?.(value);\r\n        props.onMouseUp?.(e);\r\n      }}\r\n      {...props}\r\n      className={cn(\r\n        \"bg-muted text-muted-foreground border-border/50 absolute inset-y-1 right-1/2 left-1 z-20 origin-right cursor-grabbing rounded-l-full border-r opacity-80\",\r\n        showCurrent && activeValue === value && \"text-secondary-foreground\",\r\n        className\r\n      )}\r\n      style={{\r\n        clipPath: `polygon(100% 50%,100% 0%,0% 0%,0% ${\r\n          50 - Math.tan(((90 - 360 / totalItems) * Math.PI) / 180) * 50\r\n        }%)`,\r\n        transform: `rotate(${(index * 360) / totalItems}deg)`,\r\n      }}\r\n    >\r\n      <div\r\n        style={{\r\n          offsetPath: \"circle(40% at 100% 50%)\",\r\n          offsetDistance: `${((-90 - 360 / totalItems / 2) / 360) * 100}%`,\r\n          offsetRotate: `${(-index * 360) / totalItems}deg`,\r\n          offsetAnchor: \"center\",\r\n        }}\r\n        className=\"w-fit\"\r\n      >\r\n        {children}\r\n      </div>\r\n    </button>\r\n  );\r\n}\r\n","type":"registry:component"}]}