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 motion

Add 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:

components/ui/magnetic-radial.tsx
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

PropTypeRequiredDefaultDescription
actions{ id, label, icon?, content? }[]Yes—Actions around the ring, clockwise from the top. content is the description shown while highlighted.
onSelect(action) => voidNo—Called when an action is chosen.
radiusnumberNo88Distance from the centre to each action, in px.
labelstringNo"Actions"Accessible name for the menu and trigger.
classNamestringNo—Classes for the wrapper.

Best Practices

  1. Four to eight actions work best; past that the wedges get too narrow to aim at.
  2. Give every action an icon. Labels only show for the highlighted action.
  3. Keyboard: Enter or the arrow keys open the menu, arrows move around the ring, Enter chooses, and Escape closes and returns focus.
  4. Reduced motion keeps the layout but drops the stagger, blur and magnet pull.