UIOverlays and Menusmotion/react

ContextMenu

A right-click menu that scales in at the pointer inside a dashed target area, clamped and flipped to stay inside the viewport. It supports shortcut hints, separators, group labels, checkbox items with an animated check and a nested submenu, with full keyboard navigation.

Preview

Customize

9 props
380
200
224

Installation

Copies the component source and its CSS Module into your project.

npx motionfoundry add context-menu

Writes components/ui/context-menu.tsx and components/styles/context-menu.module.css.

Usage

Updates live as you change the controls — only props that differ from the defaults are included.

example.tsx
1import { ContextMenu } from "@motionfoundry/ui";2 3export function Example() {4  return (5    <ContextMenu />6  );7}

Props

PropTypeDefault
items

Rows: { label, type?: "item" | "checkbox" | "separator" | "label", shortcut?, disabled?, checked?, items? (submenu) }.

ContextMenuItem[]browser-style page menu
onSelect

Called with the label of the activated item.

(label: string) => voidundefined
areaLabel

Text inside the dashed target area.

string"Right click here"
hint

Secondary hint text inside the area.

string"or focus and press Shift+F10"
width

Target area width in pixels (max 100%).

number380
height

Target area height in pixels.

number200
menuWidth

Menu panel width in pixels.

number224
accentColor

Accent for checks, the hover border and focus highlights.

string"#6366f1"
showShortcuts

Show keyboard shortcut hints.

booleantrue
showSubmenu

Include items that open a submenu.

booleantrue
disabled

Disable the custom menu (the native menu shows instead).

booleanfalse
className

Class merged onto the root.

string""
style

Inline styles merged onto the root.

React.CSSPropertiesundefined

Accessibility

Implements the WAI-ARIA menu pattern: role="menu" panels with menuitem / menuitemcheckbox (aria-checked) rows, role="separator", aria-disabled, and submenu triggers with aria-haspopup="menu" and aria-expanded. The target area is focusable and opens the menu with Shift+F10, the ContextMenu key, Enter or Space (focus moves to the first item). Arrow Up/Down move focus (wrapping), Home/End jump, typing a letter jumps to a matching item, Enter/Space activate, Arrow Right opens and Arrow Left closes a submenu, Escape closes the current level and Tab closes everything. Focus returns to the target area on close; outside click, scroll, resize and window blur dismiss. The last action is announced through an aria-live region. Under prefers-reduced-motion the menu only fades.

Toggle prefers-reduced-motion in your OS or browser devtools (Rendering → Emulate CSS media feature) to verify the reduced-motion state.

Source

components/ui/context-menu.tsx
1"use client";2 3import * as React from "react";4import { createPortal } from "react-dom";5import { AnimatePresence, motion, useReducedMotion } from "motion/react";6import styles from "../styles/context-menu.module.css";7 8export interface ContextMenuItem {9  /** Visible label (also the value passed to onSelect) */10  label?: string;11  /** Row kind */12  type?: "item" | "checkbox" | "separator" | "label";13  /** Keyboard shortcut hint shown on the right */14  shortcut?: string;15  /** Disable the row */16  disabled?: boolean;17  /** Initial checked state for checkbox rows */18  checked?: boolean;19  /** Nested items, turning this row into a submenu trigger */20  items?: ContextMenuItem[];21}22 23export interface ContextMenuProps {24  /** Menu items; defaults to a browser-style page menu */25  items?: ContextMenuItem[];26  /** Called with the label of the activated item */27  onSelect?: (label: string) => void;28  /** Text inside the dashed target area */29  areaLabel?: string;30  /** Secondary hint text inside the area */31  hint?: string;32  /** Target area width in pixels */33  width?: number;34  /** Target area height in pixels */35  height?: number;36  /** Menu panel width in pixels */37  menuWidth?: number;38  /** Accent color for checks, highlights and the focus ring */39  accentColor?: string;40  /** Show keyboard shortcut hints */41  showShortcuts?: boolean;42  /** Include items that open a submenu */43  showSubmenu?: boolean;44  /** Disable the context menu (native menu is shown instead) */45  disabled?: boolean;46  /** Optional className merged onto the root */47  className?: string;48  /** Optional inline styles merged onto the root */49  style?: React.CSSProperties;50}51 52const DEFAULT_ITEMS: ContextMenuItem[] = [53  { label: "Back", shortcut: "⌘[" },54  { label: "Forward", shortcut: "⌘]", disabled: true },55  { label: "Reload", shortcut: "⌘R" },56  {57    label: "More tools",58    items: [59      { label: "Save page as…", shortcut: "⇧⌘S" },60      { label: "Create shortcut…" },61      { label: "Name window…" },62      { type: "separator" },63      { label: "Developer tools", shortcut: "⌥⌘I" },64    ],65  },66  { type: "separator" },67  { type: "label", label: "View" },68  { type: "checkbox", label: "Show bookmarks bar", shortcut: "⇧⌘B", checked: true },69  { type: "checkbox", label: "Show full URLs" },70];71const THEME_TOKENS = ["--mf-bg", "--mf-fg", "--mf-surface", "--mf-surface-raised", "--mf-muted", "--mf-subtle", "--mf-border", "--mf-border-strong", "--mf-accent"];72const useIsoLayoutEffect = typeof window !== "undefined" ? React.useLayoutEffect : React.useEffect;73const clamp = (v: number, min: number, max: number) => Math.max(min, Math.min(v, max));74const isAction = (it: ContextMenuItem) => (it.type ?? "item") === "item" || it.type === "checkbox";75 76function readThemeVars(el: Element | null): Record<string, string> {77  const out: Record<string, string> = {};78  if (!el) return out;79  const cs = getComputedStyle(el);80  for (const t of THEME_TOKENS) {81    const v = cs.getPropertyValue(t).trim();82    if (v) out[t] = v;83  }84  return out;85}86 87interface Anchor {88  x: number;89  y: number;90  w: number;91  h: number;92}93 94interface PanelProps {95  items: ContextMenuItem[];96  anchor: Anchor;97  isSub: boolean;98  focusFirst: boolean;99  labelledBy?: string;100  showShortcuts: boolean;101  checked: Record<string, boolean>;102  layerStyle: React.CSSProperties;103  onActivate: (item: ContextMenuItem) => void;104  onCloseAll: () => void;105  onCloseSelf: () => void;106}107 108function MenuPanel({ items, anchor, isSub, focusFirst, labelledBy, showShortcuts, checked, layerStyle, onActivate, onCloseAll, onCloseSelf }: PanelProps) {109  const reduce = useReducedMotion();110  const id = React.useId();111  const ref = React.useRef<HTMLDivElement>(null);112  const [pos, setPos] = React.useState<{ left: number; top: number; origin: string } | null>(null);113  const [active, setActive] = React.useState(-1);114  const [sub, setSub] = React.useState<{ index: number; anchor: Anchor; focus: boolean } | null>(null);115  const enabled = items.map((it, i) => (isAction(it) && !it.disabled ? i : -1)).filter((i) => i >= 0);116 117  useIsoLayoutEffect(() => {118    const el = ref.current;119    if (!el) return;120    const w = el.offsetWidth;121    const h = el.offsetHeight;122    const vw = window.innerWidth;123    const vh = window.innerHeight;124    const pad = 8;125    const fitsRight = anchor.x + anchor.w + w <= vw - pad;126    const left = clamp(fitsRight ? anchor.x + anchor.w - (isSub ? 2 : 0) : anchor.x - w + (isSub ? 2 : 0), pad, vw - w - pad);127    const startY = isSub ? anchor.y - 5 : anchor.y;128    const fitsDown = startY + h <= vh - pad;129    const top = clamp(fitsDown ? startY : (isSub ? anchor.y + anchor.h + 5 : anchor.y) - h, pad, vh - h - pad);130    setPos({ left, top, origin: `${fitsRight ? "left" : "right"} ${fitsDown ? "top" : "bottom"}` });131    if (focusFirst && enabled.length) setActive(enabled[0]);132    else el.focus();133  }, []);134 135  React.useEffect(() => {136    if (active >= 0) ref.current?.querySelector<HTMLElement>(`[data-index="${active}"]`)?.focus();137  }, [active]);138 139  const openSub = (i: number, focus: boolean) => {140    const node = ref.current?.querySelector<HTMLElement>(`[data-index="${i}"]`);141    if (!node) return;142    const r = node.getBoundingClientRect();143    setSub({ index: i, anchor: { x: r.left, y: r.top, w: r.width, h: r.height }, focus });144  };145 146  const activate = (i: number, viaKey: boolean) => {147    const it = items[i];148    if (!it || !isAction(it) || it.disabled) return;149    if (it.items?.length) openSub(i, viaKey);150    else onActivate(it);151  };152 153  const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {154    if (!ref.current?.contains(e.target as Node) || !enabled.length) return;155    const k = e.key;156    const at = enabled.indexOf(active);157    const move = (n: number) => {158      e.preventDefault();159      setActive(enabled[(n + enabled.length) % enabled.length]);160    };161    if (k === "ArrowDown") move(at < 0 ? 0 : at + 1);162    else if (k === "ArrowUp") move(at < 0 ? enabled.length - 1 : at - 1);163    else if (k === "Home") move(0);164    else if (k === "End") move(enabled.length - 1);165    else if (k === "Enter" || k === " ") {166      e.preventDefault();167      activate(active, true);168    } else if (k === "ArrowRight" && items[active]?.items?.length) {169      e.preventDefault();170      openSub(active, true);171    } else if (k === "ArrowLeft" && isSub) {172      e.preventDefault();173      onCloseSelf();174    } else if (k === "Escape") {175      e.preventDefault();176      e.stopPropagation();177      if (isSub) onCloseSelf();178      else onCloseAll();179    } else if (k === "Tab") {180      e.preventDefault();181      onCloseAll();182    } else if (k.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {183      const q = k.toLowerCase();184      const order = enabled.map((_, n) => enabled[(at + 1 + n) % enabled.length]);185      const hit = order.find((i) => items[i].label?.toLowerCase().startsWith(q));186      if (hit !== undefined) setActive(hit);187    }188  };189 190  return (191    <motion.div192      ref={ref}193      role="menu"194      aria-label={labelledBy ? undefined : "Context menu"}195      aria-labelledby={labelledBy}196      aria-orientation="vertical"197      tabIndex={-1}198      data-mf-cm-panel=""199      className={styles.menu}200      style={{ ...layerStyle, left: pos?.left ?? anchor.x, top: pos?.top ?? anchor.y, visibility: pos ? "visible" : "hidden", transformOrigin: pos?.origin ?? "left top" }}201      initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.95 }}202      animate={reduce ? { opacity: 1 } : { opacity: 1, scale: 1 }}203      exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.97, transition: { duration: 0.1 } }}204      transition={{ duration: reduce ? 0.08 : 0.15, ease: [0.16, 1, 0.3, 1] }}205      onKeyDown={onKeyDown}206      onContextMenu={(e) => e.preventDefault()}207      onPointerLeave={() => {208        if (!sub) {209          setActive(-1);210          ref.current?.focus();211        }212      }}213    >214      {items.map((it, i) => {215        const kind = it.type ?? "item";216        if (kind === "separator") return <div key={`sep-${i}`} role="separator" className={styles.separator} />;217        if (kind === "label") return <div key={`lbl-${i}`} role="presentation" className={styles.groupLabel}>{it.label}</div>;218        const hasSub = !!it.items?.length;219        const isChecked = kind === "checkbox" && !!checked[it.label ?? ""];220        return (221          <div222            key={`${it.label}-${i}`}223            id={`${id}-${i}`}224            role={kind === "checkbox" ? "menuitemcheckbox" : "menuitem"}225            aria-checked={kind === "checkbox" ? isChecked : undefined}226            aria-disabled={it.disabled || undefined}227            aria-haspopup={hasSub ? "menu" : undefined}228            aria-expanded={hasSub ? sub?.index === i : undefined}229            tabIndex={-1}230            data-index={i}231            data-active={active === i || sub?.index === i ? "" : undefined}232            className={styles.item}233            onPointerMove={() => {234              if (it.disabled) return;235              if (active !== i) setActive(i);236              if (hasSub && sub?.index !== i) openSub(i, false);237              else if (!hasSub && sub) setSub(null);238            }}239            onClick={() => activate(i, false)}240          >241            <span aria-hidden="true" className={styles.indicator}>242              {isChecked && (243                <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.6" strokeLinecap="round" strokeLinejoin="round">244                  <motion.path d="M5 12.5l4.5 4.5L19 7.5" initial={reduce ? false : { pathLength: 0 }} animate={{ pathLength: 1 }} transition={{ duration: 0.22 }} />245                </svg>246              )}247            </span>248            <span className={styles.label}>{it.label}</span>249            {showShortcuts && it.shortcut && !hasSub && <span className={styles.shortcut}>{it.shortcut}</span>}250            {hasSub && (251              <svg aria-hidden="true" className={styles.chevron} width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">252                <path d="m9 6 6 6-6 6" />253              </svg>254            )}255          </div>256        );257      })}258      {createPortal(259        <AnimatePresence>260          {sub && items[sub.index]?.items && (261            <MenuPanel262              key={sub.index}263              items={items[sub.index].items ?? []}264              anchor={sub.anchor}265              isSub266              focusFirst={sub.focus}267              labelledBy={`${id}-${sub.index}`}268              showShortcuts={showShortcuts}269              checked={checked}270              layerStyle={layerStyle}271              onActivate={onActivate}272              onCloseAll={onCloseAll}273              onCloseSelf={() => {274                const i = sub.index;275                setSub(null);276                setActive(i);277                ref.current?.querySelector<HTMLElement>(`[data-index="${i}"]`)?.focus();278              }}279            />280          )}281        </AnimatePresence>,282        document.body,283      )}284    </motion.div>285  );286}287 288export function ContextMenu({289  items = DEFAULT_ITEMS,290  onSelect,291  areaLabel = "Right click here",292  hint = "or focus and press Shift+F10",293  width = 380,294  height = 200,295  menuWidth = 224,296  accentColor = "#6366f1",297  showShortcuts = true,298  showSubmenu = true,299  disabled = false,300  className = "",301  style,302}: ContextMenuProps) {303  const areaRef = React.useRef<HTMLDivElement>(null);304  const keyOpenedAt = React.useRef(0);305  const [mounted, setMounted] = React.useState(false);306  const [menu, setMenu] = React.useState<{ x: number; y: number; keyboard: boolean; n: number } | null>(null);307  const [themeVars, setThemeVars] = React.useState<Record<string, string>>({});308  const [last, setLast] = React.useState("");309  const list = React.useMemo(() => (showSubmenu ? items : items.filter((it) => !it.items?.length)), [items, showSubmenu]);310  const [checked, setChecked] = React.useState<Record<string, boolean>>(() => {311    const out: Record<string, boolean> = {};312    const walk = (arr: ContextMenuItem[]) => arr.forEach((it) => (it.type === "checkbox" && it.label ? (out[it.label] = !!it.checked) : it.items && walk(it.items)));313    walk(items);314    return out;315  });316 317  React.useEffect(() => setMounted(true), []);318 319  const close = React.useCallback(() => {320    setMenu(null);321    const ae = document.activeElement;322    if (!ae || ae === document.body || ae.closest("[data-mf-cm-panel]")) areaRef.current?.focus({ preventScroll: true });323  }, []);324 325  const openAt = (x: number, y: number, keyboard: boolean) => {326    setThemeVars(readThemeVars(areaRef.current));327    setMenu((m) => ({ x, y, keyboard, n: (m?.n ?? 0) + 1 }));328  };329 330  React.useEffect(() => {331    if (!menu) return;332    const onDown = (e: PointerEvent) => {333      const t = e.target as Element | null;334      if (!t?.closest?.("[data-mf-cm-panel]") && !(e.button === 2 && areaRef.current?.contains(t))) close();335    };336    const dismiss = () => close();337    document.addEventListener("pointerdown", onDown, true);338    window.addEventListener("resize", dismiss);339    window.addEventListener("blur", dismiss);340    window.addEventListener("scroll", dismiss, true);341    return () => {342      document.removeEventListener("pointerdown", onDown, true);343      window.removeEventListener("resize", dismiss);344      window.removeEventListener("blur", dismiss);345      window.removeEventListener("scroll", dismiss, true);346    };347  }, [menu, close]);348 349  const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {350    if (disabled || e.target !== e.currentTarget) return;351    if (e.key === "ContextMenu" || (e.shiftKey && e.key === "F10") || e.key === "Enter" || e.key === " ") {352      e.preventDefault();353      keyOpenedAt.current = performance.now();354      const r = e.currentTarget.getBoundingClientRect();355      openAt(r.left + 24, r.top + 24, true);356    }357  };358 359  const layerStyle = { ...themeVars, "--mf-cm-accent": accentColor, "--mf-cm-w": `${menuWidth}px` } as React.CSSProperties;360 361  return (362    <div className={`${styles.root} ${className}`.trim()} style={{ "--mf-cm-accent": accentColor, ...style } as React.CSSProperties}>363      <div364        ref={areaRef}365        role="button"366        tabIndex={disabled ? -1 : 0}367        aria-haspopup="menu"368        aria-expanded={!!menu}369        aria-disabled={disabled || undefined}370        aria-label={`${areaLabel}. ${hint}`}371        className={styles.area}372        data-open={menu ? "" : undefined}373        style={{ width, height }}374        onKeyDown={onKeyDown}375        onContextMenu={(e) => {376          if (disabled) return;377          e.preventDefault();378          if (performance.now() - keyOpenedAt.current < 400) return;379          openAt(e.clientX, e.clientY, false);380        }}381      >382        <span className={styles.areaLabel}>{areaLabel}</span>383        <span className={styles.hint}>{hint}</span>384        <span className={styles.last} aria-live="polite">385          {last ? `Last action: ${last}` : ""}386        </span>387      </div>388      {mounted &&389        createPortal(390          <AnimatePresence>391            {menu && (392              <MenuPanel393                key={menu.n}394                items={list}395                anchor={{ x: menu.x, y: menu.y, w: 0, h: 0 }}396                isSub={false}397                focusFirst={menu.keyboard}398                showShortcuts={showShortcuts}399                checked={checked}400                layerStyle={layerStyle}401                onActivate={(it) => {402                  const label = it.label ?? "";403                  if (it.type === "checkbox") setChecked((c) => ({ ...c, [label]: !c[label] }));404                  setLast(it.type === "checkbox" ? `${label} ${checked[label] ? "off" : "on"}` : label);405                  onSelect?.(label);406                  close();407                }}408                onCloseAll={close}409                onCloseSelf={close}410              />411            )}412          </AnimatePresence>,413          document.body,414        )}415    </div>416  );417}