Radial Menu
Floating action button that fans its actions out in an arc.
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 />;
}
Edit with AIPro
Describe a change to Radial Menu and get the updated code. The live preview above stays as the original.
Installation
Install the dependencies
$ npm i motion lucide-reactCopy the source into
components/ui/radial-menu.tsxcomponents/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 />; }Use it
The file exports the component and a
Demoshowing typical usage. Import whichever you need.