Radial Flower Menu with Blooming Action Petals

A circular action menu. One round button sits in the middle; pressing it sends six icon buttons out to evenly spaced points on a circle, each a few milliseconds after the last and with a small overshoot, while the plus sign turns into a cross. Pressing again, pressing Escape or clicking elsewhere folds them back. The label and a short hint for the highlighted petal are shown under the flower, so nothing depends on a tooltip. The whole menu is about 256 pixels across and fits a phone screen without changes.

Code

// Adapted from Animata (https://animata.design/docs/fabs/flower-menu)
// Copyright (c) Animata. MIT License: https://we.inc/components/licenses
import { useEffect, useId, useRef, useState, type KeyboardEvent } from "react";
import { Camera, Image, MapPin, Mic, Paperclip, PenLine } from "lucide-react";

const items = [
  { key: "photo", label: "Take a photo", hint: "Opens the camera", icon: Camera },
  { key: "voice", label: "Record a voice note", hint: "Up to five minutes", icon: Mic },
  { key: "place", label: "Pin a place", hint: "Uses your location", icon: MapPin },
  { key: "sketch", label: "Draw a sketch", hint: "Finger or pencil", icon: PenLine },
  { key: "file", label: "Attach a file", hint: "PDF, up to 20 MB", icon: Paperclip },
  { key: "gallery", label: "Pick from gallery", hint: "Choose up to ten", icon: Image },
];

const TOGGLER = 60; // centre button, in pixels
const PETAL = 52; // each action button
const ORBIT = 96; // distance from the centre to each petal
const DURATION = 320; // bloom time in milliseconds

export default function RadialFlowerMenu() {
  const menuId = useId();
  const rootRef = useRef<HTMLDivElement>(null);
  const triggerRef = useRef<HTMLButtonElement>(null);
  const itemRefs = useRef<Array<HTMLButtonElement | null>>([]);
  const byUser = useRef(false);
  const [open, setOpen] = useState(false);
  const [active, setActive] = useState(0);
  const [idle, setIdle] = useState(true);
  const [picked, setPicked] = useState<string | null>(null);

  const size = (ORBIT + PETAL / 2) * 2 + 12;
  const count = items.length;

  // Bloom once on load so the page shows the menu, not just a round button.
  useEffect(() => {
    const timer = setTimeout(() => setOpen(true), 350);
    return () => clearTimeout(timer);
  }, []);

  // Until someone touches it, a soft highlight walks round the petals.
  useEffect(() => {
    if (!open || !idle || window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
    const timer = setInterval(() => setActive((i) => (i + 1) % count), 1400);
    return () => clearInterval(timer);
  }, [open, idle, count]);

  useEffect(() => {
    if (!open) return;
    if (byUser.current) itemRefs.current[0]?.focus();
    const onPointerDown = (event: PointerEvent) => {
      if (!rootRef.current?.contains(event.target as Node)) setOpen(false);
    };
    document.addEventListener("pointerdown", onPointerDown);
    return () => document.removeEventListener("pointerdown", onPointerDown);
  }, [open]);

  const toggle = () => {
    byUser.current = true;
    setIdle(false);
    setOpen((value) => !value);
  };

  const close = () => {
    setOpen(false);
    triggerRef.current?.focus();
  };

  const onKeyDown = (event: KeyboardEvent<HTMLUListElement>) => {
    const current = itemRefs.current.findIndex((node) => node === document.activeElement);
    const go = (i: number) => itemRefs.current[(i + count) % count]?.focus();
    if (event.key === "Escape") {
      event.preventDefault();
      close();
    } else if (event.key === "Tab") setOpen(false);
    else if (event.key === "Home") {
      event.preventDefault();
      go(0);
    } else if (event.key === "End") {
      event.preventDefault();
      go(count - 1);
    } else if (event.key === "ArrowRight" || event.key === "ArrowDown") {
      event.preventDefault();
      go(current + 1);
    } else if (event.key === "ArrowLeft" || event.key === "ArrowUp") {
      event.preventDefault();
      go(current - 1);
    }
  };

  const shown = items[active];

  return (
    <section className="bg-rose-50 px-4 py-16 sm:px-6">
      <style>{`
        .radial-flower-menu-petal { transition: transform ${DURATION}ms cubic-bezier(0.34, 1.56, 0.64, 1), opacity ${Math.round(DURATION * 0.6)}ms ease-out; }
        .radial-flower-menu-ring { transition: transform 300ms ease-out, opacity 300ms ease-out; }
        @media (prefers-reduced-motion: reduce) {
          .radial-flower-menu-petal, .radial-flower-menu-ring { transition: none; }
        }
      `}</style>
      <div className="mx-auto grid max-w-4xl items-center gap-10 md:grid-cols-2">
        <div className="text-center md:text-left">
          <p className="text-sm font-semibold uppercase tracking-widest text-rose-700">Field journal</p>
          <h2 className="mt-3 text-3xl font-semibold tracking-tight text-rose-950 sm:text-4xl">Add anything to today&apos;s page in one tap.</h2>
          <p className="mt-4 text-base leading-relaxed text-rose-900">
            Press the centre button and six ways to capture the moment open around it. Press again and they fold away.
          </p>
          <p aria-live="polite" className="mt-6 min-h-[1.5rem] text-sm font-medium text-rose-800">
            {picked ? `Added to the page: ${picked}.` : "Nothing added yet."}
          </p>
        </div>

        <div className="flex flex-col items-center">
          <div ref={rootRef} className="relative" style={{ width: size, height: size }}>
            {/* Faint guide ring that grows with the bloom. */}
            <span
              aria-hidden="true"
              className="radial-flower-menu-ring absolute inset-0 m-auto rounded-full border border-dashed border-rose-300"
              style={{ width: ORBIT * 2, height: ORBIT * 2, transform: open ? "scale(1)" : "scale(0.3)", opacity: open ? 1 : 0 }}
            />
            <button
              ref={triggerRef}
              type="button"
              aria-expanded={open}
              aria-haspopup="menu"
              aria-controls={menuId}
              aria-label={open ? "Close add menu" : "Open add menu"}
              onClick={toggle}
              className="absolute inset-0 z-20 m-auto flex items-center justify-center rounded-full bg-rose-600 text-white shadow-lg shadow-rose-600/30 transition-colors hover:bg-rose-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rose-900 focus-visible:ring-offset-2 focus-visible:ring-offset-rose-50"
              style={{ width: TOGGLER, height: TOGGLER }}
            >
              <span aria-hidden="true" className="relative block h-5 w-5">
                <span className={`absolute left-0 top-1/2 h-0.5 w-5 -translate-y-1/2 rounded bg-current transition-transform duration-200 motion-reduce:transition-none ${open ? "rotate-45" : ""}`} />
                <span className={`absolute left-0 top-1/2 h-0.5 w-5 -translate-y-1/2 rounded bg-current transition-transform duration-200 motion-reduce:transition-none ${open ? "-rotate-45" : "rotate-90"}`} />
              </span>
            </button>

            <ul id={menuId} role="menu" aria-label="Add to page" aria-hidden={!open} onKeyDown={onKeyDown} className="absolute inset-0 m-0 list-none p-0">
              {items.map((item, index) => {
                const Icon = item.icon;
                const angle = (360 / count) * index + 90;
                const lit = open && index === active;
                return (
                  <li
                    key={item.key}
                    role="none"
                    className={`radial-flower-menu-petal absolute inset-0 m-auto ${open ? "opacity-100" : "pointer-events-none opacity-0"}`}
                    style={{
                      width: PETAL,
                      height: PETAL,
                      transitionDelay: open ? `${30 + index * 35}ms` : `${(count - 1 - index) * 20}ms`,
                      transform: open ? `rotate(${angle}deg) translateX(-${ORBIT}px) rotate(-${angle}deg) scale(1)` : `rotate(${angle - 60}deg) translateX(0px) rotate(-${angle - 60}deg) scale(0.5)`,
                    }}
                  >
                    <button
                      ref={(node) => {
                        itemRefs.current[index] = node;
                      }}
                      type="button"
                      role="menuitem"
                      aria-label={item.label}
                      tabIndex={open ? 0 : -1}
                      onPointerEnter={() => {
                        setIdle(false);
                        setActive(index);
                      }}
                      onFocus={() => {
                        setIdle(false);
                        setActive(index);
                      }}
                      onClick={() => {
                        setPicked(item.label.toLowerCase());
                        close();
                      }}
                      className={`flex h-full w-full items-center justify-center rounded-full border shadow-md transition-[transform,background-color,color,border-color] duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rose-900 focus-visible:ring-offset-2 focus-visible:ring-offset-rose-50 active:scale-95 motion-reduce:transition-none ${
                        lit ? "scale-110 border-rose-600 bg-rose-600 text-white" : "border-rose-200 bg-white text-rose-700"
                      }`}
                    >
                      <Icon aria-hidden="true" className="h-5 w-5" />
                    </button>
                  </li>
                );
              })}
            </ul>
          </div>
          {/* The label of the highlighted petal, so it never depends on hover alone. */}
          <div className={`mt-4 h-12 text-center transition-opacity duration-200 motion-reduce:transition-none ${open ? "opacity-100" : "opacity-0"}`}>
            <p className="text-sm font-semibold text-rose-950">{shown.label}</p>
            <p className="text-xs text-rose-800">{shown.hint}</p>
          </div>
        </div>
      </div>
    </section>
  );
}

Install with one command: npx shadcn@latest add https://we.inc/r/radial-flower-menu-with-blooming-action-petals.json

When to use it

How to customize it

Accessibility

Questions

How are the petals placed on a circle?

Each petal starts in the centre and gets the transform rotate, translateX, rotate back. The first rotation points it in its direction, the translate pushes it out by the orbit distance and the second rotation turns the icon upright again. No trigonometry is needed.

Why is there a label under the flower?

Icon-only buttons are hard to guess and tooltips do not exist on touch screens. The caption shows the name and a hint for whichever petal is hovered, focused or highlighted, so every visitor can read what an action does before pressing it.

Does it open by itself on my site?

In this demo it blooms once after load so the preview shows the open state, and a highlight walks round the petals until someone interacts. Remove the first two effects to keep it closed until pressed.

Can the petals be links?

Yes. Replace the inner `button` with an `a` that has an `href` and keep `role="menuitem"`, the label and the ref. The keyboard handling works the same way for links.

How do I pin it to a corner of the screen?

Wrap the flower container in an element with `fixed bottom-6 right-6`. For a corner position you will usually want a quarter circle: use `90 / (count - 1)` as the step between petals and start the angle at `0`.

Adapted from Flower Menu in Animata, used under its open-source licence. Licences and credits

Related: Expanding Action Button That Morphs into a Quick Menu, Share Button That Expands into Icon Row, Magnifying Dock with Tooltips and Idle Wave.

More docks and floating menus or all free UI components. Start for free to have a full site built around it.

More in Docks

We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.

Product

Who It's For

Features

Resources

Company

View Sitemap