Overlays

Radial menu

A pie menu of up to eight actions that opens around the pointer; aim and release to run one.

Built on
Popover APIPointer capture@starting-style
Client JavaScript
Yes, for state
Works without React
React only (needs JavaScript)
Source, gzipped
4.8 kB
Dependencies
React
Hold or right click
Code
"use client";

import { useState } from "react";
import { RadialMenu } from "@/components/equip/radial-menu";

const icon = (d: string) => (
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
    <path d={d} />
  </svg>
);

export default function Demo() {
  const [last, setLast] = useState("Hold or right click");
  const items = [
    { id: "copy", label: "Copy", icon: icon("M9 9h10v10H9zM5 15V5h10") },
    { id: "paste", label: "Paste", icon: icon("M9 4h6v3H9zM7 5.5H5V20h14V5.5h-2") },
    { id: "duplicate", label: "Duplicate", icon: icon("M8 8h11v11H8zM5 16V5h11M13.5 11v5M11 13.5h5") },
    { id: "delete", label: "Delete", icon: icon("M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13") },
  ].map((item) => ({ ...item, onSelect: () => setLast(`${item.label} done`) }));

  return (
    <RadialMenu
      label="Canvas actions"
      items={items}
      style={{
        display: "grid",
        placeItems: "center",
        width: 208,
        height: 108,
        border: "1px dashed var(--eq-line-strong)",
        background: "var(--eq-tint)",
        color: "var(--eq-ink-2)",
        fontSize: "var(--eq-text-s)",
      }}
    >
      {last}
    </RadialMenu>
  );
}

Examples

Eight directions

With eight slices each direction is a compass point, so a flick up-left always means the same thing.

Current tool: Select
Code
"use client";

import { useState } from "react";
import { RadialMenu } from "@/components/equip/radial-menu";

const tools = ["Select", "Pen", "Text", "Frame", "Hand", "Comment", "Measure", "Slice"];

export default function Example() {
  const [tool, setTool] = useState("Select");
  return (
    <RadialMenu
      label="Tools"
      items={tools.map((name) => ({ id: name, label: name, onSelect: () => setTool(name) }))}
      style={{
        display: "grid",
        placeItems: "center",
        height: 140,
        border: "1px dashed var(--eq-line-strong)",
        background: "var(--eq-tint)",
        fontSize: "var(--eq-text-s)",
      }}
    >
      <span>
        Current tool: <strong>{tool}</strong>
      </span>
    </RadialMenu>
  );
}

Hold only, with a disabled action

Right click keeps the browser's own menu here, and the disabled slice lights up when aimed at but never runs.

web-prodPress and hold the card
Code
"use client";

import { useState } from "react";
import { RadialMenu } from "@/components/equip/radial-menu";

export default function Example() {
  const [status, setStatus] = useState("Press and hold the card");
  return (
    <RadialMenu
      label="Deployment actions"
      trigger="hold"
      holdDelay={350}
      items={[
        { id: "redeploy", label: "Redeploy", onSelect: () => setStatus("Redeploying web-prod") },
        { id: "logs", label: "Open logs", onSelect: () => setStatus("Opened logs") },
        { id: "rollback", label: "Roll back", disabled: true, onSelect: () => setStatus("Rolled back") },
        { id: "pin", label: "Pin", onSelect: () => setStatus("Pinned web-prod") },
      ]}
      style={{
        display: "grid",
        gap: 4,
        padding: 16,
        width: 240,
        border: "1px solid var(--eq-line-strong)",
        background: "var(--eq-raised)",
      }}
    >
      <strong>web-prod</strong>
      <span style={{ color: "var(--eq-ink-2)", fontSize: "var(--eq-text-s)" }}>{status}</span>
    </RadialMenu>
  );
}

Props

PropTypeDefaultWhat it does
labelstringnoneNames the target region and the menu.
items{ id: string; label: string; icon?: ReactNode; onSelect: () => void; disabled?: boolean }[]noneUp to eight actions, placed clockwise from the top. Extra items are ignored.
trigger"hold" | "contextmenu" | "both""both"Which pointer gestures open the ring. The Menu key and Shift+F10 always do.
holdDelaynumber250How long a press must last before the ring opens, in milliseconds.
childrenReactNodenoneThe target region.
...restComponentProps<"div">noneEvery native div attribute, applied to the target region.

Anatomy

target.eq-radial-menuThe focusable region the ring opens over.
layer.eq-radial-menu-layerA clear popover over the viewport, so any direction counts however far the pointer goes.
ring.eq-radial-menu-ringThe circular floating surface, role menu.
wedge.eq-radial-menu-wedgeThe sector the pointer aims at.
item.eq-radial-menu-itemOne slice, role menuitem.
initial.eq-radial-menu-initialShown in place of an icon when an item has none.
centre.eq-radial-menu-centreNames the slice being aimed at, or the menu when none is.

Keyboard

Shift+F10MenuOpens the ring at the centre of the focused target, with the first action active.
Right arrowDown arrowMoves to the next action clockwise.
Left arrowUp arrowMoves to the next action anticlockwise.
HomeEndMoves to the first or last action.
18Digits 1 to 8 run the action in that position directly.
EnterSpaceRuns the active action.
EscapeTabCloses the ring and returns focus to the target.

Notes

  • Selection is by angle once the pointer passes a small dead zone, so each slice reaches to the edge of the screen.
  • Direction is measured from where the press happened, so a ring nudged inside the viewport never opens with a slice already aimed at.
  • Releasing inside the dead zone leaves the ring open for a click or the keyboard; it never runs an action.
  • The ring is a role menu with aria-activedescendant, and each slice carries its number in aria-keyshortcuts.
  • With hold enabled the target sets touch-action: none, so do not wrap content that needs to scroll by touch.
  • Disabled actions still light up when aimed at, so the layout never shifts, but they never run.
  • Put the actions somewhere else too, such as a toolbar or a menu: a pie menu speeds up experts but should not be the only way in.
  • The fade out on close needs transitionable overlay and display, in Chromium today; elsewhere the ring closes at once.

Source

Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.

components/equip/radial-menu.tsx366 lines
"use client";

import {
  useEffect,
  useId,
  useRef,
  useState,
  type ComponentProps,
  type CSSProperties,
  type KeyboardEvent,
  type MouseEvent,
  type PointerEvent,
  type ReactNode,
} from "react";
import "./radial-menu.css";

export type RadialMenuItem = {
  id: string;
  label: string;
  icon?: ReactNode;
  onSelect: () => void;
  disabled?: boolean;
};

export type RadialMenuProps = Omit<ComponentProps<"div">, "children"> & {
  /** Names the target region and the menu. */
  label: string;
  /** Up to eight actions, placed clockwise from the top. */
  items: RadialMenuItem[];
  /** How the pointer opens it. The Menu key and Shift+F10 always work. */
  trigger?: "hold" | "contextmenu" | "both";
  /** How long a press must last before the ring opens, in milliseconds. */
  holdDelay?: number;
  /** The target region. */
  children: ReactNode;
};

/* Must match the sizes in radial-menu.css. */
const RING = 168;
const ORBIT = 58;
const EDGE = 8;
/* Pointer travel that still counts as "no direction yet". */
const DEAD_ZONE = 18;
/* Movement that turns a pending hold into an ordinary drag. */
const SLOP = 6;

type Opened = {
  /* Ring centre, kept inside the viewport. */
  x: number;
  y: number;
  /* Where the pointer was. Direction is measured from here, so a clamped ring never starts hot. */
  px: number;
  py: number;
};

export function RadialMenu({
  label,
  items,
  trigger = "both",
  holdDelay = 250,
  className,
  children,
  onPointerDown,
  onPointerMove,
  onPointerUp,
  onPointerCancel,
  onPointerLeave,
  onContextMenu,
  onKeyDown,
  ...rest
}: RadialMenuProps) {
  const auto = useId();
  const slices = items.slice(0, 8);
  const count = slices.length;
  const [opened, setOpened] = useState<Opened | null>(null);
  const [active, setActive] = useState(-1);
  const targetRef = useRef<HTMLDivElement>(null);
  const layerRef = useRef<HTMLDivElement>(null);
  const ringRef = useRef<HTMLDivElement>(null);
  const holdTimer = useRef<number | undefined>(undefined);
  const press = useRef<{ id: number; x: number; y: number } | null>(null);
  const layerPress = useRef(false);
  const wedgeTurn = useRef(0);
  /* The ring keeps its place while it fades out after closing. */
  const lastPlace = useRef({ x: 0, y: 0 });

  const holdEnabled = trigger !== "contextmenu";
  const contextEnabled = trigger !== "hold";

  useEffect(() => () => window.clearTimeout(holdTimer.current), []);

  useEffect(() => {
    const layer = layerRef.current;
    if (!layer) return;
    if (opened) {
      if (!layer.matches(":popover-open")) layer.showPopover();
      ringRef.current?.focus({ preventScroll: true });
    } else if (layer.matches(":popover-open")) {
      const hadFocus = layer.contains(document.activeElement);
      layer.hidePopover();
      if (hadFocus) targetRef.current?.focus({ preventScroll: true });
    }
  }, [opened]);

  function open(px: number, py: number) {
    if (count === 0) return;
    const half = RING / 2 + EDGE;
    const width = document.documentElement.clientWidth;
    const height = document.documentElement.clientHeight;
    const x = Math.min(Math.max(px, half), Math.max(half, width - half));
    const y = Math.min(Math.max(py, half), Math.max(half, height - half));
    setOpened({ x, y, px, py });
    setActive(-1);
  }

  function openAtTarget() {
    const box = targetRef.current?.getBoundingClientRect();
    if (!box) return;
    open(box.left + box.width / 2, box.top + box.height / 2);
    setActive(slices.findIndex((item) => !item.disabled));
  }

  function close() {
    setOpened(null);
    setActive(-1);
  }

  function run(index: number) {
    const item = slices[index];
    if (!item || item.disabled) return;
    close();
    item.onSelect();
  }

  /** The slice the pointer points at, or -1 inside the dead zone. */
  function aim(clientX: number, clientY: number) {
    if (!opened) return -1;
    const dx = clientX - opened.px;
    const dy = clientY - opened.py;
    if (Math.hypot(dx, dy) < DEAD_ZONE) return -1;
    const degrees = (Math.atan2(dy, dx) * 180) / Math.PI + 90;
    const step = 360 / count;
    return Math.round((((degrees % 360) + 360) % 360) / step) % count;
  }

  function step(from: number, by: 1 | -1) {
    for (let i = 1; i <= count; i++) {
      const next = (((from + by * i) % count) + count) % count;
      if (!slices[next]?.disabled) return next;
    }
    return from;
  }

  function capture(id: number) {
    try {
      targetRef.current?.setPointerCapture(id);
    } catch {
      /* The pointer is already gone; the ring stays open for a click instead. */
    }
  }

  function handlePointerDown(event: PointerEvent<HTMLDivElement>) {
    onPointerDown?.(event);
    if (opened || event.defaultPrevented) return;
    press.current = { id: event.pointerId, x: event.clientX, y: event.clientY };
    if (holdEnabled && event.button === 0 && count > 0) {
      const { pointerId, clientX, clientY } = event;
      window.clearTimeout(holdTimer.current);
      holdTimer.current = window.setTimeout(() => {
        capture(pointerId);
        open(clientX, clientY);
      }, holdDelay);
    }
  }

  function handlePointerMove(event: PointerEvent<HTMLDivElement>) {
    onPointerMove?.(event);
    const start = press.current;
    if (!start) return;
    if (!opened) {
      if (Math.hypot(event.clientX - start.x, event.clientY - start.y) > SLOP) {
        window.clearTimeout(holdTimer.current);
      }
      return;
    }
    setActive(aim(event.clientX, event.clientY));
  }

  function handlePointerUp(event: PointerEvent<HTMLDivElement>) {
    onPointerUp?.(event);
    window.clearTimeout(holdTimer.current);
    const wasPressing = press.current !== null;
    press.current = null;
    if (!opened || !wasPressing) return;
    const index = aim(event.clientX, event.clientY);
    if (index >= 0) run(index);
    /* Released inside the dead zone: the ring stays open for a click or the keyboard. */
  }

  function handlePointerCancel(event: PointerEvent<HTMLDivElement>) {
    onPointerCancel?.(event);
    window.clearTimeout(holdTimer.current);
    press.current = null;
  }

  function handlePointerLeave(event: PointerEvent<HTMLDivElement>) {
    onPointerLeave?.(event);
    if (!opened) {
      window.clearTimeout(holdTimer.current);
      press.current = null;
    }
  }

  function handleContextMenu(event: MouseEvent<HTMLDivElement>) {
    onContextMenu?.(event);
    if (opened) {
      event.preventDefault();
      return;
    }
    if (!contextEnabled || count === 0 || event.defaultPrevented) return;
    event.preventDefault();
    window.clearTimeout(holdTimer.current);
    const start = press.current;
    if (start) capture(start.id);
    /* A context menu from the keyboard carries no pointer position. */
    if (start || event.clientX !== 0 || event.clientY !== 0) open(event.clientX, event.clientY);
    else openAtTarget();
  }

  function handleTargetKeyDown(event: KeyboardEvent<HTMLDivElement>) {
    onKeyDown?.(event);
    if (event.defaultPrevented || opened) return;
    if (event.key === "ContextMenu" || (event.shiftKey && event.key === "F10")) {
      event.preventDefault();
      event.stopPropagation();
      openAtTarget();
    }
  }

  function handleRingKeyDown(event: KeyboardEvent<HTMLDivElement>) {
    const { key } = event;
    let handled = true;
    if (key === "ArrowRight" || key === "ArrowDown") setActive((a) => step(a < 0 ? -1 : a, 1));
    else if (key === "ArrowLeft" || key === "ArrowUp") setActive((a) => step(a < 0 ? 0 : a, -1));
    else if (key === "Home") setActive(step(-1, 1));
    else if (key === "End") setActive(step(0, -1));
    else if (key === "Enter" || key === " ") run(active);
    else if (key === "Escape" || key === "Tab") close();
    else if (/^[1-8]$/.test(key)) run(Number(key) - 1);
    else handled = false;
    if (handled) {
      event.preventDefault();
      event.stopPropagation();
    }
  }

  function handleLayerPointerDown(event: PointerEvent<HTMLDivElement>) {
    layerPress.current = true;
    setActive(aim(event.clientX, event.clientY));
  }

  function handleLayerPointerUp(event: PointerEvent<HTMLDivElement>) {
    if (!layerPress.current) return;
    layerPress.current = false;
    const index = aim(event.clientX, event.clientY);
    if (index >= 0) run(index);
    else close();
  }

  if (active >= 0 && count > 0) {
    const target = (active * 360) / count;
    const current = wedgeTurn.current;
    const delta = ((((target - current) % 360) + 540) % 360) - 180;
    wedgeTurn.current = current + delta;
  }

  if (opened) lastPlace.current = { x: opened.x, y: opened.y };
  const ringStyle = {
    left: lastPlace.current.x,
    top: lastPlace.current.y,
    "--_turn": `${wedgeTurn.current}deg`,
    "--_slice": `${360 / Math.max(count, 1)}deg`,
  } as CSSProperties;
  const activeItem = active >= 0 ? slices[active] : undefined;

  return (
    <>
      <div
        tabIndex={0}
        role="group"
        aria-label={label}
        aria-keyshortcuts="Shift+F10"
        {...rest}
        ref={targetRef}
        className={["eq-radial-menu", className].filter(Boolean).join(" ")}
        data-trigger={trigger}
        data-open={opened ? "" : undefined}
        onPointerDown={handlePointerDown}
        onPointerMove={handlePointerMove}
        onPointerUp={handlePointerUp}
        onPointerCancel={handlePointerCancel}
        onPointerLeave={handlePointerLeave}
        onLostPointerCapture={() => {
          press.current = null;
        }}
        onContextMenu={handleContextMenu}
        onKeyDown={handleTargetKeyDown}
      >
        {children}
      </div>
      <div
        ref={layerRef}
        popover="manual"
        className="eq-radial-menu-layer"
        onPointerDown={handleLayerPointerDown}
        onMouseDown={(event) => event.preventDefault()}
        onPointerMove={(event) => setActive(aim(event.clientX, event.clientY))}
        onPointerUp={handleLayerPointerUp}
        onContextMenu={(event) => event.preventDefault()}
      >
        <div
          ref={ringRef}
          role="menu"
          aria-label={label}
          tabIndex={-1}
          aria-activedescendant={activeItem ? `${auto}${active}` : undefined}
          className="eq-radial-menu-ring"
          style={ringStyle}
          data-active={activeItem ? "" : undefined}
          onKeyDown={handleRingKeyDown}
          onBlur={(event) => {
            const next = event.relatedTarget;
            if (!(next instanceof Node && event.currentTarget.contains(next))) close();
          }}
        >
          <span className="eq-radial-menu-wedge" aria-hidden="true" />
          {slices.map((item, index) => {
            const angle = ((index * 360) / count - 90) * (Math.PI / 180);
            const place = {
              "--_x": `${(Math.cos(angle) * ORBIT).toFixed(2)}px`,
              "--_y": `${(Math.sin(angle) * ORBIT).toFixed(2)}px`,
            } as CSSProperties;
            return (
              <div
                key={item.id}
                id={`${auto}${index}`}
                role="menuitem"
                aria-label={item.label}
                aria-disabled={item.disabled || undefined}
                aria-keyshortcuts={String(index + 1)}
                className="eq-radial-menu-item"
                data-active={index === active ? "" : undefined}
                style={place}
              >
                {item.icon ?? <span className="eq-radial-menu-initial">{item.label.slice(0, 1)}</span>}
              </div>
            );
          })}
          <span className="eq-radial-menu-centre" aria-hidden="true" data-disabled={activeItem?.disabled ? "" : undefined}>
            {activeItem ? activeItem.label : label}
          </span>
        </div>
      </div>
    </>
  );
}
components/equip/radial-menu.css198 lines
@layer equip {
  .eq-radial-menu {
    position: relative;
    border-radius: var(--eq-radius-m);
    cursor: default;
    user-select: none;
    -webkit-user-select: none;
    -webkit-touch-callout: none;
  }

  /* A hold has to be able to turn into a drag, so touch scrolling cannot start here. */
  .eq-radial-menu:not([data-trigger="contextmenu"]) {
    touch-action: none;
  }

  .eq-radial-menu:focus-visible {
    outline: 2px solid var(--eq-focus);
    outline-offset: 2px;
  }

  .eq-radial-menu-layer {
    /* A clear sheet over the viewport: every direction counts, however far the pointer goes. */
    position: fixed;
    inset: 0;
    width: auto;
    height: auto;
    max-width: none;
    max-height: none;
    margin: 0;
    padding: 0;
    border: 0;
    background: transparent;
    overflow: hidden;
    cursor: default;
  }

  .eq-radial-menu-layer:popover-open {
    display: block;
  }

  .eq-radial-menu-ring {
    /* Sizes match RING and ORBIT in radial-menu.tsx. */
    --_item: 36px;

    box-sizing: border-box;
    position: absolute;
    width: 168px;
    height: 168px;
    margin: -84px 0 0 -84px;
    border: 1px solid var(--eq-line-strong);
    border-radius: var(--eq-radius-full);
    background: var(--eq-raised);
    color: var(--eq-ink);
    box-shadow: var(--eq-shadow);
    -webkit-backdrop-filter: var(--eq-blur);
    backdrop-filter: var(--eq-blur);
    outline: none;
    opacity: 0;
    scale: 0.96;
    transition:
      opacity var(--eq-dur) var(--eq-ease),
      scale var(--eq-dur) var(--eq-ease);
  }

  .eq-radial-menu-layer:popover-open .eq-radial-menu-ring {
    opacity: 1;
    scale: none;
    transition:
      opacity var(--eq-dur) var(--eq-ease),
      scale var(--eq-dur-move) var(--eq-spring);
  }

  @starting-style {
    .eq-radial-menu-layer:popover-open .eq-radial-menu-ring {
      opacity: 0;
      scale: 0.96;
    }
  }

  .eq-radial-menu-ring:focus-visible {
    outline: 2px solid var(--eq-focus);
    outline-offset: 2px;
  }

  /* The pointed-at sector, so the whole direction reads as the target, not only the button. */
  .eq-radial-menu-wedge {
    position: absolute;
    inset: 0;
    border-radius: inherit;
    background: conic-gradient(
      from calc(var(--_slice) / -2),
      var(--eq-tint-strong) 0 var(--_slice),
      transparent 0
    );
    -webkit-mask: radial-gradient(circle, transparent 27px, var(--eq-ink) 28px);
    mask: radial-gradient(circle, transparent 27px, var(--eq-ink) 28px);
    rotate: var(--_turn);
    opacity: 0;
    transition:
      opacity var(--eq-dur) var(--eq-ease),
      rotate var(--eq-dur-move) var(--eq-spring);
  }

  .eq-radial-menu-ring[data-active] .eq-radial-menu-wedge {
    opacity: 1;
  }

  .eq-radial-menu-item {
    box-sizing: border-box;
    position: absolute;
    top: 50%;
    left: 50%;
    display: grid;
    place-items: center;
    width: var(--_item);
    height: var(--_item);
    margin: calc(var(--_item) / -2) 0 0 calc(var(--_item) / -2);
    border-radius: var(--eq-radius-full);
    color: var(--eq-ink);
    translate: var(--_x) var(--_y);
    transition:
      background-color var(--eq-dur) var(--eq-ease),
      color var(--eq-dur) var(--eq-ease),
      scale var(--eq-dur) var(--eq-ease),
      translate var(--eq-dur-move) var(--eq-spring),
      opacity var(--eq-dur) var(--eq-ease);
  }

  @starting-style {
    .eq-radial-menu-layer:popover-open .eq-radial-menu-item {
      translate: 0 0;
      opacity: 0;
    }
  }

  .eq-radial-menu-item[data-active] {
    background: var(--eq-solid);
    color: var(--eq-solid-ink);
  }

  .eq-radial-menu-item:active:not([aria-disabled="true"]) {
    scale: var(--eq-press);
  }

  .eq-radial-menu-item[aria-disabled="true"] {
    opacity: 0.45;
    cursor: not-allowed;
  }

  .eq-radial-menu-item[aria-disabled="true"][data-active] {
    background: var(--eq-tint-strong);
    color: var(--eq-ink);
  }

  .eq-radial-menu-item > svg {
    width: 18px;
    height: 18px;
  }

  .eq-radial-menu-initial {
    font: var(--eq-weight-strong) var(--eq-text-s) / 1 var(--eq-font);
  }

  .eq-radial-menu-centre {
    position: absolute;
    top: 50%;
    left: 50%;
    width: 72px;
    translate: -50% -50%;
    overflow: hidden;
    color: var(--eq-ink-3);
    font: var(--eq-weight) var(--eq-text-xs) / 1.2 var(--eq-font);
    text-align: center;
    text-overflow: ellipsis;
    white-space: nowrap;
    transition: color var(--eq-dur) var(--eq-ease);
  }

  .eq-radial-menu-ring[data-active] .eq-radial-menu-centre {
    color: var(--eq-ink);
    font-weight: var(--eq-weight-strong);
    font-size: var(--eq-text-s);
  }

  .eq-radial-menu-ring[data-active] .eq-radial-menu-centre[data-disabled] {
    color: var(--eq-ink-3);
  }

  /* Keeping the sheet in the top layer while the ring fades out needs overlay, which only some
     engines can transition. Elsewhere it closes at once. */
  @supports (overlay: auto) {
    .eq-radial-menu-layer {
      transition:
        display var(--eq-dur) allow-discrete,
        overlay var(--eq-dur) allow-discrete;
    }
  }
}

EqUIp is in beta: free for non-commercial use, no warranty, and things can change. Terms

Nothing picked yet. Press A on a part, or use the plus on the board, and the install command builds here.

Loading the index.

Loading the index