A context menu is a Menu opened from a different trigger. Menu.ContextTrigger is a region (any element) whose contextmenu event (right-click, Ctrl+click on macOS, Shift+F10 or the ContextMenu key) or touch long-press opens the Root's Menu.Popup at the pointer. Everything inside the popup is Menu's: items whose action runs with data-pending, checkbox and radio items, submenus, typeahead, Escape and Tab. The library fills the three gaps the platform leaves: touch on iOS (which never fires contextmenu), placing a popover at a point, and the light dismiss that would close a just-opened menu in Firefox and Safari.
| Name | Size | Actions |
|---|---|---|
| Report.pdf | 1.2 MB | |
| Budget.xlsx | 84 KB | |
| Notes.md | 3 KB |
Right-click a row, or use its ⋯ button.
"use client";
import { startTransition, useState } from "react";
import { Menu } from "@funstack/ui/menu";
import { fakeSave } from "./fake-api.js";
interface File {
readonly id: string;
readonly name: string;
readonly size: string;
}
const initialFiles: readonly File[] = [
{ id: "1", name: "Report.pdf", size: "1.2 MB" },
{ id: "2", name: "Budget.xlsx", size: "84 KB" },
{ id: "3", name: "Notes.md", size: "3 KB" },
];
// One Menu.Root per row with two ways in: the "⋯" button opens the menu anchored below it, a
// right-click anywhere on the row (or Shift+F10 on the button, or a long-press on touch) opens
// the same menu at the pointer. The button is also the required alternate path: a context menu
// is never the only way to an action. Archive is a fake 1.2 s server call: the item shows
// "Archiving…" (data-pending) and the menu closes when it settles.
export function ContextMenuFiles() {
const [files, setFiles] = useState(initialFiles);
const [log, setLog] = useState("Right-click a row, or use its ⋯ button.");
const remove = (id: string) =>
startTransition(() => setFiles((f) => f.filter((x) => x.id !== id)));
const rename = (id: string) =>
startTransition(() =>
setFiles((f) => f.map((x) => (x.id === id ? { ...x, name: `Renamed ${x.name}` } : x))),
);
return (
<div className="demo-stack">
<table className="demo-files">
<thead>
<tr>
<th scope="col">Name</th>
<th scope="col">Size</th>
<th scope="col">
<span className="demo-cm-hidden">Actions</span>
</th>
</tr>
</thead>
<tbody>
{files.map((file) => (
<Menu.Root
key={file.id}
openChangeAction={(open, { reason }) => {
if (open) setLog(`Opened the menu of ${file.name} (${reason}).`);
}}
>
<Menu.ContextTrigger render={<tr className="demo-file-row" />}>
<td>{file.name}</td>
<td>{file.size}</td>
<td>
<Menu.Trigger
className="demo-icon-button"
aria-label={`Actions for ${file.name}`}
>
{({ pending }) =>
pending ? <span className="demo-spinner" aria-hidden /> : "⋯"
}
</Menu.Trigger>
{/* In a table the Popup goes inside a cell (a <div> cannot sit next to a <tr>);
its clicks and keys then bubble to the row: check event.target there. */}
<Menu.Popup className="demo-menu" align="end">
<Menu.Item action={() => rename(file.id)}>Rename</Menu.Item>
<Menu.Item
action={async () => {
await fakeSave("the archive", { ms: 1200 });
remove(file.id);
startTransition(() => setLog(`Archived ${file.name}.`));
}}
>
{({ pending }) => (pending ? "Archiving…" : "Archive")}
</Menu.Item>
<Menu.Separator />
<Menu.Item className="demo-menu-danger" action={() => remove(file.id)}>
Delete
</Menu.Item>
</Menu.Popup>
</td>
</Menu.ContextTrigger>
</Menu.Root>
))}
</tbody>
</table>
{files.length === 0 && (
<button type="button" className="demo-button" onClick={() => setFiles(initialFiles)}>
Restore the files
</button>
)}
<p className="demo-readout">{log}</p>
</div>
);
}/* Context menu: the region (Menu.ContextTrigger) carries `data-open` while its menu is open and
`data-pending` while the open is in flight. The popup uses the Menu styles (menu.css); a
context open is `[data-context]` and is placed at the pointer by the library, so it gets no
anchored gap (menu.css only adds one for `[data-side]`). */
.demo-files {
inline-size: 100%;
max-inline-size: 28rem;
border-collapse: collapse;
}
.demo-files th,
.demo-files td {
padding: 6px 10px;
border-block-end: 1px solid var(--color-border);
text-align: start;
}
.demo-files td:last-child {
inline-size: 1%;
white-space: nowrap;
}
/* The row whose menu is open. */
.demo-file-row[data-open] {
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
}
.demo-file-row[data-pending] {
cursor: progress;
}
/* Touch: no text selection or loupe during the long-press. A trade-off (the row's text is not
selectable by touch), so it is the page's choice, not a library default. */
.demo-file-row {
-webkit-user-select: none;
user-select: none;
}
.demo-cm-hidden {
position: absolute;
inline-size: 1px;
block-size: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.demo-canvas {
display: block;
max-inline-size: 100%;
border: 1px solid var(--color-border);
border-radius: 8px;
background: var(--color-bg);
-webkit-user-select: none;
user-select: none;
}
.demo-canvas:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
.demo-canvas[data-open] {
border-color: var(--color-primary);
}(nothing exported yet)
"use client";
import { startTransition, useEffect, useRef, useState } from "react";
import { Menu } from "@funstack/ui/menu";
import { sleep } from "./fake-api.js";
// A focusable canvas as the region: right-click it, long-press it on touch, or focus it (Tab)
// and press Shift+F10 / the ContextMenu key. The checkbox and radio items keep the menu open and
// show data-pending while their (fake) save runs; "Export" is a submenu. The same options sit in
// the toolbar above: a context menu is never the only way to an action. A second right-click
// elsewhere on the canvas moves the open menu there.
export function ContextMenuCanvas() {
const [grid, setGrid] = useState(true);
const [zoom, setZoom] = useState("fit");
const [exported, setExported] = useState("(nothing exported yet)");
const canvas = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const element = canvas.current;
const context = element?.getContext("2d");
if (!element || !context) return;
const { width, height } = element;
context.clearRect(0, 0, width, height);
const step = zoom === "fit" ? 20 : 40;
if (grid) {
context.strokeStyle = "rgb(128 128 128 / 0.35)";
for (let x = 0; x <= width; x += step) {
context.beginPath();
context.moveTo(x, 0);
context.lineTo(x, height);
context.stroke();
}
for (let y = 0; y <= height; y += step) {
context.beginPath();
context.moveTo(0, y);
context.lineTo(width, y);
context.stroke();
}
}
context.fillStyle = "rgb(99 102 241)";
const size = step * 3;
context.fillRect(width / 2 - size / 2, height / 2 - size / 2, size, size);
}, [grid, zoom]);
const saveGrid = async (next: boolean) => {
await sleep(500);
startTransition(() => setGrid(next));
};
const saveZoom = async (next: string) => {
await sleep(500);
startTransition(() => setZoom(next));
};
const exportAs = (format: string) => async () => {
await sleep(800);
startTransition(() => setExported(`Exported diagram.${format}`));
};
return (
<div className="demo-stack">
<div className="demo-row" role="group" aria-label="View">
<label className="demo-row">
<input
type="checkbox"
checked={grid}
onChange={(e) => startTransition(() => setGrid(e.target.checked))}
/>
Grid
</label>
<select
aria-label="Zoom"
value={zoom}
onChange={(e) => startTransition(() => setZoom(e.target.value))}
>
<option value="fit">Fit</option>
<option value="100">100%</option>
</select>
</div>
<Menu.Root>
<Menu.ContextTrigger
render={
<canvas ref={canvas} width={320} height={160} tabIndex={0} aria-label="Diagram" />
}
className="demo-canvas"
/>
<Menu.Popup
className="demo-menu"
aria-label="Diagram options"
transition={{
"fsui-menu-open": "menu-in",
"fsui-menu-close": "menu-out",
default: "none",
}}
>
<Menu.CheckboxItem checked={grid} checkedChangeAction={saveGrid}>
Show grid
</Menu.CheckboxItem>
<Menu.RadioGroup value={zoom} valueChangeAction={saveZoom}>
<Menu.GroupLabel className="demo-menu-label">Zoom</Menu.GroupLabel>
<Menu.RadioItem value="fit">Fit</Menu.RadioItem>
<Menu.RadioItem value="100">100%</Menu.RadioItem>
</Menu.RadioGroup>
<Menu.Separator />
<Menu.Root>
<Menu.SubmenuTrigger>Export</Menu.SubmenuTrigger>
<Menu.Popup className="demo-menu">
<Menu.Item action={exportAs("svg")}>
{({ pending }) => (pending ? "Exporting…" : "SVG")}
</Menu.Item>
<Menu.Item action={exportAs("png")}>
{({ pending }) => (pending ? "Exporting…" : "PNG")}
</Menu.Item>
</Menu.Popup>
</Menu.Root>
</Menu.Popup>
</Menu.Root>
<p className="demo-readout">{exported}</p>
</div>
);
}/* Context menu: the region (Menu.ContextTrigger) carries `data-open` while its menu is open and
`data-pending` while the open is in flight. The popup uses the Menu styles (menu.css); a
context open is `[data-context]` and is placed at the pointer by the library, so it gets no
anchored gap (menu.css only adds one for `[data-side]`). */
.demo-files {
inline-size: 100%;
max-inline-size: 28rem;
border-collapse: collapse;
}
.demo-files th,
.demo-files td {
padding: 6px 10px;
border-block-end: 1px solid var(--color-border);
text-align: start;
}
.demo-files td:last-child {
inline-size: 1%;
white-space: nowrap;
}
/* The row whose menu is open. */
.demo-file-row[data-open] {
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
}
.demo-file-row[data-pending] {
cursor: progress;
}
/* Touch: no text selection or loupe during the long-press. A trade-off (the row's text is not
selectable by touch), so it is the page's choice, not a library default. */
.demo-file-row {
-webkit-user-select: none;
user-select: none;
}
.demo-cm-hidden {
position: absolute;
inline-size: 1px;
block-size: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.demo-canvas {
display: block;
max-inline-size: 100%;
border: 1px solid var(--color-border);
border-radius: 8px;
background: var(--color-bg);
-webkit-user-select: none;
user-select: none;
}
.demo-canvas:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
.demo-canvas[data-open] {
border-color: var(--color-primary);
}import { Menu } from "@funstack/ui/menu";
<Menu.Root> {/* no DOM; as Menu */}
<Menu.ContextTrigger render={<div className="card" />}> {/* the region: any element, no ARIA */}
<h3>{file.name}</h3>
<Menu.Trigger aria-label={`Actions for ${file.name}`}>⋯</Menu.Trigger> {/* optional: the same menu as a button */}
</Menu.ContextTrigger>
<Menu.Popup aria-label="File actions"> {/* a sibling of the region */}
<Menu.Item action={rename}>Rename</Menu.Item>
<Menu.Item action={archive}>Archive</Menu.Item>
<Menu.Root>
<Menu.SubmenuTrigger>Move to</Menu.SubmenuTrigger>
<Menu.Popup>…</Menu.Popup>
</Menu.Root>
</Menu.Popup>
</Menu.Root>ContextMenu namespace: Root, Popup, items and submenus are Menu's.tr, li or canvas through render keeps its own), no aria-haspopup (not allowed on generic elements) and no aria-expanded. ARIA has no way to say "this has a context menu". tabIndex is yours (rule 2).render={<tr />}) put it inside a cell instead: a <div> cannot be a sibling of a <tr>. Right-clicks and long-presses inside a menu (this one, or any other Menu.Popup inside the region) are never the region's. Other events inside the menu do bubble to the row, in the DOM and in React: a row onClick (selection) also sees item clicks, and selectors such as .row:hover * reach the menu. Check event.target in row handlers.Popover.Popup, a Select picker) rendered inside the region is the region's: its menu opens and the other popup closes, as any popover="auto" would. Keep such popups outside the region, or give them onContextMenu={(e) => e.preventDefault()}, which keeps the region quiet there.Menu.ContextTrigger belongs to a top-level Root; inside a Menu.Popup a Root is a submenu (development warning).| Input | Where | What opens |
|---|---|---|
| Right-click; Ctrl+click on macOS | the region | The menu at the pointer, first item focused without a focus ring |
| Shift+F10, the ContextMenu key; Ctrl+Return on macOS; VO+Shift+M | a focused element in the region | The menu inside the focused element (the browser puts the event there), first item focused with the ring |
| Long-press (touch, pen) | the region | After longPressDelay (500 ms) held within 10 px: the menu at the touch point, first item focused quietly |
openChangeAction(true, { reason: "activate" }) with details.event the contextmenu event (the pointerdown of a long-press), in the Root's transition. Items that suspend hold the open with data-pending on the region (and on a Trigger in the same Root); a rejection rolls data-open back.contextmenu. Where the platform fires its own (Android, Windows pen), whichever comes first opens once. The page stays scrollable: a pan cancels the press. The one click a release may synthesise is swallowed.The menu opens with its top-left corner at the point and flips when it does not fit: upward at the bottom edge, leftward at the right edge, both in the corner. CSS anchor positioning anchors to elements, not points, so the Popup renders the point inline (--menu-point-x, --menu-point-y), puts left / top there and names three @position-try options that mirror it. The menu lives in the top layer, so a transformed or filtered ancestor of the region does not move it.
Those options are the library's only shipped CSS: a <style> React hoists into <head> once per page on the first context open (data-href="@funstack/ui/menu-point"). It is functional only: three named at-rules, no selectors, nothing visual.
@position-try --fsui-menu-up { top: auto; bottom: calc(100% - var(--menu-point-y)); }
@position-try --fsui-menu-left { left: auto; right: calc(100% - var(--menu-point-x)); }
@position-try --fsui-menu-up-left { top: auto; bottom: calc(100% - var(--menu-point-y));
left: auto; right: calc(100% - var(--menu-point-x)); }style, which wins over the inline placement (your own position-try-fallbacks, a margin).side={null} on the Popup: no inline placement and no <style> on either path; a context open still sets --menu-point-x / --menu-point-y and data-context for your stylesheet.<style> falls under the same style-src directive as the inline style attributes every anchored popup already uses, so nothing new is required.direction: rtl.<Menu.Popup side={null} className="menu">…</Menu.Popup>.menu[data-context] {
inset: auto;
left: var(--menu-point-x);
top: var(--menu-point-y);
position-try-fallbacks: --mine;
}
@position-try --mine { top: auto; bottom: calc(100% - var(--menu-point-y)); }
.menu:not([data-context]) { position-area: bottom span-right; }| Input | What happens | reason |
|---|---|---|
| Right-click elsewhere in the region while open | The menu moves to the new point: no openChangeAction, items keep their state, a pending item action keeps running, the first item is focused again | – |
| Right-click or middle-click outside the menu | Closes at once, for every menu (a Menu opened from its button too): the browser's own light dismiss ignores those buttons in Chromium, and native menus close on any click | "dismiss" |
| Right-click inside the menu | Nothing: the browser's menu is suppressed, the menu stays where it is | – |
| An item's activation, Escape, Tab, a left click outside | As Menu | "activate" / "dismiss" |
Focus after a close is the platform's: the element focused when the menu opened. A right-click on a region that cannot take focus leaves focus on <body> (the right-button press already moved it there, as with native menus); with a Menu.Trigger in the same Root, focus goes to that button instead.
contextmenu event reaches it not yet defaultPrevented, and opening prevents it, so an outer region stays quiet. A disabled region lets the event through to an outer one. The library never calls stopPropagation(), so your document-level listeners still see the event.preventDefault() keeps both the browser's menu and the region quiet (a canvas that draws its own); stopPropagation() gives the browser's menu (and leaves an open menu of the region open under it).<input>, a <textarea> or editable content opens nothing custom. An editor whose menu has its own clipboard items sets editableTargets="menu".<Menu.ContextTrigger editableTargets="menu" render={<div contentEditable />} />One Root may hold a Menu.Trigger and a Menu.ContextTrigger: the button opens the menu anchored below it (side / align), a right-click opens the same menu at the pointer. It is one menu: while it is open from a right-click the button shows aria-expanded="true" and data-open, and a click on it closes the menu. That is the recommended way to satisfy rule 1, and it gives keyboard users a focusable target inside the region. Use one Root per target (a row, a card); several ContextTriggers in one Root are allowed but all show data-open together.
Menu.ContextTrigger| Prop | Type | Default | Description |
|---|---|---|---|
disabled | boolean | false | The region does nothing: the browser's menu shows, or an outer region opens. data-disabled. |
editableTargets | "native" | "menu" | "native" | What a right-click or long-press on a text-taking <input>, a <textarea> or editable content inside the region opens: the browser's menu, or this one. |
longPressDelay | number | 500 | Milliseconds a touch or pen pointer must stay down (within 10 px) to open. A development warning when negative. |
className | string | ((state: MenuContextTriggerState) => string | undefined) | – | A class name, or a function of the part's state. |
style | CSSProperties | ((state: MenuContextTriggerState) => CSSProperties | undefined) | – | Inline style, or a function of the part's state. |
children | ReactNode | ((state: MenuContextTriggerState) => ReactNode) | – | Content, or a function of the part's state. |
render | ReactElement | ((props, state: MenuContextTriggerState) => ReactNode) | – | Replace the default <div> (element form) or take over rendering (function form). |
MenuContextTriggerState: { open, pending, disabled } (open optimistic, the Root's). Emits data-open, data-pending, data-disabled and inline -webkit-touch-callout: none (your style wins). Owns onContextMenu, onPointerDown, onPointerMove, onPointerUp and onPointerCancel; onKeyDown and onClick pass through. No id, tabIndex or ARIA. Every prop is serialisable, so a Server Component can render the region.
Menu.Popup and Menu.Root| Where | What | Meaning |
|---|---|---|
Menu.Popup | data-context, state.context | The current open came from a ContextTrigger and is placed at a point (committed). data-side / data-align are absent then, and state.side is null. |
Menu.Popup | --menu-point-x, --menu-point-y | The point, inline, in viewport pixels (also with side={null}). |
Menu.Popup | side, align | Apply to opens from a Menu.Trigger; ignored for a context open. side={null} opts out of the inline placement on both paths. |
Menu.Popup | onContextMenu (owned) | Prevents the browser's menu over the custom one. |
Menu.Root | state.context | The current open (optimistic) came from a ContextTrigger. |
.row[data-open] { background: var(--accent-weak); } /* the region of the open menu */
.row[data-pending] { cursor: progress; }
.row { -webkit-user-select: none; user-select: none; } /* touch: optional, see below */
.menu[data-context] { margin: 0; } /* point-placed: no anchored gap */margin: 0; inset: auto on the popup is what a point-placed menu wants too). Enter and exit CSS and transition as Menu: the open and library-driven closes animate with transition; dismissals and the outside right-click with CSS; a move is instant.user-select: none that applies before the touch starts prevents it, and then the text cannot be selected by touch, so it is your choice, not a library default. The region already suppresses the link and image callout inline.contextmenu for touch; the library's long-press opens the menu there. VoiceOver on iOS cannot open it (rule 1). On iPadOS with a trackpad, Safari shows its own menu over the custom one (WebKit bug 260722).contextmenu after the button is released; Linux and macOS on the press. The library captures the pointer on the popup in the latter case, so the release does not light-dismiss the new menu in Firefox and Safari.contextmenu on a target with border-radius (WebKit bug 317496): keep a square focus target, or offer the Menu button.