Radial menuNew

One starting point, a few ways forward. Commands unfold into a small clockwise constellation.

Install with the shadcn CLI

$ npx shadcn@latest add https://hairlineui.com/r/radial-menu.json

Overview

Put a compact set of creative commands around the control that opens them. Fine spokes grow from the centre, buttons travel out with a 24ms stagger, and the source turns into a close control. Each command has a visible name and a real menu button. Selection folds the constellation home and returns focus to its source.

States

open
Commands move to fixed clockwise anchors over 460ms, staggered by 24ms. The first enabled command receives focus.
navigate
Arrow keys move through enabled commands in the same order as the ring. Home and End reach the first and last.
choose
Selecting a command calls onSelect with its id, closes the menu and returns focus to the central control.
close
Escape, a second trigger click or focus leaving the menu folds commands home. Closed items are inert.

Usage

usage.tsx
import { RadialMenu } from "@/components/radial-menu";import { Image, StickyNote, Link } from "lucide-react";<RadialMenu label="Add a block" onSelect={insertBlock} items={[  { id: "note", label: "Note", icon: <StickyNote /> },  { id: "image", label: "Image", icon: <Image /> },  { id: "link", label: "Link", icon: <Link /> },]} />

A starting point in a canvas

Keep the commands short and local to a creative workspace. Four to six items give names enough breathing room; the component reserves its own space rather than covering surrounding content.

example.tsx
"use client";import { useState } from "react";import { RadialMenu, type RadialMenuItem } from "@/components/radial-menu";export function CanvasTools({ items, insertBlock }: {  items: RadialMenuItem[]; insertBlock: (block: { type: string }) => void;}) {  const [open, setOpen] = useState(false);  return <RadialMenu items={items} label="Add a block" open={open}    onOpenChange={setOpen} onSelect={(id) => insertBlock({ type: id })} />;}

Props

PropTypeDescription
itemsRadialMenuItem[]Commands with id, short label, icon and optional disabled.
onSelect(id: string) => voidRuns the selected command. The application owns what it creates.
labelstringdefault InsertAccessible name of the trigger and menu.
open / defaultOpenbooleandefault falseControlled or initial disclosure state.
onOpenChange(open: boolean) => voidReports opening and closing requests.
disabledbooleandefault falseDisables the source and keeps the menu closed.

Accessibility and motion

  • The trigger exposes aria-haspopup and aria-expanded. Arrow Down opens the menu; native Enter and Space also activate the trigger.
  • Disabled commands are skipped during keyboard navigation. Escape and selection return focus to the trigger; Tab can leave normally.
  • Geometry is measured only when the container resizes. Opening and closing move buttons and spokes through transforms and opacity.
  • The reserved 284px canvas adapts its radius to the container. Use short labels and at least 240px of width.
  • Reduced motion changes the disclosure in place without the stagger, travel or rotating source. Icons are supplied by your application.

Requirements

React 19, Tailwind CSS v4 and shadcn/ui theme variables (any style, light and dark). Icons from lucide-react.

Source

components/radial-menu.tsx
'use client';import * as React from 'react';import { EASE_OUT, EASE_SETTLE, INTERACTION_FOCUS, useInteractionReducedMotion } from './ui-interaction-motion';import { useRadialMenu } from './use-radial-menu';export type RadialMenuItem = { id: string; label: string; icon: React.ReactNode; disabled?: boolean };export interface RadialMenuProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onSelect'> {  items: RadialMenuItem[]; label?: string; open?: boolean; defaultOpen?: boolean; disabled?: boolean;  onOpenChange?: (open: boolean) => void; onSelect: (id: string) => void;}/** Commands unfold from their source into a clockwise ring; the closed menu has no focusable hidden items. */export function RadialMenu({ items, label = 'Insert', open: controlled, defaultOpen = false, disabled = false, onOpenChange, onSelect, className = '', ...props }: RadialMenuProps) {  const reduced = useInteractionReducedMotion();  const [own, setOwn] = React.useState(defaultOpen);  const open = !disabled && (controlled ?? own);  const change = (next: boolean) => { if (controlled === undefined) setOwn(next); onOpenChange?.(next); };  const enabled = items.flatMap((item, index) => item.disabled ? [] : [index]);  const menu = useRadialMenu(items.length, enabled, open, change);  const id = React.useId();  return <div {...props} ref={menu.root} className={`relative h-[284px] w-full text-foreground ${className}`}    onKeyDown={(event) => { if (event.key === 'Escape' && open) { event.preventDefault(); menu.close(); } }}    onBlur={(event) => { if (!event.currentTarget.contains(event.relatedTarget as Node | null)) menu.close(false); }}>    <div aria-hidden="true" className="pointer-events-none absolute left-1/2 top-1/2 rounded-full border border-border" style={{ width: menu.radius * 2, height: menu.radius * 2, marginLeft: -menu.radius, marginTop: -menu.radius, opacity: open ? 1 : 0, transform: open || reduced ? 'none' : 'scale(0.35)', transition: reduced ? 'none' : `transform 480ms ${EASE_OUT}, opacity 220ms ${EASE_OUT}` }} />    <div id={id} role="menu" aria-label={label} aria-hidden={!open} inert={!open}>      {items.map((item, index) => {        const point = menu.point(index), delay = open ? index * 24 : 0;        return <React.Fragment key={item.id}>          <span aria-hidden="true" className="pointer-events-none absolute left-1/2 top-1/2 h-px origin-left bg-border" style={{ width: menu.radius - 22, transform: `rotate(${point.angle}deg) scaleX(${open ? 1 : 0})`, opacity: open ? 1 : 0, transition: reduced ? 'none' : `transform 400ms ${EASE_OUT} ${delay}ms, opacity 160ms ${EASE_OUT}` }} />          <button ref={(element) => { if (element) menu.buttons.current.set(index, element); else menu.buttons.current.delete(index); }} type="button" role="menuitem" disabled={item.disabled} tabIndex={open && index === enabled[0] ? 0 : -1}            onFocus={(event) => menu.buttons.current.forEach((button) => { button.tabIndex = button === event.currentTarget ? 0 : -1; })}            onKeyDown={(event) => menu.navigate(event, index)} onClick={() => { menu.close(); onSelect(item.id); }}            className={`absolute left-1/2 top-1/2 -ml-5 -mt-5 flex size-10 cursor-pointer items-center justify-center rounded-[14px] border border-border bg-background text-muted-foreground transition-colors hover:bg-accent hover:text-primary disabled:cursor-default disabled:opacity-30 [&>svg]:size-[18px] ${INTERACTION_FOCUS}`}            style={{ opacity: open ? 1 : 0, transform: open ? `translate3d(${point.x}px,${point.y}px,0) scale(1)` : reduced ? 'none' : 'translate3d(0,0,0) scale(0.5)', transition: reduced ? 'none' : `transform 460ms ${EASE_SETTLE} ${delay}ms, opacity ${open ? 240 : 120}ms ${EASE_OUT} ${delay}ms, background-color 180ms, color 180ms`, willChange: open ? 'transform' : undefined }}>            <span aria-hidden="true" className="flex [&>svg]:size-[18px]">{item.icon}</span>            <span className="pointer-events-none absolute top-full mt-2 rounded-full bg-background px-1.5 text-[10px] font-medium leading-4 whitespace-nowrap">{item.label}</span>          </button>        </React.Fragment>;      })}    </div>    <button ref={menu.trigger} type="button" aria-label={label} aria-haspopup="menu" aria-expanded={open} aria-controls={id} disabled={disabled || !enabled.length}      onClick={() => change(!open)} onKeyDown={(event) => { if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { event.preventDefault(); change(true); } }}      className={`absolute left-1/2 top-1/2 -ml-6 -mt-6 flex size-12 cursor-pointer items-center justify-center rounded-full border border-primary bg-primary text-primary-foreground disabled:cursor-default disabled:opacity-40 ${INTERACTION_FOCUS}`}>      <svg aria-hidden="true" viewBox="0 0 20 20" className="size-5" style={{ transform: open ? 'rotate(135deg)' : 'rotate(0deg)', transition: reduced ? 'none' : `transform 420ms ${EASE_SETTLE}` }}><path d="M10 4v12M4 10h12" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" /></svg>    </button>  </div>;}
components/use-radial-menu.ts
'use client';import * as React from 'react';/** Focus follows the same clockwise order as the geometry. Resize measurements never run in a gesture. */export function useRadialMenu(count: number, enabled: number[], open: boolean, onOpenChange: (open: boolean) => void) {  const root = React.useRef<HTMLDivElement>(null);  const trigger = React.useRef<HTMLButtonElement>(null);  const buttons = React.useRef(new Map<number, HTMLButtonElement>());  const focusFrame = React.useRef(0);  const [radius, setRadius] = React.useState(86);  const latest = React.useRef({ enabled, onOpenChange }); latest.current = { enabled, onOpenChange };  React.useEffect(() => {    const element = root.current;    if (!element) return;    const observer = new ResizeObserver(([entry]) => {      const next = Math.max(40, Math.min(90, entry.contentRect.width / 2 - 48));      setRadius((previous) => previous === next ? previous : next);    });    observer.observe(element); return () => observer.disconnect();  }, []);  React.useEffect(() => {    if (open) focusFrame.current = requestAnimationFrame(() => buttons.current.get(latest.current.enabled[0])?.focus({ preventScroll: true }));    return () => { cancelAnimationFrame(focusFrame.current); focusFrame.current = 0; };  }, [open]);  const close = (focus = true) => { latest.current.onOpenChange(false); if (focus) trigger.current?.focus({ preventScroll: true }); };  const navigate = (event: React.KeyboardEvent, index: number) => {    if (event.key === 'Escape') { event.preventDefault(); close(); return; }    const list = latest.current.enabled;    if (!['ArrowRight', 'ArrowDown', 'ArrowLeft', 'ArrowUp', 'Home', 'End'].includes(event.key)) return;    event.preventDefault();    const at = list.indexOf(index);    const next = event.key === 'Home' ? list[0] : event.key === 'End' ? list.at(-1) : list[(at + (event.key === 'ArrowRight' || event.key === 'ArrowDown' ? 1 : -1) + list.length) % list.length];    if (next !== undefined) buttons.current.get(next)?.focus({ preventScroll: true });  };  return { root, trigger, buttons, radius, close, navigate,    point(index: number) { const angle = -Math.PI / 2 + index * Math.PI * 2 / Math.max(1, count); return { x: Math.cos(angle) * radius, y: Math.sin(angle) * radius, angle: angle * 180 / Math.PI }; },  };}
components/ui-interaction-motion.ts
'use client';import * as React from 'react';export const EASE_OUT = 'cubic-bezier(0.16, 1, 0.3, 1)';export const EASE_SETTLE = 'cubic-bezier(0.22, 1.18, 0.36, 1)';export const INTERACTION_FOCUS = 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-offset-2 focus-visible:ring-offset-background';const query = '(prefers-reduced-motion: reduce)';const subscribe = (notify: () => void) => {  const media = window.matchMedia(query);  media.addEventListener('change', notify);  return () => media.removeEventListener('change', notify);};export const useInteractionReducedMotion = () => React.useSyncExternalStore(subscribe, () => window.matchMedia(query).matches, () => false);export function useInteractionActivity<T extends Element>() {  const ref = React.useRef<T>(null);  const [visible, setVisible] = React.useState(false);  const [hidden, setHidden] = React.useState(false);  React.useEffect(() => {    const el = ref.current;    if (!el) return;    const observer = new IntersectionObserver(([entry]) => setVisible(entry.isIntersecting));    observer.observe(el);    const visibility = () => setHidden(document.hidden);    visibility(); document.addEventListener('visibilitychange', visibility);    return () => { observer.disconnect(); document.removeEventListener('visibilitychange', visibility); };  }, []);  return { ref, active: visible && !hidden };}export function interactionIcon(on: boolean, reduced: boolean): React.CSSProperties {  return { opacity: on ? 1 : 0, transform: on ? 'none' : 'scale(0.6)', filter: on ? 'blur(0px)' : 'blur(3px)', transition: reduced ? 'none' : `opacity 220ms ${EASE_OUT}, transform 380ms ${EASE_OUT}, filter 220ms ${EASE_OUT}` };}/** Sample recent movement rather than averaging a long drag and losing its release velocity. */export type PointerSample = { x: number; time: number };export function samplePointer(samples: PointerSample[], x: number, time: number) {  samples.push({ x, time });  while (samples.length > 2 && time - samples[0].time > 100) samples.shift();}export function releaseVelocity(samples: PointerSample[], now: number) {  if (samples.length < 2 || now - samples[samples.length - 1].time > 100) return 0;  const first = samples[0], last = samples[samples.length - 1];  return (last.x - first.x) / Math.max(1, last.time - first.time);}/** A damped spring sampled by WAAPI: no React renders or perpetual frame loop. */export function springFrames(from: number, to: number, velocity = 0, duration = 520) {  const omega = 20, damping = 0.82, damped = omega * Math.sqrt(1 - damping * damping);  const displacement = from - to;  const coefficient = (velocity * 1000 + damping * omega * displacement) / damped;  return Array.from({ length: 33 }, (_, i) => {    const t = (i / 32) * duration / 1000;    const value = to + Math.exp(-damping * omega * t) * (displacement * Math.cos(damped * t) + coefficient * Math.sin(damped * t));    return { value: i === 32 ? to : value, offset: i / 32 };  });}

Registry item: /r/radial-menu.json