Menu radial d’actions
Un bouton plus flottant déploie des actions en cercle avec un effet ressort décalé, et le nom de chacune défile en dessous.
Réglages
Code
import { useEffect, useRef, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import { Camera, FileText, Image as ImageIcon, Link2, MapPin, Mic, Plus } from "lucide-react";
const RADIAL_ITEMS = [
{ label: "Appareil photo", icon: Camera },
{ label: "Galerie", icon: ImageIcon },
{ label: "Audio", icon: Mic },
{ label: "Position", icon: MapPin },
{ label: "Note", icon: FileText },
{ label: "Lien", icon: Link2 },
];
interface RadialMenuProps {
color?: string;
radius?: number;
count?: number;
}
export default function RadialMenu({ color = "#ff5a1f", radius = 88, count = 6 }: RadialMenuProps) {
const [open, setOpen] = useState(false);
const [hover, setHover] = useState(-1);
const [picked, setPicked] = useState(-1);
const reduce = useReducedMotion();
const wrapRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const itemRefs = useRef<(HTMLButtonElement | null)[]>([]);
const items = RADIAL_ITEMS.slice(0, count);
const size = radius * 2 + 64;
useEffect(() => {
if (!open) return;
const onDown = (e: PointerEvent) => {
if (!wrapRef.current?.contains(e.target as Node)) setOpen(false);
};
document.addEventListener("pointerdown", onDown);
return () => document.removeEventListener("pointerdown", onDown);
}, [open]);
const focusItem = (i: number) => {
const k = (i + items.length) % items.length;
setHover(k);
itemRefs.current[k]?.focus();
};
const close = () => {
setOpen(false);
setHover(-1);
triggerRef.current?.focus();
};
const pick = (i: number) => {
setPicked(i);
close();
};
const label = open
? (items[hover]?.label ?? "Choisir une action")
: picked >= 0
? (items[picked]?.label ?? "") + " ajouté"
: "Nouveau";
return (
<div
ref={wrapRef}
onKeyDown={(e) => {
if (!open) return;
if (e.key === "ArrowRight" || e.key === "ArrowDown") {
e.preventDefault();
focusItem(hover + 1);
} else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
e.preventDefault();
focusItem(hover < 0 ? items.length - 1 : hover - 1);
} else if (e.key === "Escape") {
close();
}
}}
className="relative flex max-w-full flex-col items-center gap-3"
style={{ width: size }}
>
<div className="relative grid place-items-center" style={{ width: size, height: size }}>
<motion.div
aria-hidden
className="absolute rounded-full border border-black/[0.06] bg-black/[0.02] dark:border-white/[0.08] dark:bg-white/[0.03]"
style={{ width: radius * 2 + 56, height: radius * 2 + 56 }}
initial={false}
animate={{ scale: open ? 1 : 0.94, opacity: open ? 1 : 0.7 }}
transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 260, damping: 26 }}
/>
{items.map((item, i) => {
const a = ((-90 + (360 / items.length) * i) * Math.PI) / 180;
return (
<motion.span
key={"dot-" + item.label}
aria-hidden
className="absolute left-1/2 top-1/2 -ml-[3px] -mt-[3px] h-1.5 w-1.5 rounded-full bg-black/20 dark:bg-white/25"
style={{ x: Math.cos(a) * radius, y: Math.sin(a) * radius }}
initial={false}
animate={{ scale: open ? 0 : 1, opacity: open ? 0 : 1 }}
transition={
reduce ? { duration: 0 } : { duration: 0.2, delay: open ? 0 : 0.15 + i * 0.03 }
}
/>
);
})}
{items.map((item, i) => {
const a = ((-90 + (360 / items.length) * i) * Math.PI) / 180;
const Icon = item.icon;
const lit = hover === i;
return (
<motion.button
key={item.label}
ref={(el) => {
itemRefs.current[i] = el;
}}
type="button"
aria-label={item.label}
tabIndex={open ? 0 : -1}
aria-hidden={!open}
onClick={() => pick(i)}
onPointerEnter={() => setHover(i)}
onPointerLeave={() => setHover(-1)}
onFocus={() => setHover(i)}
initial={false}
animate={
open
? {
x: Math.cos(a) * radius,
y: Math.sin(a) * radius,
scale: lit ? 1.12 : 1,
opacity: 1,
}
: { x: 0, y: 0, scale: 0.4, opacity: 0 }
}
whileTap={{ scale: 0.92 }}
transition={
reduce
? { duration: 0 }
: {
type: "spring",
stiffness: 480,
damping: 26,
delay: open && hover < 0 ? i * 0.03 : 0,
}
}
className="absolute left-1/2 top-1/2 -ml-[22px] -mt-[22px] grid h-11 w-11 place-items-center rounded-full bg-white text-[#1d1d1f] shadow-[0_1px_2px_rgba(0,0,0,0.06),0_6px_16px_rgba(0,0,0,0.08)] outline-none ring-1 ring-black/[0.06] transition-colors duration-200 dark:bg-[#2c2c2e] dark:text-[#f5f5f7] dark:ring-white/10"
style={{
pointerEvents: open ? "auto" : "none",
backgroundColor: lit ? color : undefined,
color: lit ? "#fff" : undefined,
}}
>
<Icon className="h-[18px] w-[18px]" strokeWidth={1.5} />
</motion.button>
);
})}
<motion.button
ref={triggerRef}
type="button"
aria-label={open ? "Fermer les actions" : "Ouvrir les actions"}
aria-expanded={open}
onClick={() => (open ? close() : setOpen(true))}
onKeyDown={(e) => {
if (open && (e.key === "ArrowRight" || e.key === "ArrowDown")) {
e.preventDefault();
e.stopPropagation();
focusItem(0);
}
}}
whileHover={{ scale: 1.04 }}
whileTap={{ scale: 0.92 }}
transition={{ type: "spring", stiffness: 500, damping: 28 }}
className="relative z-10 grid h-14 w-14 place-items-center rounded-full text-white outline-none focus-visible:ring-4"
style={{
backgroundColor: color,
boxShadow:
"inset 0 1px 0 rgba(255,255,255,0.25), 0 2px 4px rgba(0,0,0,0.12), 0 12px 28px " +
color +
"55",
["--tw-ring-color" as string]: color + "40",
}}
>
<motion.span
animate={{ rotate: open ? 135 : 0 }}
transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 320, damping: 18 }}
className="grid place-items-center"
>
<Plus className="h-6 w-6" strokeWidth={2} />
</motion.span>
</motion.button>
</div>
<div
aria-live="polite"
className="relative h-5 w-full overflow-hidden text-center text-[13px] font-medium text-[#86868b]"
>
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={label}
initial={{ y: 12, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: -12, opacity: 0 }}
transition={{ type: "spring", stiffness: 500, damping: 36 }}
className="absolute inset-x-0"
style={{ color: open && hover >= 0 ? color : undefined }}
>
{label}
</motion.span>
</AnimatePresence>
</div>
</div>
);
}
Prompt pour l’IA
Collez-le dans ChatGPT, Claude ou Cursor : le bloc sera adapté à votre projet avec ces réglages, même sans React.
Plus dans Navigation
Où l’utiliser
Un menu radial est pratique quand un seul bouton doit cacher plusieurs façons de créer quelque chose : le trombone d’une messagerie, le bouton « nouveau » d’une appli de notes, la saisie rapide d’un outil de terrain. Celui-ci ouvre caméra, galerie, audio, lieu, note et lien autour d’un bouton d’action flottant, au clavier aussi.
Six actions, c’est le maximum ; avec quatre, on lit encore plus vite. Sur mobile, le nom n’apparaît qu’après le toucher, les icônes doivent donc parler d’elles-mêmes. Gardez un petit rayon sur les écrans étroits, sinon les boutons extérieurs finissent sous le pouce.