Radial Menu
A hub that blooms into a ring or fan of icon petals flying out with a springy stagger, with a readout label at each petal.
A raised round hub whose plus rotates into a close mark when open, spawning a ring or partial fan of raised petal chips that fly out with a springy stagger and settle. Hover or focus a petal to read its label just beyond it. Built with Motion.
Installation
Complete the shared Setup first, then copy the component into
components/ui/radial-menu.tsx.
bun add motion'use client';import * as React from 'react';import { AnimatePresence, motion, useReducedMotion } from 'motion/react';import { Plus } from 'lucide-react';import { cn } from '@/lib/cn';const EASE_OUT = [0.23, 1, 0.32, 1] as const;const BLOOM = { type: 'spring' as const, duration: 0.42, bounce: 0.24 };const FOCUS = 'focus-visible:outline-none focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:[outline-color:var(--radial-accent)]';export type RadialItem = { label: string; icon: React.ReactNode; onSelect?: () => void;};export type RadialMenuProps = { items: RadialItem[]; hubIcon?: React.ReactNode; radius?: number; spread?: number; centerAngle?: number; accent?: string; className?: string;};export function RadialMenu({ items, hubIcon, radius = 108, spread = 360, centerAngle = 0, accent = '#f0883e', className,}: RadialMenuProps) { const reduce = useReducedMotion(); const [open, setOpen] = React.useState(false); const [keyOpen, setKeyOpen] = React.useState(false); const [active, setActive] = React.useState<number | null>(null); const hubRef = React.useRef<HTMLButtonElement>(null); const itemRefs = React.useRef<(HTMLButtonElement | null)[]>([]); const petals = React.useMemo(() => { const n = items.length; return items.map((_, i) => { const deg = spread >= 360 ? centerAngle + (360 / n) * i : centerAngle - spread / 2 + (n > 1 ? (spread / (n - 1)) * i : 0); const rad = (deg * Math.PI) / 180; const ux = Math.sin(rad); const uy = -Math.cos(rad); return { x: ux * radius, y: uy * radius, ux, uy }; }); }, [items, radius, spread, centerAngle]); const close = React.useCallback(() => { setOpen(false); setActive(null); hubRef.current?.focus(); }, []); React.useEffect(() => { if (open && keyOpen) itemRefs.current[0]?.focus(); }, [open, keyOpen]); const onKeyDown = (e: React.KeyboardEvent) => { if (!open) return; if (e.key === 'Escape') { e.preventDefault(); close(); return; } const fwd = e.key === 'ArrowRight' || e.key === 'ArrowDown'; const back = e.key === 'ArrowLeft' || e.key === 'ArrowUp'; if (!fwd && !back) return; e.preventDefault(); const here = itemRefs.current.indexOf( document.activeElement as HTMLButtonElement, ); const from = here === -1 ? 0 : here; const n = items.length; const next = (from + (fwd ? 1 : -1) + n) % n; itemRefs.current[next]?.focus(); }; const gap = 20; const vars = { '--radial-accent': accent } as React.CSSProperties; return ( <div style={vars} onKeyDown={onKeyDown} className={cn('relative inline-flex', className)} > <AnimatePresence> {open && ( <motion.div key="scrim" aria-hidden onClick={close} initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.16, ease: EASE_OUT }} className="fixed inset-0 z-10 bg-black/[0.03] backdrop-blur-[1px] dark:bg-black/30" /> )} </AnimatePresence> <AnimatePresence> {open && petals.map((p, i) => { const item = items[i]; const shown = active === i; return ( <motion.button key={item.label} ref={(el) => { itemRefs.current[i] = el; }} type="button" aria-label={item.label} onClick={() => { item.onSelect?.(); close(); }} onPointerEnter={() => setActive(i)} onPointerLeave={() => setActive((a) => (a === i ? null : a))} onFocus={() => setActive(i)} onBlur={() => setActive((a) => (a === i ? null : a))} initial={ reduce ? { opacity: 0, x: p.x, y: p.y, scale: 1 } : { opacity: 0, x: 0, y: 0, scale: 0.4 } } animate={{ opacity: 1, x: p.x, y: p.y, scale: 1 }} exit={ reduce ? { opacity: 0, x: p.x, y: p.y, scale: 1 } : { opacity: 0, x: 0, y: 0, scale: 0.4 } } transition={ reduce ? { duration: 0.14 } : { ...BLOOM, delay: i * 0.028 } } transformTemplate={({ x, y, scale }) => `translate(-50%, -50%) translate(${x}, ${y}) scale(${scale})` } whileTap={reduce ? undefined : { scale: 0.9 }} className={cn( 'bloom-edge absolute left-1/2 top-1/2 z-20 grid size-11 place-items-center rounded-full outline-none', 'bg-gradient-to-b from-white to-[#e9e9ec] text-neutral-700', 'dark:from-[#333335] dark:to-[#232325] dark:text-neutral-200', 'shadow-[inset_0_1px_0_0_rgba(255,255,255,1),0_1px_2px_0_rgba(0,0,0,0.08),0_5px_12px_-5px_rgba(0,0,0,0.22)]', 'dark:shadow-[inset_0_1px_0_0_rgba(255,255,255,0.1),0_1px_2px_0_rgba(0,0,0,0.5),0_6px_16px_-5px_rgba(0,0,0,0.6)]', 'transition-colors duration-150 hover:text-neutral-900 dark:hover:text-white', FOCUS, )} > <span className="[&_svg]:size-[19px]" aria-hidden> {item.icon} </span> <span className="pointer-events-none absolute left-1/2 top-1/2 z-30" style={{ transform: `translate(-50%, -50%) translate(${ p.ux * (22 + gap) }px, ${p.uy * (22 + gap)}px)`, }} > <AnimatePresence> {shown && ( <motion.span initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.9, filter: 'blur(4px)' }} animate={{ opacity: 1, scale: 1, filter: 'blur(0px)' }} exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.9, filter: 'blur(4px)' }} transition={{ duration: 0.14, ease: EASE_OUT }} className={cn( 'bloom-edge block -translate-x-1/2 -translate-y-1/2 whitespace-nowrap rounded-lg px-2 py-1', 'text-[12px] font-medium tracking-[-0.01em]', 'bg-gradient-to-b from-white to-[#f4f4f5] text-neutral-900', 'dark:from-[#272727] dark:to-[#191919] dark:text-white', 'shadow-[0_1px_2px_0_rgba(0,0,0,0.05),0_6px_16px_-6px_rgba(0,0,0,0.2)]', 'dark:shadow-[inset_0_1px_0_0_rgba(255,255,255,0.06),0_6px_16px_-10px_rgba(0,0,0,0.45)]', )} > {item.label} </motion.span> )} </AnimatePresence> </span> </motion.button> ); })} </AnimatePresence> <motion.button ref={hubRef} type="button" aria-haspopup="menu" aria-expanded={open} aria-label={open ? 'Close menu' : 'Open menu'} onClick={(e) => { if (open) { close(); } else { setKeyOpen(e.detail === 0); setOpen(true); } }} whileTap={reduce ? undefined : { scale: 0.94 }} className={cn( 'bloom-edge relative z-30 grid size-14 place-items-center rounded-full outline-none', 'bg-gradient-to-b from-white to-[#e9e9ec] text-neutral-800', 'dark:from-[#343436] dark:to-[#232325] dark:text-neutral-100', 'shadow-[inset_0_1px_0_0_rgba(255,255,255,1),0_1px_2px_0_rgba(0,0,0,0.08),0_8px_20px_-6px_rgba(0,0,0,0.22)]', 'dark:shadow-[inset_0_1px_0_0_rgba(255,255,255,0.1),0_1px_2px_0_rgba(0,0,0,0.5),0_10px_24px_-6px_rgba(0,0,0,0.65)]', FOCUS, )} > <motion.span animate={{ rotate: open ? 45 : 0 }} transition={reduce ? { duration: 0 } : BLOOM} className="grid place-items-center [&_svg]:size-6" aria-hidden > {hubIcon ?? <Plus strokeWidth={2} />} </motion.span> </motion.button> </div> );}Usage
import { Home, Search, Settings } from 'lucide-react';
import { RadialMenu, type RadialItem } from '@/components/ui/radial-menu';
const items: RadialItem[] = [
{ label: 'Home', icon: <Home strokeWidth={1.75} /> },
{ label: 'Search', icon: <Search strokeWidth={1.75} /> },
{ label: 'Settings', icon: <Settings strokeWidth={1.75} /> },
];
export default function Example() {
return <RadialMenu items={items} />;
}Examples
A partial fan spread across 180 degrees instead of a full ring, with a cyan accent.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
items | RadialItem[] | - | Petals: { label, icon, onSelect? }. |
hubIcon | ReactNode | a plus | Icon on the closed hub; rotates into a close mark when open. |
radius | number | 108 | Distance from the hub center to each petal center, in px. |
spread | number | 360 | Total angular spread of the fan, in degrees; 360 is a full ring. |
centerAngle | number | 0 | Angle the fan is centered on, in degrees (0 = up, clockwise). |
accent | string | "#f0883e" | Accent for focus rings and the active mark. |
className | string | - | Forwarded to the root. |