Radial Menu

Floating action button that fans its actions out in an arc.

Edit with AIPro

Describe a change to Radial Menu and get the updated code. The live preview above stays as the original.

Installation

  1. Install the dependencies

    $ npm i motion lucide-react
  2. Copy the source into components/ui/radial-menu.tsx

    components/ui/radial-menu.tsx
    "use client";
    
    import { AnimatePresence, motion } from "motion/react";
    import { Camera, FileText, Link2, Mic, Plus, Video } from "lucide-react";
    import { useState, type ComponentType } from "react";
    
    type Action = { label: string; icon: ComponentType<{ className?: string }>; color: string };
    
    const defaults: Action[] = [
      { label: "Photo", icon: Camera, color: "#2563eb" },
      { label: "Video", icon: Video, color: "#7c3aed" },
      { label: "Voice note", icon: Mic, color: "#db2777" },
      { label: "Document", icon: FileText, color: "#ea580c" },
      { label: "Link", icon: Link2, color: "#059669" },
    ];
    
    /** Floating action button that fans its actions out in an arc, with springy stagger. */
    export function RadialMenu({ actions = defaults, radius = 96, onSelect }: { actions?: Action[]; radius?: number; onSelect?: (label: string) => void }) {
      const [open, setOpen] = useState(false);
      const [last, setLast] = useState<string | null>(null);
    
      return (
        <div className="relative grid h-72 w-72 place-items-end" onKeyDown={(e) => e.key === "Escape" && setOpen(false)}>
          {last && <p className="absolute left-0 top-0 text-sm text-zinc-500">Added: {last}</p>}
          <div className="relative m-4 size-14">
            <AnimatePresence>
              {open &&
                actions.map((a, i) => {
                  // Spread across a quarter circle, from straight up to straight left.
                  const angle = (Math.PI / 2) * (i / (actions.length - 1));
                  return (
                    <motion.button
                      key={a.label}
                      aria-label={a.label}
                      title={a.label}
                      onClick={() => {
                        setLast(a.label);
                        setOpen(false);
                        onSelect?.(a.label);
                      }}
                      className="absolute left-1 top-1 grid size-12 place-items-center rounded-full text-white shadow-lg"
                      style={{ background: a.color }}
                      initial={{ x: 0, y: 0, scale: 0.3, opacity: 0 }}
                      animate={{ x: -Math.sin(angle) * radius, y: -Math.cos(angle) * radius, scale: 1, opacity: 1 }}
                      exit={{ x: 0, y: 0, scale: 0.3, opacity: 0 }}
                      transition={{ type: "spring", stiffness: 420, damping: 24, delay: i * 0.035 }}
                      whileHover={{ scale: 1.12 }}
                    >
                      <a.icon className="size-5" />
                    </motion.button>
                  );
                })}
            </AnimatePresence>
            <motion.button
              aria-label={open ? "Close menu" : "Open menu"}
              aria-expanded={open}
              onClick={() => setOpen((o) => !o)}
              animate={{ rotate: open ? 45 : 0 }}
              transition={{ type: "spring", stiffness: 400, damping: 22 }}
              className="relative grid size-14 place-items-center rounded-full bg-zinc-900 text-white shadow-xl"
            >
              <Plus className="size-6" />
            </motion.button>
          </div>
        </div>
      );
    }
    
    export default function Demo() {
      return <RadialMenu />;
    }
    
  3. Use it

    The file exports the component and a Demo showing typical usage. Import whichever you need.