← Back
Micro-interaction

Radial Menu

Radial menu — items fan out from a floating action button along a circle with staggered spring transitions and reverse-order collapse.

tap the button · staggered spring fan-out

RadialMenu.tsx69 lines
"use client";

import { useState } from "react";

const ITEMS = [
  { emoji: "📷", label: "Photo" },
  { emoji: "🎵", label: "Music" },
  { emoji: "✏️", label: "Edit" },
  { emoji: "📍", label: "Pin" },
  { emoji: "💬", label: "Chat" },
  { emoji: "⭐", label: "Star" },
];

const DISTANCE = 96;

export default function RadialMenu() {
  const [open, setOpen] = useState(false);
  const [selected, setSelected] = useState<string | null>(null);

  return (
    <div className="flex flex-col items-center gap-6 py-10 w-full">
      <div className="relative w-64 h-64 flex items-center justify-center">
        {ITEMS.map((item, i) => {
          const angle = (i / ITEMS.length) * Math.PI * 2 - Math.PI / 2;
          const x = Math.cos(angle) * DISTANCE;
          const y = Math.sin(angle) * DISTANCE;
          return (
            <button
              key={item.label}
              onClick={() => {
                setSelected(item.label);
                setOpen(false);
              }}
              aria-label={item.label}
              tabIndex={open ? 0 : -1}
              style={{
                transform: open
                  ? `translate(${x}px, ${y}px) scale(1)`
                  : "translate(0px, 0px) scale(0.3)",
                opacity: open ? 1 : 0,
                pointerEvents: open ? "auto" : "none",
                transitionDelay: `${(open ? i : ITEMS.length - 1 - i) * 35}ms`,
              }}
              className="absolute w-12 h-12 flex items-center justify-center rounded-full bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 shadow-md text-xl transition-all duration-300 ease-[cubic-bezier(0.34,1.56,0.64,1)] hover:border-zinc-400 dark:hover:border-zinc-500 cursor-pointer"
            >
              {item.emoji}
            </button>
          );
        })}

        <button
          onClick={() => setOpen((v) => !v)}
          aria-expanded={open}
          aria-label="Toggle menu"
          className="relative z-10 w-14 h-14 flex items-center justify-center rounded-full bg-zinc-900 dark:bg-zinc-50 text-white dark:text-zinc-900 text-2xl shadow-lg cursor-pointer transition-transform duration-300 hover:scale-105"
          style={{ transform: open ? "rotate(45deg)" : "rotate(0deg)" }}
        >
          +
        </button>
      </div>

      <p className="text-xs text-zinc-400 dark:text-zinc-600 font-mono">
        {selected ? `selected: ${selected}` : "tap the button"} · staggered
        spring fan-out
      </p>
    </div>
  );
}

More in Micro-interaction