Overview
A radial menu around a single button. Opening it fans the actions out in a staggered spring onto a frosted disc. While it's open you don't have to land on an action: the direction of the pointer picks one. A wedge swings round to it, the action is pulled slightly toward the pointer, its label appears outside the ring, the centre button shows its icon, and a description shows below. You can also press the button, drag toward an action and release to choose it in one move. While closed, the button leans toward a nearby pointer.
Installation
Install dependencies:
$ pnpm add motionAdd the utility function for class merging:
lib/utils.ts
tsx
import { ClassValue, clsx } from "clsx";import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs));}
Copy the component code into your project:
tsx
"use client"; import { useEffect, useId, useRef, useState } from "react";import type { KeyboardEvent as ReactKeyboardEvent, PointerEvent as ReactPointerEvent, ReactNode } from "react";import { AnimatePresence, animate, motion, useMotionValue, useReducedMotion, useSpring, useTransform } from "motion/react";import type { MotionValue } from "motion/react";import { cn } from "@/lib/utils"; export type RadialAction = { id: string; label: string; icon?: ReactNode; /** Short description shown while the action is highlighted. */ content?: ReactNode;}; export type MagneticRadialProps = { actions: RadialAction[]; onSelect?: (action: RadialAction) => void; /** Distance from the centre to each action, in px. */ radius?: number; /** Accessible name for the trigger. */ label?: string; className?: string;}; const EASE_OUT = [0.23, 1, 0.32, 1] as const;const SPRING = { type: "spring" as const, stiffness: 420, damping: 28 };const MAGNET = { stiffness: 260, damping: 18, mass: 0.5 };const clamp = (v: number, m: number) => Math.max(-m, Math.min(m, v)); /** A ring segment centred on 12 o'clock. */function wedge(count: number, inner: number, outer: number, c: number) { const half = (Math.PI / count) * 0.86; const pt = (r: number, a: number) => `${c + Math.sin(a) * r} ${c - Math.cos(a) * r}`; const large = half * 2 > Math.PI ? 1 : 0; return `M ${pt(inner, -half)} L ${pt(outer, -half)} A ${outer} ${outer} 0 ${large} 1 ${pt(outer, half)} L ${pt(inner, half)} A ${inner} ${inner} 0 ${large} 0 ${pt(inner, -half)} Z`;} export function MagneticRadial({ actions, onSelect, radius = 88, label = "Actions", className }: MagneticRadialProps) { const reduce = !!useReducedMotion(); const uid = useId(); const count = actions.length; const size = (radius + 46) * 2; const c = size / 2; const [open, setOpen] = useState(false); const [highlight, setHighlight] = useState<number | null>(null); const [selected, setSelected] = useState<number | null>(null); const [chosen, setChosen] = useState<number | null>(null); const rootRef = useRef<HTMLDivElement>(null); const triggerRef = useRef<HTMLButtonElement>(null); const itemRefs = useRef<(HTMLButtonElement | null)[]>([]); const press = useRef<{ x: number; y: number; moved: boolean } | null>(null); const skipClick = useRef(false); const focusFirst = useRef(false); // Keyboard opening moves focus onto the first action once it's focusable. useEffect(() => { if (!open || !focusFirst.current) return; focusFirst.current = false; itemRefs.current[0]?.focus(); }, [open]); // Pointer relative to the centre, shared by the magnet effects. const pointerX = useMotionValue(0); const pointerY = useMotionValue(0); const hl = useMotionValue(-1); useEffect(() => hl.set(highlight ?? -1), [highlight, hl]); // The wedge turns by the shortest path to the highlighted action. const wedgeRotate = useMotionValue(0); useEffect(() => { if (highlight === null) return; const target = (highlight / count) * 360; const now = wedgeRotate.get(); const delta = ((((target - now) % 360) + 540) % 360) - 180; if (reduce) wedgeRotate.jump(now + delta); else animate(wedgeRotate, now + delta, SPRING); }, [highlight, count, reduce, wedgeRotate]); // Closed, the trigger leans toward the pointer. const openMv = useMotionValue(0); useEffect(() => openMv.set(open || reduce ? 1 : 0), [open, reduce, openMv]); const tx = useSpring(useTransform(() => (openMv.get() ? 0 : clamp(pointerX.get() * 0.16, 10))), MAGNET); const ty = useSpring(useTransform(() => (openMv.get() ? 0 : clamp(pointerY.get() * 0.16, 10))), MAGNET); const close = (focusTrigger = false) => { setOpen(false); setHighlight(null); press.current = null; if (focusTrigger) triggerRef.current?.focus(); }; const choose = (i: number) => { setSelected(i); setChosen(i); onSelect?.(actions[i]); window.setTimeout( () => { setChosen(null); close(true); }, reduce ? 0 : 240, ); }; // Outside press closes; releasing a hold-and-drag over an action picks it. useEffect(() => { if (!open) return; const down = (e: PointerEvent) => { if (!rootRef.current?.contains(e.target as Node)) close(); }; const up = () => { const p = press.current; press.current = null; if (p?.moved && hl.get() >= 0) choose(hl.get()); }; document.addEventListener("pointerdown", down); window.addEventListener("pointerup", up); return () => { document.removeEventListener("pointerdown", down); window.removeEventListener("pointerup", up); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [open]); const onMove = (e: ReactPointerEvent<HTMLDivElement>) => { const r = rootRef.current?.getBoundingClientRect(); if (!r) return; const x = e.clientX - (r.left + r.width / 2); const y = e.clientY - (r.top + r.height / 2); pointerX.set(x); pointerY.set(y); if (press.current && Math.hypot(e.clientX - press.current.x, e.clientY - press.current.y) > 18) press.current.moved = true; if (!open) return; // Direction picks the action, so the pointer never has to land exactly on it. if (Math.hypot(x, y) < 30) setHighlight(null); else { const a = Math.atan2(y, x) + Math.PI / 2; setHighlight((((Math.round((a / (Math.PI * 2)) * count) % count) + count) % count)); } }; const onItemKey = (e: ReactKeyboardEvent<HTMLButtonElement>, i: number) => { const map: Record<string, number> = { ArrowRight: i + 1, ArrowDown: i + 1, ArrowLeft: i - 1, ArrowUp: i - 1, Home: 0, End: count - 1 }; if (e.key in map) { e.preventDefault(); const n = (((map[e.key] % count) + count) % count); setHighlight(n); itemRefs.current[n]?.focus(); } else if (e.key === "Escape") { e.preventDefault(); close(true); } else if (e.key === "Tab") { close(); } }; if (!count) return null; const shown = highlight ?? (open ? null : selected); const status = open && highlight !== null ? actions[highlight] : !open && selected !== null ? actions[selected] : null; return ( <div className={cn("mx-auto flex w-fit flex-col items-center gap-3", className)}> <div ref={rootRef} onPointerMove={onMove} onPointerLeave={() => { pointerX.set(0); pointerY.set(0); if (!press.current) setHighlight(null); }} className="relative touch-none select-none" style={{ width: size, height: size }} > {/* Ring, wedge and soft backdrop */} <AnimatePresence> {open ? ( <motion.div key="ring" aria-hidden initial={{ opacity: 0, scale: 0.6 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: 0.7 }} transition={reduce ? { duration: 0.12 } : { type: "spring", bounce: 0.25, duration: 0.45 }} className="pointer-events-none absolute inset-0" > <div className="absolute inset-[6px] rounded-full bg-(--color-paper)/70 shadow-[0_0_0_1px_var(--color-rule),0_20px_50px_-24px_rgba(0,0,0,0.35)] backdrop-blur-md" /> <svg viewBox={`0 0 ${size} ${size}`} className="absolute inset-0 size-full overflow-visible"> <motion.circle cx={c} cy={c} r={radius} fill="none" stroke="var(--color-rule)" strokeWidth="1" strokeDasharray="2 5" initial={reduce ? false : { pathLength: 0 }} animate={{ pathLength: 1 }} transition={{ duration: 0.5, ease: EASE_OUT }} /> </svg> <motion.div className="absolute inset-0" style={{ rotate: wedgeRotate }}> <svg viewBox={`0 0 ${size} ${size}`} className="absolute inset-0 size-full overflow-visible"> <motion.path d={wedge(count, 34, radius + 38, c)} fill="var(--color-ink)" initial={false} animate={{ fillOpacity: highlight === null ? 0 : 0.06 }} transition={{ duration: 0.2 }} /> </svg> </motion.div> </motion.div> ) : null} </AnimatePresence> {/* Actions */} <div role="menu" id={`${uid}-menu`} aria-label={label} aria-hidden={!open} className="absolute inset-0"> {actions.map((action, i) => ( <Item key={action.id} action={action} index={i} count={count} radius={radius} open={open} highlighted={highlight === i} selected={selected === i} chosen={chosen === i} reduce={reduce} pointerX={pointerX} pointerY={pointerY} hl={hl} buttonRef={(el) => { itemRefs.current[i] = el; }} onFocus={() => setHighlight(i)} onClick={() => choose(i)} onKeyDown={(e) => onItemKey(e, i)} /> ))} </div> {/* Trigger */} <motion.button ref={triggerRef} type="button" aria-haspopup="menu" aria-expanded={open} aria-controls={`${uid}-menu`} aria-label={open ? `Close ${label.toLowerCase()}` : `Open ${label.toLowerCase()}`} onPointerDown={(e) => { if (e.pointerType === "mouse" && e.button !== 0) return; if (!open) { // Opening on press lets people hold, drag toward an action and release. setOpen(true); press.current = { x: e.clientX, y: e.clientY, moved: false }; skipClick.current = true; } }} onClick={(e) => { if (skipClick.current) { skipClick.current = false; return; } if (open) close(); else { // Keyboard activation has no pointer press; move focus into the menu. if (e.detail === 0) focusFirst.current = true; setOpen(true); } }} onKeyDown={(e) => { if (e.key === "ArrowDown" || e.key === "ArrowUp") { e.preventDefault(); if (open) itemRefs.current[0]?.focus(); else { focusFirst.current = true; setOpen(true); } } }} whileTap={reduce ? undefined : { scale: 0.92 }} style={{ x: tx, y: ty }} className="absolute top-1/2 left-1/2 z-30 -mt-8 -ml-8 grid size-16 place-items-center rounded-full bg-(--color-ink) text-(--color-paper) shadow-[0_0_0_4px_var(--color-paper),0_10px_28px_-10px_rgba(0,0,0,0.5),inset_0_1px_0_rgba(255,255,255,0.15)] outline-none focus-visible:ring-2 focus-visible:ring-(--color-focus) focus-visible:ring-offset-4 focus-visible:ring-offset-(--color-paper)" > <AnimatePresence initial={false} mode="popLayout"> {open && shown !== null && actions[shown]?.icon ? ( <motion.span key={`icon-${shown}`} initial={{ opacity: 0, scale: 0.5, filter: "blur(4px)" }} animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }} exit={{ opacity: 0, scale: 0.5, filter: "blur(4px)" }} transition={{ duration: 0.18 }} className="grid size-6 place-items-center [&_svg]:size-5" > {actions[shown].icon} </motion.span> ) : ( <motion.svg key="plus" viewBox="0 0 24 24" className="size-6" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" initial={{ opacity: 0, scale: 0.5 }} animate={{ opacity: 1, scale: 1, rotate: open ? 45 : 0 }} exit={{ opacity: 0, scale: 0.5 }} transition={reduce ? { duration: 0 } : { type: "spring", bounce: 0.35, duration: 0.4 }} > <path d="M12 5v14M5 12h14" /> </motion.svg> )} </AnimatePresence> </motion.button> </div> {/* Status */} <div aria-live="polite" className="relative grid h-11 w-72 place-items-center text-center"> <AnimatePresence initial={false} mode="popLayout"> <motion.div key={status ? `${open ? "h" : "s"}-${status.id}` : open ? "open" : "idle"} initial={{ opacity: 0, y: 6, filter: "blur(4px)" }} animate={{ opacity: 1, y: 0, filter: "blur(0px)" }} exit={{ opacity: 0, y: -6, filter: "blur(4px)" }} transition={{ duration: 0.2, ease: EASE_OUT }} > {status ? ( <> <div className="text-[13.5px] font-semibold text-(--color-ink)"> {open ? status.label : `Chose ${status.label}`} </div> {open && status.content ? <div className="mt-0.5 text-[12.5px] text-(--color-ink-2)">{status.content}</div> : null} </> ) : ( <div className="text-[12.5px] text-(--color-neutral)">{open ? "Point toward an action" : "Click, or press and drag toward an action"}</div> )} </motion.div> </AnimatePresence> </div> </div> );} function Item({ action, index, count, radius, open, highlighted, selected, chosen, reduce, pointerX, pointerY, hl, buttonRef, onFocus, onClick, onKeyDown,}: { action: RadialAction; index: number; count: number; radius: number; open: boolean; highlighted: boolean; selected: boolean; chosen: boolean; reduce: boolean; pointerX: MotionValue<number>; pointerY: MotionValue<number>; hl: MotionValue<number>; buttonRef: (el: HTMLButtonElement | null) => void; onFocus: () => void; onClick: () => void; onKeyDown: (e: ReactKeyboardEvent<HTMLButtonElement>) => void;}) { const a = (index / count) * Math.PI * 2 - Math.PI / 2; const x = Math.cos(a) * radius; const y = Math.sin(a) * radius; // The highlighted action is pulled a little toward the pointer. const mx = useSpring(useTransform(() => (!reduce && hl.get() === index ? clamp((pointerX.get() - x) * 0.3, 12) : 0)), MAGNET); const my = useSpring(useTransform(() => (!reduce && hl.get() === index ? clamp((pointerY.get() - y) * 0.3, 12) : 0)), MAGNET); const lx = Math.cos(a) * 46; const ly = Math.sin(a) * 46; return ( <motion.div className="absolute top-1/2 left-1/2 z-20 -mt-7 -ml-7 size-14" initial={false} animate={ open ? { x, y, scale: 1, opacity: 1, filter: "blur(0px)" } : { x: 0, y: 0, scale: 0.4, opacity: 0, filter: reduce ? "blur(0px)" : "blur(6px)" } } transition={ reduce ? { duration: 0.12 } : { type: "spring", stiffness: 380, damping: 26, delay: open ? index * 0.035 : (count - 1 - index) * 0.02 } } style={{ pointerEvents: open ? "auto" : "none" }} > <motion.button ref={buttonRef} type="button" role="menuitem" tabIndex={open ? 0 : -1} aria-label={action.label} aria-current={selected || undefined} onFocus={onFocus} onClick={onClick} onKeyDown={onKeyDown} style={{ x: mx, y: my }} animate={{ scale: chosen ? [1, 1.3, 1.15] : highlighted ? 1.14 : 1 }} transition={{ type: "spring", bounce: 0.4, duration: 0.35 }} className={cn( "relative grid size-14 place-items-center rounded-full text-[11px] font-medium outline-none transition-[background-color,color,box-shadow] duration-200 [&_svg]:size-5", "focus-visible:ring-2 focus-visible:ring-(--color-focus) focus-visible:ring-offset-2 focus-visible:ring-offset-(--color-paper)", highlighted || chosen ? "bg-(--color-ink) text-(--color-paper) shadow-[0_10px_24px_-10px_rgba(0,0,0,0.5)]" : "bg-(--color-paper) text-(--color-ink-2) shadow-[0_0_0_1px_var(--color-rule),0_3px_10px_-4px_rgba(0,0,0,0.2)]", )} > {action.icon ?? action.label.slice(0, 2)} {selected && !highlighted ? <span aria-hidden className="absolute top-1 right-1 size-1.5 rounded-full bg-(--color-ink)" /> : null} </motion.button> {/* Label sits outside the ring, along the action's direction. */} <AnimatePresence> {open && highlighted ? ( <motion.span aria-hidden initial={{ opacity: 0, scale: 0.8, filter: "blur(4px)" }} animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }} exit={{ opacity: 0, scale: 0.8, filter: "blur(4px)" }} transition={{ duration: 0.16 }} style={{ left: 28 + lx, top: 28 + ly }} className="pointer-events-none absolute z-40 -translate-x-1/2 -translate-y-1/2 rounded-full bg-(--color-ink) px-2 py-0.5 text-[11.5px] font-medium whitespace-nowrap text-(--color-paper) shadow-[0_6px_16px_-6px_rgba(0,0,0,0.45)]" > {action.label} </motion.span> ) : null} </AnimatePresence> </motion.div> );}
Adjust import paths according to your folder structure.
Basic Usage
tsx
import { MagneticRadial } from "@/components/ui/magnetic-radial";import { Bookmark, Pencil, Share2 } from "lucide-react"; <MagneticRadial label="Card actions" onSelect={(action) => run(action.id)} actions={[ { id: "edit", label: "Edit", icon: <Pencil />, content: "Open it in the editor." }, { id: "share", label: "Share", icon: <Share2 />, content: "Send a link." }, { id: "save", label: "Save", icon: <Bookmark />, content: "Keep it for later." }, ]}/>
Props
| Prop | Type | Required | Default | Description |
|---|---|---|---|---|
| actions | { id, label, icon?, content? }[] | Yes | — | Actions around the ring, clockwise from the top. content is the description shown while highlighted. |
| onSelect | (action) => void | No | — | Called when an action is chosen. |
| radius | number | No | 88 | Distance from the centre to each action, in px. |
| label | string | No | "Actions" | Accessible name for the menu and trigger. |
| className | string | No | — | Classes for the wrapper. |
Best Practices
- Four to eight actions work best; past that the wedges get too narrow to aim at.
- Give every action an icon. Labels only show for the highlighted action.
- Keyboard: Enter or the arrow keys open the menu, arrows move around the ring, Enter chooses, and Escape closes and returns focus.
- Reduced motion keeps the layout but drops the stagger, blur and magnet pull.