Skip to content
goodtabs UI
BrowseBrowse components

Radial menu

A menu whose items fan out on a ring, placed with CSS cos() and sin().

npx shadcn@latest add https://ui.goodtabs.co/r/radial-menu.json

With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/radial-menu

Six actions on a ring around a plus button.

Showing the working

  • θ = -90° + i × 360° / 4
  • x = cos θ × 72, y = sin θ × 72
  • Picked: none yet
  • Arrows move, Escape closes

Four items with the angle formula and the picked id.

Usage

import { RadialMenu } from "@/components/ui/radial-menu"

<RadialMenu label="Actions" items={items} onSelect={run}>+</RadialMenu>

Props

PropTypeDefaultDescription
labelstringAccessible name for the menu and the trigger.
itemsRadialMenuItem[]Items with id, label and icon, first at the top.
onSelect(id: string) => voidCalled with the chosen item id.
radiusnumber76Ring radius in px.
childrenReactNodeContent of the trigger button.
defaultOpenbooleanfalseStart with the ring open. Focus does not move.

Source

radial-menu/radial-menu.tsx
"use client";

import * as React from "react";
import { cn } from "@/lib/utils";

export type RadialMenuItem = { id: string; label: string; icon: React.ReactNode };

type RadialMenuProps = Omit<React.ComponentProps<"div">, "onSelect"> & {
  /** Accessible name for the menu and the trigger. */
  label: string;
  /** Items placed evenly on the ring, first at the top. */
  items: RadialMenuItem[];
  /** Called with the chosen item id. */
  onSelect?: (id: string) => void;
  /** Ring radius in px. */
  radius?: number;
  /** Start with the ring open. Focus stays where it is until someone reaches for the menu. */
  defaultOpen?: boolean;
  /** Content of the trigger button. */
  children?: React.ReactNode;
};

/**
 * A radial menu. Item i of n sits at θ = -90deg + i × 360deg / n, placed with CSS
 * cos() and sin(). Real menu semantics: arrow keys move, Home and End jump, Escape closes.
 */
export function RadialMenu({
  label,
  items,
  onSelect,
  radius = 76,
  defaultOpen = false,
  className,
  children,
  ...props
}: RadialMenuProps) {
  const [open, setOpen] = React.useState(defaultOpen);
  const [active, setActive] = React.useState(0);
  const triggerRef = React.useRef<HTMLButtonElement>(null);
  const refs = React.useRef<(HTMLButtonElement | null)[]>([]);
  const n = items.length;
  const box = radius * 2 + 44;

  function focusItem(i: number) {
    const next = (i + n) % n;
    setActive(next);
    refs.current[next]?.focus();
  }

  function close(returnFocus: boolean) {
    setOpen(false);
    if (returnFocus) triggerRef.current?.focus();
  }

  function onKeyDown(e: React.KeyboardEvent) {
    if (e.key === "ArrowRight" || e.key === "ArrowDown") focusItem(active + 1);
    else if (e.key === "ArrowLeft" || e.key === "ArrowUp") focusItem(active - 1);
    else if (e.key === "Home") focusItem(0);
    else if (e.key === "End") focusItem(n - 1);
    else if (e.key === "Escape" || e.key === "Tab") close(e.key === "Escape");
    else return;
    if (e.key !== "Tab") e.preventDefault();
  }

  return (
    <div
      className={cn("relative grid shrink-0 place-items-center", className)}
      style={{ width: box, height: box }}
      onKeyDown={open ? onKeyDown : undefined}
      {...props}
    >
      <button
        ref={triggerRef}
        type="button"
        aria-haspopup="menu"
        aria-expanded={open}
        aria-label={label}
        onClick={() => {
          setOpen((o) => !o);
          setActive(0);
          if (!open) requestAnimationFrame(() => refs.current[0]?.focus());
        }}
        className="z-10 grid size-11 place-items-center rounded-full border bg-background text-sm text-foreground transition-colors hover:bg-muted"
      >
        {children}
      </button>
      <div role="menu" aria-label={label} className="absolute inset-0">
        {items.map((item, i) => (
          <button
            key={item.id}
            ref={(el) => {
              refs.current[i] = el;
            }}
            type="button"
            role="menuitem"
            aria-label={item.label}
            title={item.label}
            tabIndex={open && i === active ? 0 : -1}
            data-open={open}
            style={
              {
                "--i": i,
                "--n": n,
                "--r": `${radius}px`,
                "--a": "calc(-90deg + var(--i) * 360deg / var(--n))",
              } as React.CSSProperties
            }
            onClick={() => {
              onSelect?.(item.id);
              close(true);
            }}
            className={cn(
              "absolute top-1/2 left-1/2 grid size-10 place-items-center rounded-full border bg-background text-foreground",
              "translate-x-[calc(-50%+cos(var(--a))*var(--r))] translate-y-[calc(-50%+sin(var(--a))*var(--r))]",
              "transition-[opacity,scale,background-color] duration-150 ease-out motion-reduce:transition-none",
              "hover:bg-muted data-[open=false]:pointer-events-none data-[open=false]:invisible data-[open=false]:scale-50 data-[open=false]:opacity-0",
            )}
          >
            {item.icon}
          </button>
        ))}
      </div>
    </div>
  );
}

For AI agents

Hand your agent this page, the markdown at /components/radial-menu/prompt.md, or the Copy AI prompt button above.

When to use

  • A short set of four to eight actions on one object
  • Touch-friendly actions that should feel close to the trigger

When not to use

  • Long or labelled lists, use a dropdown menu
  • More than eight items, the chord gets too short

Agent notes

  • Client component. A demo that passes onSelect must be a client component too
  • Items are icon only with aria-label and title, so every icon needs a clear meaning
  • Arrows move focus around the ring, Home and End jump, Escape closes and returns focus
  • Placement uses CSS cos() and sin(), supported in current evergreen browsers

The maths

  • Item i of n sits at θ = -90deg + i × 360deg / n, so the first is at the top.
  • Offset x = cos θ × r and y = sin θ × r from the trigger centre.
  • With n = 6 and r = 64 the neighbour gap is 2r × sin(180deg / 6) = 64px.