Navigationv0.1.0
Radial Menu
Items bloom out radially with a stagger.
Installation
CLI
npx shadcn@latest add https://hantui-theta.vercel.app/r/radial-menu.jsonManual
Install the dependencies
npm install clsx lucide-react motion tailwind-mergeAdd the shared helpers
Components use
cn()fromlib/utils.tsand the motion tokens below. See Installation if you have not set them up yet.lib/motion.tsimport type { Transition, Variants } from "motion/react"; /** Durations in seconds. */ export const duration = { instant: 0.1, quick: 0.18, base: 0.28, slow: 0.45, } as const; /** Cubic-bezier curves. */ export const ease = { /** Entering, UI response. */ out: [0.22, 1, 0.36, 1], /** Moving on screen. */ inOut: [0.65, 0, 0.35, 1], /** Sheets and the mobile menu. */ drawer: [0.32, 0.72, 0, 1], } as const; /** Everyday UI: presses, icon swaps, popovers and modals. Exits run at `exitFactor` of the enter. */ export const ui = { press: { scale: 0.96, duration: 0.12 }, icon: { type: "spring", duration: 0.3, bounce: 0 }, popover: { duration: 0.18, ease: ease.out }, modal: { duration: 0.24, ease: ease.out }, exitFactor: 0.6, } as const; /** Once-per-visit motion on the landing page: hero letters, the reel and the afterimage. */ export const kinetic = { heroLetter: { duration: 0.6, ease: ease.out }, /** Delay per letter, eased so the wave slows toward the end. */ heroStep: 0.035, reelSwap: { type: "spring", duration: 0.5, bounce: 0.1 }, /** Two afterimages: lag in seconds and opacity of each. */ ghost: { lag: [0.04, 0.09], opacity: [0.14, 0.06] }, } as const; /** Spring presets. */ export const spring = { snappy: { type: "spring", stiffness: 500, damping: 30 }, smooth: { type: "spring", stiffness: 260, damping: 26 }, bouncy: { type: "spring", stiffness: 300, damping: 15 }, } as const satisfies Record<string, Transition>; /** Stagger between siblings, in seconds (0.03–0.06). */ export const stagger = { tight: 0.03, base: 0.045, loose: 0.06, } as const; /** Blur + fade + y-offset. Falls back to a plain fade under reduced motion. */ export function enterVariants(reduceMotion?: boolean | null, y = 8): Variants { if (reduceMotion) { return { hidden: { opacity: 0 }, visible: { opacity: 1, transition: { duration: duration.base } }, exit: { opacity: 0, transition: { duration: duration.quick } }, }; } return { hidden: { opacity: 0, y, filter: "blur(4px)" }, visible: { opacity: 1, y: 0, filter: "blur(0px)", transition: { duration: duration.base, ease: ease.out }, }, exit: { opacity: 0, y: -y / 2, filter: "blur(4px)", transition: { duration: duration.quick, ease: ease.out }, }, }; } /** Scale 0.96 → 1 for popovers, menus and dialogs. */ export function overlayVariants(reduceMotion?: boolean | null): Variants { if (reduceMotion) { return { hidden: { opacity: 0 }, visible: { opacity: 1, transition: { duration: duration.quick } }, exit: { opacity: 0, transition: { duration: duration.instant } }, }; } return { hidden: { opacity: 0, scale: 0.96, filter: "blur(2px)" }, visible: { opacity: 1, scale: 1, filter: "blur(0px)", transition: { duration: duration.quick, ease: ease.out }, }, exit: { opacity: 0, scale: 0.96, filter: "blur(2px)", transition: { duration: duration.instant + 0.02, ease: ease.out }, }, }; } /** Parent variants that stagger their children in. */ export function staggerVariants(gap: number = stagger.base, delayChildren = 0): Variants { return { hidden: {}, visible: { transition: { staggerChildren: gap, delayChildren } }, exit: { transition: { staggerChildren: gap / 2, staggerDirection: -1 } }, }; }Copy the source
components/ui/radial-menu/radial-menu.tsx"use client"; import { useEffect, useRef, useState, type ComponentProps, type KeyboardEvent, type ReactNode } from "react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { Plus } from "lucide-react"; import { cn } from "@/lib/utils"; import { duration, ease } from "@/lib/motion"; export interface RadialMenuItem { /** Unique id. */ id: string; /** Accessible name, also shown as a label on hover and focus. */ label: string; /** Icon for the item. */ icon: ReactNode; /** Runs when the item is chosen; the menu then closes. */ onSelect?: () => void; } export interface RadialMenuProps extends Omit<ComponentProps<"div">, "children" | "onAnimationStart" | "onDrag" | "onDragStart" | "onDragEnd"> { /** Items, placed along the arc in order. */ items: RadialMenuItem[]; /** Distance from the button's center to each item, in px. @default 88 */ radius?: number; /** Arc start in degrees: 0 is right, 90 down, 180 left, 270 up. @default 180 */ startAngle?: number; /** Arc end in degrees. A full 360° span spaces items evenly around the circle. @default 360 */ endAngle?: number; /** Accessible name of the trigger. @default "Open menu" */ label?: string; /** Trigger icon; it turns 45° when open, so a plus becomes a cross. @default <Plus /> */ icon?: ReactNode; } const FAN = { type: "spring", stiffness: 420, damping: 24, mass: 0.8 } as const; export function RadialMenu({ items, radius = 88, startAngle = 180, endAngle = 360, label = "Open menu", icon, className, ...props }: RadialMenuProps) { const [open, setOpen] = useState(false); const [active, setActive] = useState(0); const rootRef = useRef<HTMLDivElement>(null); const triggerRef = useRef<HTMLButtonElement>(null); const itemRefs = useRef<(HTMLButtonElement | null)[]>([]); const returnFocus = useRef(false); const reduceMotion = useReducedMotion(); // A full circle would put the first and last item on top of each other, so leave a gap. const span = endAngle - startAngle; const fullCircle = Math.abs(span) >= 360; const steps = fullCircle ? items.length : Math.max(items.length - 1, 1); const positions = items.map((_, i) => { const angle = ((startAngle + (span * i) / steps) * Math.PI) / 180; return { x: Math.cos(angle) * radius, y: Math.sin(angle) * radius, angle }; }); useEffect(() => { if (open) itemRefs.current[0]?.focus({ preventScroll: true }); else if (returnFocus.current) { returnFocus.current = false; triggerRef.current?.focus({ preventScroll: true }); } }, [open]); useEffect(() => { if (!open) return; const onPointerDown = (event: PointerEvent) => { if (!rootRef.current?.contains(event.target as Node)) setOpen(false); }; document.addEventListener("pointerdown", onPointerDown); return () => document.removeEventListener("pointerdown", onPointerDown); }, [open]); function close(focusTrigger: boolean) { returnFocus.current = focusTrigger; setOpen(false); } function onMenuKey(event: KeyboardEvent<HTMLDivElement>) { const count = items.length; const moves: Record<string, number> = { ArrowRight: (active + 1) % count, ArrowDown: (active + 1) % count, ArrowLeft: (active - 1 + count) % count, ArrowUp: (active - 1 + count) % count, Home: 0, End: count - 1, }; if (event.key in moves) { event.preventDefault(); setActive(moves[event.key]); itemRefs.current[moves[event.key]]?.focus(); } else if (event.key === "Escape") { event.preventDefault(); close(true); } else if (event.key === "Tab") { close(false); } } return ( <div ref={rootRef} className={cn("relative inline-flex size-14 items-center justify-center", className)} {...props}> {/* A soft halo grows behind the items as the menu opens. */} <AnimatePresence> {open ? ( <motion.span aria-hidden="true" className="pointer-events-none absolute rounded-full bg-foreground/[0.04] ring-1 ring-border/60" style={{ width: radius * 2 + 64, height: radius * 2 + 64 }} initial={{ opacity: 0, scale: reduceMotion ? 1 : 0.2 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: reduceMotion ? 1 : 0.2, transition: { duration: duration.quick } }} transition={{ duration: duration.base, ease: ease.out }} /> ) : null} </AnimatePresence> {/* The role is only set while items exist, so there's never an empty menu in the tree. */} <div role={open ? "menu" : undefined} aria-label={open ? label : undefined} onKeyDown={onMenuKey} className="contents" > <AnimatePresence> {open ? items.map((item, i) => { const { x, y, angle } = positions[i]; return ( <motion.button key={item.id} ref={(node) => { itemRefs.current[i] = node; }} type="button" role="menuitem" tabIndex={i === active ? 0 : -1} aria-label={item.label} onFocus={() => setActive(i)} onClick={() => { item.onSelect?.(); close(true); }} initial={reduceMotion ? { opacity: 0, x, y } : { opacity: 0, x: 0, y: 0, scale: 0.3 }} animate={{ opacity: 1, x, y, scale: 1 }} exit={ reduceMotion ? { opacity: 0, transition: { duration: duration.instant } } : { opacity: 0, x: 0, y: 0, scale: 0.3, transition: { duration: duration.quick, ease: ease.out, delay: (items.length - 1 - i) * 0.015 } } } transition={reduceMotion ? { duration: duration.quick } : { ...FAN, delay: i * 0.035 }} className="group/item absolute z-10 flex size-11 cursor-pointer items-center justify-center rounded-full border border-border bg-background text-foreground shadow-md outline-none transition-colors hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring [&_svg]:size-[18px]" > <span aria-hidden="true">{item.icon}</span> {/* Label sits further out along the same ray, so it never covers the item. */} <span aria-hidden="true" className="pointer-events-none absolute rounded-md bg-foreground px-2 py-0.5 text-xs font-medium whitespace-nowrap text-background opacity-0 shadow-md transition-opacity duration-150 group-hover/item:opacity-100 group-focus-visible/item:opacity-100" style={{ left: "50%", top: "50%", transform: `translate(-50%, -50%) translate(${Math.cos(angle) * 46}px, ${Math.sin(angle) * 40}px)`, }} > {item.label} </span> </motion.button> ); }) : null} </AnimatePresence> </div> <motion.button ref={triggerRef} type="button" aria-label={label} aria-haspopup="menu" aria-expanded={open} onClick={() => { if (open) { close(false); return; } setActive(0); setOpen(true); }} onKeyDown={(event) => { if (open && event.key === "Escape") { event.preventDefault(); close(true); } }} animate={{ scale: open && !reduceMotion ? 0.9 : 1 }} transition={FAN} className="relative z-20 flex size-14 cursor-pointer items-center justify-center rounded-full bg-foreground text-background shadow-lg outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background" > <motion.span aria-hidden="true" className="flex [&_svg]:size-6" animate={{ rotate: open ? 45 : 0 }} transition={reduceMotion ? { duration: 0 } : FAN} > {icon ?? <Plus />} </motion.span> </motion.button> </div> ); }
Usage
import { RadialMenu } from "@/components/ui/radial-menu/radial-menu";
export function CreateButton() {
return (
<RadialMenu
label="Create"
items={[
{ id: "note", label: "Note", icon: <NoteIcon />, onSelect: newNote },
{ id: "photo", label: "Photo", icon: <PhotoIcon />, onSelect: newPhoto },
]}
/>
);
}API reference
| Prop | Type | Default | Description |
|---|---|---|---|
items* | RadialMenuItem[] | – | Items, placed along the arc in order. |
radius | number | 88 | Distance from the button's center to each item, in px. |
startAngle | number | 180 | Arc start in degrees: 0 is right, 90 down, 180 left, 270 up. |
endAngle | number | 360 | Arc end in degrees. A full 360° span spaces items evenly around the circle. |
label | string | "Open menu" | Accessible name of the trigger. |
icon | ReactNode | <Plus /> | Trigger icon; it turns 45° when open, so a plus becomes a cross. |
Also accepts every native <div> attribute, including ref and className.
- Items spread evenly from startAngle to endAngle (0° right, 90° down, 180° left, 270° up). A full 360° span spaces them evenly around the circle.
- Items fan out on a lightly underdamped spring with a 35ms stagger, and fold back in reverse order.
- Leave room of about radius + 40px around the button for the items and their labels.
Accessibility
- The trigger is a button with aria-haspopup="menu" and aria-expanded; opening moves focus to the first item.
- Items are menuitems with a roving tabindex: arrow keys go round the circle, Home and End jump, Escape closes and returns focus, and Tab closes.
- Every item has an accessible name, and its visible label appears on hover and keyboard focus.
- Clicking outside closes the menu.
- With reduced motion enabled, items fade in at their final positions, with no fan-out, rotation or halo growth.