liten

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.

Terminal
bun add motion
components/ui/radial-menu.tsx
'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

Example.tsx
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

PropTypeDefaultDescription
itemsRadialItem[]-Petals: { label, icon, onSelect? }.
hubIconReactNodea plusIcon on the closed hub; rotates into a close mark when open.
radiusnumber108Distance from the hub center to each petal center, in px.
spreadnumber360Total angular spread of the fan, in degrees; 360 is a full ring.
centerAnglenumber0Angle the fan is centered on, in degrees (0 = up, clockwise).
accentstring"#f0883e"Accent for focus rings and the active mark.
classNamestring-Forwarded to the root.
On this page0%