Back to components

Arc Menu

A bottom-anchored shortcut menu that fans actions along a configurable arc while its trigger shrinks and swaps from a shortcut grid to a close icon.

overlayarc-motion
"use client";

import * as React from "react";
import { motion, useReducedMotion } from "motion/react";

import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";

type ButtonProps = React.ComponentProps<typeof Button>;
type ButtonClickEvent = Parameters<NonNullable<ButtonProps["onClick"]>>[0];
type ButtonKeyDownEvent = Parameters<
  NonNullable<ButtonProps["onKeyDown"]>
>[0];

const EASE_OUT = [0.23, 1, 0.32, 1] as const;
const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;
const ITEM_DURATION_SECONDS = 0.22;
const TRIGGER_DURATION_SECONDS = 0.17;
const ICON_SWAP_DELAY_SECONDS = TRIGGER_DURATION_SECONDS / 2;
const REDUCED_DURATION_SECONDS = 0.12;

export type ArcMenuClassNames = {
  root?: string;
  menu?: string;
  actionSlot?: string;
  actionButton?: string;
  actionIcon?: string;
  trigger?: string;
  triggerSurface?: string;
  triggerFace?: string;
  triggerIcon?: string;
  caption?: string;
  closeIcon?: string;
};

export type ArcMenuTriggerProps = Omit<
  ButtonProps,
  | "aria-controls"
  | "aria-expanded"
  | "aria-haspopup"
  | "aria-label"
  | "children"
  | "ref"
  | "role"
  | "size"
  | "style"
  | "type"
  | "variant"
>;

type ArcMenuContextValue = {
  actionSize: number;
  actionIconSize: number;
  classNames?: ArcMenuClassNames;
  close: (skipMotion?: boolean) => void;
  closeOnAction: boolean;
};

const ArcMenuContext = React.createContext<ArcMenuContextValue | undefined>(
  undefined,
);

function useArcMenuContext() {
  const context = React.useContext(ArcMenuContext);

  if (!context) {
    throw new Error("ArcMenuAction must be used inside ArcMenu.");
  }

  return context;
}

function ShortcutGridIcon() {
  return (
    <svg aria-hidden="true" viewBox="0 0 24 24" fill="none">
      <g transform="translate(2 2)">
        <path
          fill="currentColor"
          fillRule="evenodd"
          clipRule="evenodd"
          d="M6 11a3 3 0 0 1 3 3v3a3 3 0 0 1-3 3H3a3 3 0 0 1-3-3v-3a3 3 0 0 1 3-3zm-3 2a1 1 0 0 0-1 1v3a1 1 0 0 0 1 1h3a1 1 0 0 0 1-1v-3a1 1 0 0 0-1-1zm14-2a3 3 0 0 1 3 3v3a3 3 0 0 1-3 3h-3a3 3 0 0 1-3-3v-3a3 3 0 0 1 3-3zm-3 2a1 1 0 0 0-1 1v3a1 1 0 0 0 1 1h3a1 1 0 0 0 1-1v-3a1 1 0 0 0-1-1zM6.154.004A3 3 0 0 1 9 3v3a3 3 0 0 1-3 3H3A3 3 0 0 1 .004 6.154L0 6V3a3 3 0 0 1 3-3h3zM3 2a1 1 0 0 0-1 1v3l.005.103A1 1 0 0 0 3 7h3a1 1 0 0 0 1-1V3a1 1 0 0 0-.897-.995L6 2zM17.154.004A3 3 0 0 1 20 3v3a3 3 0 0 1-3 3h-3a3 3 0 0 1-3-3V3a3 3 0 0 1 3-3h3zM14 2a1 1 0 0 0-1 1v3a1 1 0 0 0 1 1h3a1 1 0 0 0 1-1V3a1 1 0 0 0-.898-.995L17 2z"
        />
      </g>
    </svg>
  );
}

function CloseIcon() {
  return (
    <svg
      aria-hidden="true"
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth="2"
      strokeLinecap="round"
    >
      <path d="m5.5 5.5 13 13m0-13-13 13" />
    </svg>
  );
}

function resolvePositive(value: number, fallback: number) {
  return Number.isFinite(value) && value > 0 ? value : fallback;
}

function resolveAngle(value: number, fallback: number) {
  return Number.isFinite(value) ? value : fallback;
}

const FOCUSABLE_SELECTOR = [
  "a[href]",
  "button:not(:disabled)",
  "input:not(:disabled)",
  "select:not(:disabled)",
  "textarea:not(:disabled)",
  '[tabindex]:not([tabindex="-1"])',
].join(",");

function focusAdjacentToTrigger(
  trigger: HTMLButtonElement | null,
  direction: -1 | 1,
) {
  if (!trigger) return;

  const focusableElements = Array.from(
    document.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR),
  ).filter(
    (element) =>
      element.tabIndex >= 0 &&
      !element.hasAttribute("hidden") &&
      !element.closest("[inert]") &&
      !element.closest('[aria-hidden="true"]'),
  );
  const triggerIndex = focusableElements.indexOf(trigger);

  if (triggerIndex === -1) {
    trigger.focus();
    return;
  }

  const target = focusableElements[triggerIndex + direction];

  (target ?? trigger).focus();
}

function getArcPosition({
  count,
  endAngle,
  index,
  radius,
  startAngle,
}: {
  count: number;
  endAngle: number;
  index: number;
  radius: number;
  startAngle: number;
}) {
  const progress = count <= 1 ? 0.5 : index / (count - 1);
  const angle = startAngle + (endAngle - startAngle) * progress;
  const radians = (angle * Math.PI) / 180;

  return {
    x: Math.cos(radians) * radius,
    y: Math.sin(radians) * radius,
  };
}

export type ArcMenuProps = Omit<React.ComponentProps<"div">, "onChange"> & {
  /** ArcMenuAction elements distributed along the arc. */
  children: React.ReactNode;
  /** Controlled open state. */
  open?: boolean;
  /** Initial open state for uncontrolled usage. */
  defaultOpen?: boolean;
  /** Called whenever the menu requests an open-state change. */
  onOpenChange?: (open: boolean) => void;
  /** Radius from the trigger center to each action center, in pixels. */
  radius?: number;
  /** First action angle in degrees. -90 points straight up. */
  startAngle?: number;
  /** Last action angle in degrees. -90 points straight up. */
  endAngle?: number;
  /** Circular trigger size in pixels. */
  triggerSize?: number;
  /** Visual trigger size when its caption is hidden. The hit area stays unchanged. */
  iconOnlyTriggerSize?: number;
  /** Visual trigger size while the menu is open. The hit area stays unchanged. */
  openTriggerSize?: number;
  /** Circular action size in pixels. */
  actionSize?: number;
  /** Trigger icon size in pixels. */
  triggerIconSize?: number;
  /** Close icon size in pixels. */
  closeIconSize?: number;
  /** Action icon size in pixels. */
  actionIconSize?: number;
  /** Icon shown while the menu is closed. */
  triggerIcon?: React.ReactNode;
  /** Short caption shown under the trigger icon. */
  triggerCaption?: React.ReactNode;
  /** Whether to render the trigger caption. Its text still labels the button. */
  showTriggerCaption?: boolean;
  /** Icon shown while the menu is open. */
  closeIcon?: React.ReactNode;
  /** Accessible name for the closed trigger. */
  triggerLabel?: string;
  /** Accessible name for the open trigger. */
  closeLabel?: string;
  /** Accessible name for the menu. */
  menuLabel?: string;
  /** Close the menu after an action runs. @default true */
  closeOnAction?: boolean;
  /** Props forwarded to the trigger button. */
  triggerProps?: ArcMenuTriggerProps;
  /** Classes for styling individual component slots. */
  classNames?: ArcMenuClassNames;
};

export type ArcMenuActionProps = Omit<
  ButtonProps,
  "aria-label" | "children" | "role" | "size" | "style" | "type" | "variant"
> & {
  /** Visible tooltip text and accessible name. */
  label: string;
  /** Icon rendered inside the circular action button. */
  icon: React.ReactNode;
  /** Override the root menu's close-on-action behavior. */
  closeOnAction?: boolean;
  /** Classes applied to this action's icon wrapper. */
  iconClassName?: string;
  /** Inline styles that do not replace action geometry. */
  style?: React.CSSProperties;
};

export function ArcMenu({
  children,
  open: openProp,
  defaultOpen = false,
  onOpenChange,
  radius = 72,
  startAngle = -150,
  endAngle = -30,
  triggerSize = 56,
  iconOnlyTriggerSize = 48,
  openTriggerSize = 40,
  actionSize = 40,
  triggerIconSize = 20,
  closeIconSize = 28,
  actionIconSize = 16,
  triggerIcon,
  triggerCaption = "Quick",
  showTriggerCaption = true,
  closeIcon,
  triggerLabel,
  closeLabel = "Close and retract shortcuts",
  menuLabel = "Shortcuts",
  closeOnAction = true,
  triggerProps,
  classNames,
  className,
  style,
  onKeyDown,
  ref,
  ...props
}: ArcMenuProps) {
  const [internalOpen, setInternalOpen] = React.useState(defaultOpen);
  const [skipMotion, setSkipMotion] = React.useState(false);
  const rootRef = React.useRef<HTMLDivElement | null>(null);
  const triggerRef = React.useRef<HTMLButtonElement | null>(null);
  const pendingFocusRef = React.useRef<"first" | "last" | null>(null);
  const menuId = React.useId();
  const shouldReduceMotion = useReducedMotion() ?? false;
  const controlled = openProp !== undefined;
  const open = controlled ? openProp : internalOpen;
  const menuItems = React.Children.toArray(children);
  const resolvedRadius = resolvePositive(radius, 72);
  const resolvedTriggerSize = resolvePositive(triggerSize, 56);
  const resolvedIconOnlyTriggerSize = resolvePositive(
    iconOnlyTriggerSize,
    48,
  );
  const resolvedOpenTriggerSize = resolvePositive(openTriggerSize, 40);
  const resolvedActionSize = resolvePositive(actionSize, 40);
  const resolvedTriggerIconSize = resolvePositive(triggerIconSize, 20);
  const resolvedCloseIconSize = resolvePositive(closeIconSize, 28);
  const resolvedActionIconSize = resolvePositive(actionIconSize, 16);
  const hasVisibleTriggerCaption =
    showTriggerCaption && Boolean(triggerCaption);
  const closedTriggerScale = hasVisibleTriggerCaption
    ? 1
    : Math.min(resolvedIconOnlyTriggerSize, resolvedTriggerSize) /
      resolvedTriggerSize;
  const openTriggerScale = resolvedOpenTriggerSize / resolvedTriggerSize;
  const captionLabel =
    typeof triggerCaption === "string" ? triggerCaption.trim() : "";
  const resolvedTriggerLabel =
    triggerLabel?.trim() || captionLabel || "Open shortcuts";
  const resolvedCloseLabel =
    closeLabel.trim() || "Close and retract shortcuts";
  const resolvedMenuLabel = menuLabel.trim() || "Shortcuts";
  const resolvedStartAngle = resolveAngle(startAngle, -150);
  const resolvedEndAngle = resolveAngle(endAngle, -30);
  const {
    className: triggerClassName,
    disabled: triggerDisabled,
    onClick: onTriggerClick,
    onKeyDown: onTriggerKeyDown,
    ...restTriggerProps
  } = triggerProps ?? {};

  const setOpen = React.useCallback(
    (nextOpen: boolean, shouldSkipMotion = false) => {
      setSkipMotion(shouldSkipMotion);

      if (!controlled) {
        setInternalOpen(nextOpen);
      }

      onOpenChange?.(nextOpen);
    },
    [controlled, onOpenChange],
  );

  React.useEffect(() => {
    if (!skipMotion) return;

    const frame = window.requestAnimationFrame(() => setSkipMotion(false));

    return () => window.cancelAnimationFrame(frame);
  }, [open, skipMotion]);

  const setRootRef = React.useCallback(
    (node: HTMLDivElement | null) => {
      rootRef.current = node;

      if (typeof ref === "function") {
        ref(node);
      } else if (ref) {
        ref.current = node;
      }
    },
    [ref],
  );

  const getEnabledItems = React.useCallback(
    () =>
      Array.from(
        rootRef.current?.querySelectorAll<HTMLButtonElement>(
          "[role='menuitem']:not(:disabled)",
        ) ?? [],
      ),
    [],
  );

  const close = React.useCallback(
    (shouldSkipMotion = false) => {
      setOpen(false, shouldSkipMotion);
      triggerRef.current?.focus();
    },
    [setOpen],
  );

  React.useEffect(() => {
    if (!open) return;

    const handlePointerDown = (event: PointerEvent) => {
      const root = rootRef.current;

      if (!root || !(event.target instanceof Node)) return;
      if (root.contains(event.target)) return;

      setOpen(false);
    };

    document.addEventListener("pointerdown", handlePointerDown);

    return () => document.removeEventListener("pointerdown", handlePointerDown);
  }, [open, setOpen]);

  React.useEffect(() => {
    if (!open || !pendingFocusRef.current) return;

    const items = getEnabledItems();
    const target =
      pendingFocusRef.current === "first" ? items[0] : items.at(-1);

    pendingFocusRef.current = null;
    target?.focus();
  }, [getEnabledItems, open]);

  const handleTriggerClick = (event: ButtonClickEvent) => {
    onTriggerClick?.(event);

    if (event.defaultPrevented || triggerDisabled) return;

    if (!open) {
      pendingFocusRef.current = "first";
    }

    setOpen(!open, event.detail === 0);
  };

  const handleTriggerKeyDown = (event: ButtonKeyDownEvent) => {
    onTriggerKeyDown?.(event);

    if (event.defaultPrevented || triggerDisabled) return;
    if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return;

    event.preventDefault();
    pendingFocusRef.current = event.key === "ArrowDown" ? "first" : "last";

    if (!open) {
      setOpen(true, true);
      return;
    }

    const items = getEnabledItems();
    const target = event.key === "ArrowDown" ? items[0] : items.at(-1);

    pendingFocusRef.current = null;
    target?.focus();
  };

  const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
    onKeyDown?.(event);

    if (event.defaultPrevented || !open) return;

    if (event.key === "Escape") {
      event.preventDefault();
      close(true);
      return;
    }

    if (event.key === "Tab") {
      event.preventDefault();
      setOpen(false, true);
      focusAdjacentToTrigger(triggerRef.current, event.shiftKey ? -1 : 1);
      return;
    }

    const items = getEnabledItems();
    const activeIndex = items.indexOf(
      document.activeElement as HTMLButtonElement,
    );

    if (activeIndex === -1 || items.length === 0) return;

    let nextIndex: number | undefined;

    if (event.key === "ArrowRight" || event.key === "ArrowDown") {
      nextIndex = (activeIndex + 1) % items.length;
    } else if (event.key === "ArrowLeft" || event.key === "ArrowUp") {
      nextIndex = (activeIndex - 1 + items.length) % items.length;
    } else if (event.key === "Home") {
      nextIndex = 0;
    } else if (event.key === "End") {
      nextIndex = items.length - 1;
    } else if (
      event.key.length === 1 &&
      !event.altKey &&
      !event.ctrlKey &&
      !event.metaKey
    ) {
      const query = event.key.toLocaleLowerCase();

      for (let offset = 1; offset <= items.length; offset += 1) {
        const candidateIndex = (activeIndex + offset) % items.length;
        const candidateLabel =
          items[candidateIndex]
            ?.getAttribute("aria-label")
            ?.toLocaleLowerCase() ?? "";

        if (candidateLabel.startsWith(query)) {
          nextIndex = candidateIndex;
          break;
        }
      }
    }

    if (nextIndex !== undefined) {
      event.preventDefault();
      items[nextIndex]?.focus();
    }
  };

  const contextValue = React.useMemo<ArcMenuContextValue>(
    () => ({
      actionSize: resolvedActionSize,
      actionIconSize: resolvedActionIconSize,
      classNames,
      close,
      closeOnAction,
    }),
    [
      classNames,
      close,
      closeOnAction,
      resolvedActionIconSize,
      resolvedActionSize,
    ],
  );

  return (
    <ArcMenuContext.Provider value={contextValue}>
      <div
        {...props}
        ref={setRootRef}
        data-slot="arc-menu"
        data-state={open ? "open" : "closed"}
        className={cn(
          "relative isolate inline-grid place-items-center",
          classNames?.root,
          className,
        )}
        style={{
          width: resolvedTriggerSize,
          height: resolvedTriggerSize,
          ...style,
        }}
        onKeyDown={handleKeyDown}
      >
        <ul
          id={menuId}
          role="menu"
          aria-label={resolvedMenuLabel}
          aria-hidden={!open}
          inert={!open || undefined}
          data-slot="arc-menu-list"
          className={cn("absolute inset-0 m-0 list-none p-0", classNames?.menu)}
        >
          {menuItems.map((child, index) => {
            const position = getArcPosition({
              count: menuItems.length,
              endAngle: resolvedEndAngle,
              index,
              radius: resolvedRadius,
              startAngle: resolvedStartAngle,
            });
            const openTransform = `translate3d(${position.x}px, ${position.y}px, 0) scale(1)`;
            const closedTransform = shouldReduceMotion
              ? openTransform
              : "translate3d(0px, 0px, 0) scale(0.94)";

            return (
              <motion.li
                key={React.isValidElement(child) ? child.key ?? index : index}
                role="none"
                data-slot="arc-menu-action-slot"
                className={cn(
                  "absolute top-1/2 left-1/2 grid place-items-center",
                  classNames?.actionSlot,
                )}
                style={{
                  width: resolvedActionSize,
                  height: resolvedActionSize,
                  marginLeft: -resolvedActionSize / 2,
                  marginTop: -resolvedActionSize / 2,
                  pointerEvents: open ? "auto" : "none",
                  willChange: open ? "transform, opacity" : "auto",
                }}
                initial={false}
                animate={{
                  opacity: open ? 1 : 0,
                  transform: open ? openTransform : closedTransform,
                }}
                transition={{
                  duration: skipMotion
                    ? 0
                    : shouldReduceMotion
                      ? REDUCED_DURATION_SECONDS
                      : ITEM_DURATION_SECONDS,
                  ease: EASE_OUT,
                }}
              >
                {child}
              </motion.li>
            );
          })}
        </ul>

        <Button
          {...restTriggerProps}
          ref={triggerRef}
          type="button"
          variant="link"
          size="icon-lg"
          disabled={triggerDisabled}
          aria-label={open ? resolvedCloseLabel : resolvedTriggerLabel}
          aria-expanded={open}
          aria-haspopup="menu"
          aria-controls={menuId}
          data-state={open ? "open" : "closed"}
          data-slot="arc-menu-trigger"
          onClick={handleTriggerClick}
          onKeyDown={handleTriggerKeyDown}
          className={cn(
            "relative origin-center touch-manipulation rounded-full p-0 no-underline",
            classNames?.trigger,
            triggerClassName,
          )}
          style={{ width: resolvedTriggerSize, height: resolvedTriggerSize }}
        >
          <motion.span
            aria-hidden="true"
            data-slot="arc-menu-trigger-surface"
            className={cn(
              "pointer-events-none absolute inset-0 rounded-full bg-primary shadow-sm transition-colors duration-150 group-hover/button:bg-primary/90 group-active/button:bg-primary/90 motion-reduce:transition-none",
              classNames?.triggerSurface,
            )}
            initial={false}
            animate={{
              transform: `scale(${open ? openTriggerScale : closedTriggerScale})`,
            }}
            transition={{
              duration:
                shouldReduceMotion || skipMotion
                  ? 0
                  : TRIGGER_DURATION_SECONDS,
              ease: EASE_IN_OUT,
            }}
          />
        </Button>

        <div
          aria-hidden="true"
          className={cn(
            "pointer-events-none absolute inset-0 grid place-items-center text-primary-foreground",
            triggerDisabled && "opacity-50",
          )}
        >
          <motion.span
            data-slot="arc-menu-trigger-face"
            className={cn(
              "col-start-1 row-start-1 flex flex-col items-center",
              classNames?.triggerFace,
            )}
            style={{ gap: 2 }}
            initial={false}
            animate={{
              opacity: open ? 0 : 1,
              filter:
                shouldReduceMotion || !open ? "blur(0px)" : "blur(2px)",
            }}
            transition={{
              duration: skipMotion
                ? 0
                : shouldReduceMotion
                  ? REDUCED_DURATION_SECONDS
                  : TRIGGER_DURATION_SECONDS,
              ease: EASE_OUT,
              delay:
                shouldReduceMotion || skipMotion || open
                  ? 0
                  : ICON_SWAP_DELAY_SECONDS,
            }}
          >
            <span
              data-icon
              data-slot="arc-menu-trigger-icon"
              className={cn(
                "grid place-items-center [&_svg]:size-full",
                classNames?.triggerIcon,
              )}
              style={{
                width: resolvedTriggerIconSize,
                height: resolvedTriggerIconSize,
              }}
            >
              {triggerIcon ?? <ShortcutGridIcon />}
            </span>
            {showTriggerCaption && triggerCaption ? (
              <span
                data-slot="arc-menu-caption"
                className={cn(
                  "text-xs font-medium leading-none",
                  classNames?.caption,
                )}
              >
                {triggerCaption}
              </span>
            ) : null}
          </motion.span>

          <motion.span
            data-icon
            data-slot="arc-menu-close-icon"
            className={cn(
              "col-start-1 row-start-1 grid place-items-center [&_svg]:size-full",
              classNames?.closeIcon,
            )}
            style={{
              width: resolvedCloseIconSize,
              height: resolvedCloseIconSize,
            }}
            initial={false}
            animate={{
              opacity: open ? 1 : 0,
              filter:
                shouldReduceMotion || open ? "blur(0px)" : "blur(2px)",
            }}
            transition={{
              duration: skipMotion
                ? 0
                : shouldReduceMotion
                  ? REDUCED_DURATION_SECONDS
                  : TRIGGER_DURATION_SECONDS,
              ease: EASE_OUT,
              delay:
                shouldReduceMotion || skipMotion || !open
                  ? 0
                  : ICON_SWAP_DELAY_SECONDS,
            }}
          >
            {closeIcon ?? <CloseIcon />}
          </motion.span>
        </div>
      </div>
    </ArcMenuContext.Provider>
  );
}

export function ArcMenuAction({
  label,
  icon,
  closeOnAction: closeOnActionProp,
  iconClassName,
  className,
  style,
  onClick,
  disabled,
  ...props
}: ArcMenuActionProps) {
  const context = useArcMenuContext();

  const handleClick = (event: ButtonClickEvent) => {
    onClick?.(event);

    if (event.defaultPrevented || disabled) return;

    if (closeOnActionProp ?? context.closeOnAction) {
      context.close(event.detail === 0);
    }
  };

  return (
    <Button
      {...props}
      type="button"
      role="menuitem"
      variant="outline"
      size="icon-lg"
      disabled={disabled}
      aria-label={label}
      tabIndex={-1}
      title={label}
      data-slot="arc-menu-action"
      onClick={handleClick}
      className={cn(
        "touch-manipulation rounded-full bg-background/90 p-0 shadow-sm backdrop-blur-md transition-[transform,background-color,color,border-color,box-shadow] duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] active:scale-[0.97] motion-reduce:transform-none motion-reduce:transition-colors",
        context.classNames?.actionButton,
        className,
      )}
      style={{
        ...style,
        width: context.actionSize,
        height: context.actionSize,
      }}
    >
      <span
        aria-hidden="true"
        data-icon
        data-slot="arc-menu-action-icon"
        className={cn(
          "grid place-items-center [&_svg]:size-full",
          context.classNames?.actionIcon,
          iconClassName,
        )}
        style={{
          width: context.actionIconSize,
          height: context.actionIconSize,
        }}
      >
        {icon}
      </span>
    </Button>
  );
}

Installation