Nuevo
React + MotionNavigation

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

88px
6

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.