|$ curl https://forge-ai.dev/api/markdown?path=docs/components/context-menu
$cat docs/context-menu.md
updated Recently·22 min read·published

Context Menu

HTMLCSSTailwindBootstrapUIIntermediate🎯Free Tools
Introduction

Context menus appear on right-click or long-press, surfacing actions that relate to the element under the cursor. A production-ready menu must handle viewport-aware positioning, keyboard triggers (Shift+F10, Menu key), nested submenus, focus trapping, and screen-reader semantics.

This guide shows the same menu implemented in plain HTML/CSS, Tailwind CSS, and Bootstrap, so you can drop the version that matches your stack.

info

Always call e.preventDefault() in the contextmenu handler. Otherwise the browser menu will appear on top of your custom menu.
Basic Context Menu

The simplest useful menu: a trigger area, a hidden menu panel, and a small script that opens it at the cursor position and closes it on click or Escape.

basic
Live
untitled.html
HTML
1<div class="demo-area" data-contextmenu="basic">
2 <span class="trigger-hint">
3 Right-click inside this area
4 </span>
5 <div class="context-menu" role="menu" aria-label="Basic actions">
6 <button class="menu-item" role="menuitem">
7 Cut
8 </button>
9 <button class="menu-item" role="menuitem">
10 Copy
11 </button>
12 <button class="menu-item" role="menuitem">
13 Paste
14 </button>
15 <div class="menu-divider" role="separator">
16 </div>
17 <button class="menu-item" role="menuitem">
18 Refresh
19 </button>
20 </div>
21</div>
preview
Icons & Keyboard Shortcuts

Inline SVG icons make scan speed faster, and shortcut hints teach power users the equivalent keystrokes. Keep icons monochromatic so they inherit hover color changes.

icons
Live
untitled.html
HTML
1<div class="demo-area" data-contextmenu="icons">
2 <span class="trigger-hint">
3 Right-click for actions
4 </span>
5 <div class="context-menu" role="menu" aria-label="Editor actions">
6 <button class="menu-item" role="menuitem">
7 <span class="menu-row">
8 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14">
9 <path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/>
10 <rect x="8" y="2" width="8" height="4" rx="1" ry="1"/>
11 </svg>
12 Copy
13 </span>
14 <kbd class="shortcut">
15 Ctrl+C
16 </kbd>
17 </button>
18 <button class="menu-item" role="menuitem">
19 <span class="menu-row">
20 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14">
21 <rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
22 <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
23 </svg>
24 Paste
25 </span>
26 <kbd class="shortcut">
27 Ctrl+V
28 </kbd>
29 </button>
30 <button class="menu-item" role="menuitem" disabled>
31 <span class="menu-row">
32 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14">
33 <path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
34 </svg>
35 Delete
36 </span>
37 </button>
38 </div>
39</div>
preview
Danger Actions

Destructive items should be visually distinct — usually red text on hover, separated by a divider, and often requiring a confirmation step.

danger
Live
untitled.html
HTML
1<div class="demo-area" data-contextmenu="danger">
2 <span class="trigger-hint">
3 Right-click on this file
4 </span>
5 <div class="context-menu" role="menu" aria-label="File actions">
6 <button class="menu-item" role="menuitem">
7 Open
8 </button>
9 <button class="menu-item" role="menuitem">
10 Rename
11 </button>
12 <button class="menu-item" role="menuitem">
13 Duplicate
14 </button>
15 <div class="menu-divider" role="separator">
16 </div>
17 <button class="menu-item danger" role="menuitem">
18 <span class="menu-row">
19 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14">
20 <polyline points="3 6 5 6 21 6"/>
21 <path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
22 </svg>
23 Move to trash
24 </span>
25 </button>
26 </div>
27</div>
preview
Nested Submenu

Submenus save vertical space and group related options. In this minimal demo, hover (or focus) on the parent item reveals the nested panel.

nested
Live
untitled.html
HTML
1<div class="demo-area" data-contextmenu="nested">
2 <span class="trigger-hint">
3 Right-click to see nested options
4 </span>
5 <div class="context-menu" role="menu" aria-label="View options">
6 <button class="menu-item" role="menuitem">
7 Show sidebar
8 </button>
9 <div class="menu-item has-submenu" role="menuitem" aria-haspopup="true" aria-expanded="false" tabindex="0">
10 <span>
11 Theme
12 </span>
13 <svg class="chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="12" height="12">
14 <polyline points="9 18 15 12 9 6"/>
15 </svg>
16 <div class="submenu" role="menu">
17 <button class="menu-item" role="menuitem">
18 Dark
19 </button>
20 <button class="menu-item" role="menuitem">
21 Light
22 </button>
23 <button class="menu-item" role="menuitem">
24 System
25 </button>
26 </div>
27 </div>
28 <button class="menu-item" role="menuitem">
29 Full screen
30 </button>
31 </div>
32</div>
preview

warning

For keyboard users, implement arrow-key navigation between items and Enter/Space to activate. The hover-only submenu above is a visual demo; production menus should manage aria-expanded and focus programmatically.
React Component

A reusable React version that computes viewport-aware position, closes on outside click or Escape, and exposes items through props.

ContextMenu.tsx
TypeScript
1"use client";
2import { useState, useRef, useEffect, useCallback } from "react";
3
4interface MenuItem {
5 label: string;
6 shortcut?: string;
7 danger?: boolean;
8 disabled?: boolean;
9 divider?: boolean;
10 action: () => void;
11}
12
13function ContextMenu({ items, children }: { items: MenuItem[]; children: React.ReactNode }) {
14 const [open, setOpen] = useState(false);
15 const [pos, setPos] = useState({ x: 0, y: 0 });
16 const menuRef = useRef<HTMLDivElement>(null);
17
18 const clamp = useCallback((x: number, y: number) => {
19 const menuWidth = menuRef.current?.offsetWidth ?? 200;
20 const menuHeight = menuRef.current?.offsetHeight ?? items.length * 36;
21 const padding = 8;
22 return {
23 x: Math.max(padding, Math.min(x, window.innerWidth - menuWidth - padding)),
24 y: Math.max(padding, Math.min(y, window.innerHeight - menuHeight - padding)),
25 };
26 }, [items.length]);
27
28 const handleContextMenu = useCallback((e: React.MouseEvent) => {
29 e.preventDefault();
30 setPos(clamp(e.clientX, e.clientY));
31 setOpen(true);
32 }, [clamp]);
33
34 useEffect(() => {
35 if (!open) return;
36 const close = () => setOpen(false);
37 const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") close(); };
38 document.addEventListener("click", close);
39 document.addEventListener("keydown", onKey);
40 return () => {
41 document.removeEventListener("click", close);
42 document.removeEventListener("keydown", onKey);
43 };
44 }, [open]);
45
46 return (
47 <div onContextMenu={handleContextMenu} className="relative">
48 {children}
49 {open && (
50 <div
51 ref={menuRef}
52 role="menu"
53 className="fixed z-50 min-w-[180px] bg-[#151515] border border-[#2A2A3E] rounded-lg shadow-2xl py-1.5"
54 style={{ left: pos.x, top: pos.y }}
55 >
56 {items.map((item, i) =>
57 item.divider ? (
58 <div key={i} className="h-px bg-[#2A2A3E] my-1.5 mx-2" role="separator" />
59 ) : (
60 <button
61 key={i}
62 role="menuitem"
63 disabled={item.disabled}
64 onClick={() => { item.action(); setOpen(false); }}
65 className={`w-full px-4 py-2 text-left text-[13px] flex items-center justify-between disabled:opacity-40 ${
66 item.danger
67 ? "text-[#EF4444] hover:bg-[rgba(239,68,68,0.1)] hover:text-[#F87171]"
68 : "text-[#E0E0E0] hover:bg-[#1A1A2E] hover:text-[#00FF41]"
69 }`}
70 >
71 <span>{item.label}</span>
72 {item.shortcut && <kbd className="font-mono text-[10px] text-[#525252] bg-[#0A0A0A] px-1.5 py-0.5 rounded border border-[#2A2A3E]">{item.shortcut}</kbd>}
73 </button>
74 )
75 )}
76 </div>
77 )}
78 </div>
79 );
80}
Smart Positioning

A menu opened near the viewport edge must flip or clamp so it stays fully visible. Measure the rendered menu after opening, then adjust coordinates.

positioning.ts
TypeScript
1function clampMenuPosition(
2 x: number,
3 y: number,
4 menuWidth: number,
5 menuHeight: number,
6 padding = 8,
7) {
8 return {
9 x: Math.max(padding, Math.min(x, window.innerWidth - menuWidth - padding)),
10 y: Math.max(padding, Math.min(y, window.innerHeight - menuHeight - padding)),
11 };
12}
13
14// Keyboard trigger: Shift+F10 or the Menu key.
15function handleKeyboardTrigger(target: HTMLElement, openAt: (x: number, y: number) => void) {
16 const rect = target.getBoundingClientRect();
17 openAt(rect.left + rect.width / 2, rect.top + rect.height / 2);
18}
19
20// After the menu renders, re-clamp if the first guess overflowed.
21useEffect(() => {
22 if (!open || !menuRef.current) return;
23 const rect = menuRef.current.getBoundingClientRect();
24 setPos(p => clampMenuPosition(p.x, p.y, rect.width, rect.height));
25}, [open]);
Accessibility

Context menus are high-risk for keyboard and screen-reader users because they are not part of the normal tab order. Follow these practical checks to keep them usable.

  • Use role="menu" on the container and role="menuitem" on each action.
  • Give the menu an accessible name with aria-label or aria-labelledby.
  • Trap focus inside the open menu; loop with ArrowUp / ArrowDown.
  • Support the native keyboard trigger: Shift+F10 or the Menu key.
  • Close on Escape, outside click, and after any item is activated.
  • Mark disabled items with disabled or aria-disabled="true" and skip them during arrow navigation.
  • Place a visible focus ring on the active item (color change alone is not enough).
  • For submenus, toggle aria-expanded and move focus into the submenu on open.
$Blueprint — Engineering Documentation·Section ID: UI-CM-01·Revision: 1.1

Community

Get help on Slack, Discord or VIP

Stuck on a guide? Join the community and ask.