Skip to content

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/react

For 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).

Reference

Propsitems, trigger, radius, startAngle, sweep, open, onSelect, container…

Props

RadialMenu props
PropType
itemsRequiredRadialMenuItem[]–
triggerRadialMenuTrigger
'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'
radiusnumber

Distance from the center to the slots' centers, in px.

default 84

84
startAnglenumber

Where the first slot sits, in degrees: 0 is up, 90 is the inline end side.

default 0

0
sweepnumber

How 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
openboolean–
defaultOpenboolean

default false

false
onOpenChange(open: boolean) => void–
onSelect(item: RadialMenuItem) => void

Runs with the chosen item, after the item's own onSelect.

–
labelstring

The ring's accessible name. Default: the actions label.

–
holdDelaynumber

How long a press is held before the ring opens, in ms. Default: the slow token.

–
disabledboolean

The trigger opens nothing.

default false

false
soundboolean

Play the kit's cues: open, a tick per slot passed, select and close.

default true

true
containerHTMLElement | null

Where the ring renders. Default: the provider's portal container, else document.body. It must be a positioning context for fixed children.

–
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

Motion tokens
MomentTokens
Fan outthe slots spring out from the center to the ring, one after another
spring.smoothstagger.tight

0.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.snappy

0.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.snappy

0.2s, bounce 0

0.2s, bounce 0
Collapsethe slots fall back into the center in reverse order and fade
duration.fastease.exit

140ms · 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 with aria-label when its content doesn't say what it is.
  • The ring is a menu named by label (default "Actions") and described by a hint on the keys; each slot is a menuitem named 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.