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 propsInstallation
Copies the component source and its CSS Module into your project.
npx motionfoundry add context-menuWrites 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.
1import { ContextMenu } from "@motionfoundry/ui";2 3export function Example() {4 return (5 <ContextMenu />6 );7}Props
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
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}