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
| Prop | Type | Default | Description |
|---|---|---|---|
| label | string | Accessible name for the menu and the trigger. | |
| items | RadialMenuItem[] | Items with id, label and icon, first at the top. | |
| onSelect | (id: string) => void | Called with the chosen item id. | |
| radius | number | 76 | Ring radius in px. |
| children | ReactNode | Content of the trigger button. | |
| defaultOpen | boolean | false | Start 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.