Radial Menu
A ring of actions that fans out around the pointer: hold, drag toward one and let go.
import { RadialMenu, RadialMenuItem } from '@youj/react'
Installation & usage
npm i @youj/reactFor an app already set up with npx @youj/cli init, which installs the peers (@youj/core, @youj/motion, motion and Base UI) and wires the provider. New app? Start at Installation.
Use a radial menu for a handful of actions people repeat often on a surface: tools on a canvas, quick actions on a card. Directions are quick to learn and fast to repeat, but only up to about eight slots; for more, or for actions that need words, use a Context Menu or a Dropdown Menu. Like any gesture-only menu, give the same actions a visible control too.
import { CodeIcon, FrameIcon, PenToolIcon, ShapesIcon, TypeIcon } from '@youj/icons'
import { RadialMenu, type RadialMenuItem } from '@youj/react'
const tools: RadialMenuItem[] = [
{ id: 'frame', label: 'Frame', icon: <FrameIcon /> },
{ id: 'text', label: 'Text', icon: <TypeIcon /> },
{ id: 'shape', label: 'Shape', icon: <ShapesIcon /> },
{ id: 'pen', label: 'Pen', icon: <PenToolIcon /> },
{ id: 'code', label: 'Code', icon: <CodeIcon /> },
]
export function Canvas({ onTool }: { onTool: (id: string) => void }) {
return (
<RadialMenu items={tools} onSelect={(item) => onTool(item.id)} label="Tools" aria-label="Canvas" className="h-96">
{/* the canvas */}
</RadialMenu>
)
}RadialMenu renders the trigger itself: a div with your children, className and props, or your own element through render (render={<Button />}). trigger says how a pointer opens it: hold (the default) after a press held still, click, or contextmenu for a right-click, where holding the button down and letting go on a slot picks it too. From the keyboard, Enter, Space, the context menu key and Shift+F10 open it on any trigger, centered on the trigger.
Each item is { id, label, icon, onSelect?, disabled? }. The item's own onSelect runs first, then the menu's onSelect with the item. open can be controlled (onOpenChange), radius sets the distance to the slots (crowded slots push it out), holdDelay the hold in ms, and sound={false} silences its cues.
To keep the ring inside part of the page, as these previews do, pass that element as container and give it contain: paint (or a transform).
Description
A design canvas where pressing and holding anywhere opens a ring of tools around the pointer.
Drag toward a tool and let go to pick it, in one gesture. The slot is chosen by direction, as in a pie menu, so the pointer only has to head the right way. Letting go in the middle leaves the ring open, so you can click a tool instead. A quick click does nothing, so the canvas keeps its own clicks.
A button in a bottom bar whose actions fan upward in a half ring.
startAngle={-90} and sweep={180} make the arc run from the start side, over the top, to the end side. A full ring opened near an edge does this by itself: it shifts inward, and if it would have to move far from the pointer it becomes a half ring facing away from the edge.
Code
radial-menu-demo.tsx, the Canvas tools story. The component itself comes from the package.
Reference
Propsitems, trigger, radius, startAngle, sweep, open, onSelect, container…
Props
| Prop | Type | |
|---|---|---|
itemsRequired | RadialMenuItem[] | – |
trigger | RadialMenuTrigger'hold''click''contextmenu'How a pointer opens it: a held press (default), a click, or a right-click. The keyboard opens it in every mode. default 'hold' | 'hold' |
radius | numberDistance from the center to the slots' centers, in px. default 84 | 84 |
startAngle | numberWhere the first slot sits, in degrees: 0 is up, 90 is the inline end side. default 0 | 0 |
sweep | numberHow much of the circle the slots span, in degrees: 360 is a full ring, less is an arc whose ends hold a slot each. default 360 | 360 |
open | boolean | – |
defaultOpen | booleandefault false | false |
onOpenChange | (open: boolean) => void | – |
onSelect | (item: RadialMenuItem) => voidRuns with the chosen item, after the item's own | – |
label | stringThe ring's accessible name. Default: the | – |
holdDelay | numberHow long a press is held before the ring opens, in ms. Default: the slow token. | – |
disabled | booleanThe trigger opens nothing. default false | false |
sound | booleanPlay the kit's cues: open, a tick per slot passed, select and close. default true | true |
container | HTMLElement | nullWhere the ring renders. Default: the provider's portal container, else | – |
Other props pass through: extends useRender.ComponentProps<'div'>. | ||
RadialMenu also takes a div's props (or those of the element you render) for the trigger.
Data attributes
The trigger is data-slot="radial-menu-trigger" with data-state (open or closed), data-yj-trigger (its trigger) and data-disabled. The ring is data-slot="radial-menu" inside radial-menu-layer, the middle radial-menu-hub, and each slot radial-menu-item with data-highlighted while it's aimed at or focused and data-disabled when it can't be chosen.
MotionFan out · highlight · hub · collapse · reduced motion
Motion
| Moment | Tokens | ||
|---|---|---|---|
| Fan outthe slots spring out from the center to the ring, one after another | spring.smoothstagger.tight0.36s, bounce 0 · 24ms step | 0.36s, bounce 024ms step | |
| Hubthe middle pops in and names the highlighted action; the name fades up as it changes | spring.snappy0.2s, bounce 0 | 0.2s, bounce 0 | |
| Highlightthe slot aimed at grows and fills with the accent, and an accent arc on the hub turns to point at it | spring.snappy0.2s, bounce 0 | 0.2s, bounce 0 | |
| Collapsethe slots fall back into the center in reverse order and fade | duration.fastease.exit140ms · cubic-bezier(0.4, 0, 1, 1) | 140mscubic-bezier(0.4, 0, 1, 1) |
Reduced motion
The ring is there at once and gone at once; the highlight and the hub's label still change.
Sound
With sound on in the provider, the ring plays open, a tick for each slot it passes, select when an action is chosen and close when it's dismissed. sound={false} silences it.
AccessibilityMenu, roving focus, keyboard, announcement
Accessibility
- The trigger is a button (
aria-haspopup="menu",aria-expanded) unless you render your own element. Name it witharia-labelwhen its content doesn't say what it is. - The ring is a
menunamed bylabel(default "Actions") and described by a hint on the keys; each slot is amenuitemnamed by its label. - Focus moves into the ring when it opens: onto the first slot from the keyboard, onto the ring from a pointer. Focus follows the highlight, so pointer and keyboard share one place.
- ← and → (swapped in right-to-left) and ↑ and ↓ go around the ring, skipping disabled slots; Home and End go to the first and last. Enter or Space chooses.
- Escape, Tab or a click outside closes the ring and returns focus to the trigger.
- The chosen action is announced politely: "Pen chosen".
- In right-to-left the ring is mirrored, so the slots run counterclockwise from the start.