Overlays
Context menu
A menu that opens at the pointer on right click or long press, for actions on the thing under it.
- Built on
Popover APIcontextmenu eventPointer events@starting-style- Client JavaScript
- Yes, for state
- Works without React
- React only (needs JavaScript)
- Source, gzipped
- 4.3 kB
- Dependencies
- React
Code
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator } from "@/components/equip/context-menu";
export default function Demo() {
return (
<ContextMenu
label="File actions"
style={{
display: "grid",
gap: 8,
width: 216,
padding: 12,
boxSizing: "border-box",
border: "1px dashed var(--eq-line-strong)",
borderRadius: "var(--eq-radius-m)",
}}
>
<div
tabIndex={0}
style={{
display: "flex",
alignItems: "center",
gap: 8,
height: 32,
padding: "0 8px",
borderRadius: "var(--eq-radius-s)",
background: "var(--eq-tint)",
fontFamily: "var(--eq-font-mono)",
fontVariationSettings: "var(--eq-font-mono-settings)",
fontSize: 13,
}}
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M4 2.5h5l3 3v8H4z M9 2.5v3h3" />
</svg>
invoice-table.tsx
</div>
<span style={{ fontSize: 12, color: "var(--eq-ink-3)", textAlign: "center" }}>Right click this row</span>
<ContextMenuContent>
<ContextMenuItem>Open</ContextMenuItem>
<ContextMenuItem shortcut="F2">Rename</ContextMenuItem>
<ContextMenuItem shortcut="⌥⌘C">Copy path</ContextMenuItem>
<ContextMenuItem shortcut="⌘D">Duplicate</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem tone="danger" shortcut="⌘⌫">
Delete
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
}Examples
From the keyboard
Focus a row and press Shift+F10 or the Menu key: the menu opens under that row and acts on it.
Code
"use client";
import { useState } from "react";
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator } from "@/components/equip/context-menu";
const deploys = ["web-7f3a2c", "api-19be04", "worker-c82d11"];
export default function Example() {
const [target, setTarget] = useState(deploys[0]);
const [log, setLog] = useState("No action yet");
return (
<ContextMenu label={`Actions for ${target}`} style={{ display: "grid", gap: 4, width: "100%", maxWidth: 280 }}>
{deploys.map((id) => (
<div
key={id}
tabIndex={0}
onFocus={() => setTarget(id)}
onContextMenu={() => setTarget(id)}
style={{
padding: "6px 8px",
borderRadius: "var(--eq-radius-s)",
border: "1px solid var(--eq-line)",
fontFamily: "var(--eq-font-mono)",
fontVariationSettings: "var(--eq-font-mono-settings)",
fontSize: 13,
}}
>
{id}
</div>
))}
<span style={{ fontSize: 12, color: "var(--eq-ink-2)" }}>{log}</span>
<ContextMenuContent>
<ContextMenuItem onSelect={() => setLog(`Opened logs for ${target}`)}>View logs</ContextMenuItem>
<ContextMenuItem onSelect={() => setLog(`Redeploying ${target}`)}>Redeploy</ContextMenuItem>
<ContextMenuItem onSelect={() => setLog(`Copied ${target}`)} shortcut="⌘C">
Copy deploy id
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem tone="danger" onSelect={() => setLog(`Rolled back ${target}`)}>
Roll back
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
}Disabled and destructive items
On touch, press and hold for half a second. Disabled items stay reachable with the arrows but do nothing.
Code
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator } from "@/components/equip/context-menu";
export default function Example() {
return (
<ContextMenu
label="Message actions"
style={{
width: "100%",
maxWidth: 280,
padding: "10px 12px",
boxSizing: "border-box",
borderRadius: "var(--eq-radius-m)",
border: "1px solid var(--eq-line)",
fontSize: 14,
}}
>
<strong style={{ fontSize: 13 }}>Dana Whitfield</strong>
<p style={{ margin: "4px 0 0", color: "var(--eq-ink-2)" }}>
Pushed the migration to staging. Can someone check the invoice totals?
</p>
<ContextMenuContent>
<ContextMenuItem shortcut="R">Reply in thread</ContextMenuItem>
<ContextMenuItem>Copy link</ContextMenuItem>
<ContextMenuItem disabled>Edit message</ContextMenuItem>
<ContextMenuItem disabled>Pin to channel</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem tone="danger">Delete message</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
}Props
| Prop | Type | Default | What it does |
|---|---|---|---|
label | string | none | Names the menu for assistive tech, e.g. "File actions". |
children | ReactNode | none | The target region's content, plus one ContextMenuContent. |
...rest | ComponentProps<"div"> | none | Every native div attribute, applied to the target region. |
ContextMenuContent ...rest | ComponentProps<"div"> | none | Every native div attribute, applied to the menu. |
ContextMenuItem onSelect | () => void | none | Runs when the item is chosen. The menu then closes. |
ContextMenuItem shortcut | string | none | A shortcut hint at the end of the row. Display only; it does not bind the key. |
ContextMenuItem disabled | boolean | none | Sets aria-disabled. The item stays focusable but does nothing. |
ContextMenuItem tone | "danger" | none | Colours the row for a destructive action. |
Anatomy
| root | .eq-context-menu | The target region. It has no look of its own. |
|---|---|---|
| content | .eq-context-menu-content | The popover with role="menu", placed at the pointer. |
| item | .eq-context-menu-item | |
| item label | .eq-context-menu-item-label | Type-ahead matches against this text. |
| shortcut | .eq-context-menu-shortcut | |
| separator | .eq-context-menu-separator |
Keyboard
| Shift+F10ContextMenu | On a focused element in the region, opens the menu under it. |
|---|---|
| ArrowDownArrowUp | Moves to the next or previous item, wrapping. |
| HomeEnd | Moves to the first or last item. |
| A-Z | Moves to the next item whose label starts with that letter. |
| EnterSpace | Chooses the item and closes the menu. |
| EscapeTab | Closes the menu and returns focus to where it was. |
Notes
- Long press opens it on touch after 500ms; moving more than 10px first cancels, and the tap that follows the long press is swallowed.
- The keyboard way in needs something focusable inside the region; give the rows a tabindex or use real buttons and links.
- A context menu is hard to discover, so every action in it should also be reachable another way, such as a row menu or toolbar.
- It closes on choosing an item, Escape, Tab, a press outside, scrolling, resizing and leaving the window.
- It opens down and right of the pointer and flips to the other side near an edge, so it always stays inside the viewport.
- Disabled items stay focusable so they can be discovered, as the menu pattern recommends, but cannot be chosen.
- The exit fade needs transition of overlay, which only Chromium has; elsewhere the menu fades in but closes at once. iOS Safari fires no contextmenu event, so the long press timer opens it there; text selection may still start on a long press over selectable text.
Source
Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.
components/equip/context-menu.tsx366 lines
"use client";
import {
createContext,
useContext,
useEffect,
useLayoutEffect,
useRef,
useState,
type ComponentProps,
type KeyboardEvent,
type MouseEvent,
type PointerEvent,
type RefObject,
} from "react";
import "./context-menu.css";
type Point = { x: number; y: number };
type ContextMenuContext = {
point: Point | null;
label: string;
menuRef: RefObject<HTMLDivElement | null>;
close: (returnFocus: boolean) => void;
};
const Context = createContext<ContextMenuContext>({
point: null,
label: "",
menuRef: { current: null },
close: () => {},
});
const LONG_PRESS = 500;
const SLOP = 10;
const EDGE = 8;
export type ContextMenuProps = ComponentProps<"div"> & {
/** Names the menu for assistive tech, e.g. "File actions". */
label: string;
};
export function ContextMenu({
label,
className,
children,
onContextMenu,
onPointerDown,
onPointerMove,
onPointerUp,
onPointerCancel,
onClickCapture,
onKeyDown,
...rest
}: ContextMenuProps) {
const menuRef = useRef<HTMLDivElement>(null);
const [point, setPoint] = useState<Point | null>(null);
const returnTo = useRef<HTMLElement | null>(null);
const press = useRef<{ timer: number; x: number; y: number } | null>(null);
// After a long press or a key opens the menu, the browser may send its own contextmenu
// event (Android, Shift+F10) and a click; both are swallowed until this time.
const quietUntil = useRef(0);
const swallowClick = useRef(false);
function inMenu(target: EventTarget | null) {
return target instanceof Node && !!menuRef.current?.contains(target);
}
function open(at: Point) {
const active = document.activeElement;
if (active instanceof HTMLElement && active !== document.body && !inMenu(active)) returnTo.current = active;
else if (!inMenu(active)) returnTo.current = null;
setPoint(at);
}
function close(returnFocus: boolean) {
setPoint(null);
swallowClick.current = false;
const target = returnTo.current;
if (returnFocus && target?.isConnected) target.focus({ preventScroll: true });
}
function cancelPress() {
if (press.current) window.clearTimeout(press.current.timer);
press.current = null;
}
useEffect(() => cancelPress, []);
useEffect(() => {
if (!point) return;
function onOutside(event: globalThis.PointerEvent) {
if (!inMenu(event.target)) close(false);
}
function onScroll(event: Event) {
if (!inMenu(event.target)) close(false);
}
function onDismiss() {
close(false);
}
document.addEventListener("pointerdown", onOutside, true);
window.addEventListener("scroll", onScroll, true);
window.addEventListener("resize", onDismiss);
window.addEventListener("blur", onDismiss);
return () => {
document.removeEventListener("pointerdown", onOutside, true);
window.removeEventListener("scroll", onScroll, true);
window.removeEventListener("resize", onDismiss);
window.removeEventListener("blur", onDismiss);
};
}, [point]);
function handleContextMenu(event: MouseEvent<HTMLDivElement>) {
onContextMenu?.(event);
if (event.defaultPrevented && !inMenu(event.target)) return;
event.preventDefault();
if (inMenu(event.target) || event.timeStamp < quietUntil.current) return;
cancelPress();
open({ x: event.clientX, y: event.clientY });
}
function handlePointerDown(event: PointerEvent<HTMLDivElement>) {
onPointerDown?.(event);
swallowClick.current = false;
if (event.pointerType === "mouse" || inMenu(event.target) || !event.isPrimary) return;
cancelPress();
const x = event.clientX;
const y = event.clientY;
const timer = window.setTimeout(() => {
press.current = null;
quietUntil.current = performance.now() + 800;
swallowClick.current = true;
open({ x, y });
}, LONG_PRESS);
press.current = { timer, x, y };
}
function handlePointerMove(event: PointerEvent<HTMLDivElement>) {
onPointerMove?.(event);
const start = press.current;
if (start && Math.hypot(event.clientX - start.x, event.clientY - start.y) > SLOP) cancelPress();
}
function handleClickCapture(event: MouseEvent<HTMLDivElement>) {
onClickCapture?.(event);
if (!swallowClick.current || inMenu(event.target)) return;
swallowClick.current = false;
event.preventDefault();
event.stopPropagation();
}
function handleKeyDown(event: KeyboardEvent<HTMLDivElement>) {
onKeyDown?.(event);
if (event.defaultPrevented || inMenu(event.target)) return;
if (event.key !== "ContextMenu" && !(event.shiftKey && event.key === "F10")) return;
event.preventDefault();
const target = event.target instanceof Element ? event.target : event.currentTarget;
const box = target.getBoundingClientRect();
quietUntil.current = event.timeStamp + 800;
open({ x: box.left + Math.min(box.width / 2, 24), y: box.bottom });
}
return (
<div
{...rest}
className={["eq-context-menu", className].filter(Boolean).join(" ")}
onContextMenu={handleContextMenu}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={(event) => {
onPointerUp?.(event);
cancelPress();
}}
onPointerCancel={(event) => {
onPointerCancel?.(event);
cancelPress();
}}
onClickCapture={handleClickCapture}
onKeyDown={handleKeyDown}
>
<Context.Provider value={{ point, label, menuRef, close }}>{children}</Context.Provider>
</div>
);
}
function items(menu: HTMLElement | null) {
return Array.from(menu?.querySelectorAll<HTMLElement>('[role="menuitem"]') ?? []);
}
export function ContextMenuContent({
className,
children,
onKeyDown,
onClick,
onPointerDown,
onContextMenu,
...rest
}: ComponentProps<"div">) {
const { point, label, menuRef, close } = useContext(Context);
useLayoutEffect(() => {
const menu = menuRef.current;
if (!menu) return;
const showing = menu.matches(":popover-open");
if (!point) {
if (showing) menu.hidePopover();
return;
}
if (!showing) menu.showPopover();
// Opens down and right of the pointer, and flips to the other side of it near an edge.
const width = menu.offsetWidth;
const height = menu.offsetHeight;
const viewWidth = document.documentElement.clientWidth;
const viewHeight = document.documentElement.clientHeight;
const flipX = point.x + width > viewWidth - EDGE;
const flipY = point.y + height > viewHeight - EDGE;
const left = flipX ? point.x - width : point.x;
const top = flipY ? point.y - height : point.y;
menu.style.left = `${Math.max(EDGE, Math.min(left, viewWidth - width - EDGE))}px`;
menu.style.top = `${Math.max(EDGE, Math.min(top, viewHeight - height - EDGE))}px`;
menu.dataset.side = flipY ? "top" : "bottom";
menu.style.transformOrigin = `${flipX ? "right" : "left"} ${flipY ? "bottom" : "top"}`;
items(menu)[0]?.focus({ preventScroll: true });
}, [point, menuRef]);
function handleKeyDown(event: KeyboardEvent<HTMLDivElement>) {
onKeyDown?.(event);
if (event.defaultPrevented) return;
const all = items(menuRef.current);
const current = all.findIndex((item) => item === document.activeElement);
let next: number | undefined;
switch (event.key) {
case "ArrowDown":
next = current < 0 ? 0 : (current + 1) % all.length;
break;
case "ArrowUp":
next = current < 0 ? all.length - 1 : (current - 1 + all.length) % all.length;
break;
case "Home":
next = 0;
break;
case "End":
next = all.length - 1;
break;
case "Escape":
event.preventDefault();
close(true);
return;
case "Tab":
event.preventDefault();
close(true);
return;
case "ContextMenu":
case "F10":
event.preventDefault();
return;
default: {
if (event.key.length !== 1 || event.key === " ") return;
if (event.ctrlKey || event.metaKey || event.altKey) return;
const letter = event.key.toLowerCase();
for (let step = 1; step <= all.length; step++) {
const index = (current + step + all.length) % all.length;
const text = all[index].querySelector(".eq-context-menu-item-label")?.textContent ?? "";
if (text.trim().toLowerCase().startsWith(letter)) {
next = index;
break;
}
}
}
}
if (next === undefined || all.length === 0) return;
event.preventDefault();
all[next]?.focus();
}
// The menu is a React child of the region, so its events would reach the region's handlers.
return (
<div
{...rest}
ref={menuRef}
popover="manual"
role="menu"
aria-label={label}
className={["eq-context-menu-content", className].filter(Boolean).join(" ")}
onKeyDown={(event) => {
handleKeyDown(event);
event.stopPropagation();
}}
onClick={(event) => {
onClick?.(event);
event.stopPropagation();
}}
onPointerDown={(event) => {
onPointerDown?.(event);
event.stopPropagation();
}}
onContextMenu={(event) => {
onContextMenu?.(event);
event.preventDefault();
event.stopPropagation();
}}
>
{children}
</div>
);
}
export type ContextMenuItemProps = Omit<ComponentProps<"button">, "onSelect"> & {
/** Runs when the item is chosen by click, Enter or Space. The menu then closes. */
onSelect?: () => void;
/** A shortcut hint shown at the end of the row, e.g. "⌘⌫". Display only. */
shortcut?: string;
/** danger colours the row for a destructive action. */
tone?: "danger";
};
export function ContextMenuItem({
onSelect,
shortcut,
tone,
disabled,
className,
children,
onClick,
onPointerMove,
...rest
}: ContextMenuItemProps) {
const { close } = useContext(Context);
return (
<button
type="button"
{...rest}
role="menuitem"
tabIndex={-1}
aria-disabled={disabled || undefined}
className={["eq-context-menu-item", className].filter(Boolean).join(" ")}
data-tone={tone}
onPointerMove={(event) => {
onPointerMove?.(event);
if (document.activeElement !== event.currentTarget) event.currentTarget.focus({ preventScroll: true });
}}
onClick={(event) => {
if (disabled) return;
onClick?.(event);
if (event.defaultPrevented) return;
onSelect?.();
close(true);
}}
>
<span className="eq-context-menu-item-label">{children}</span>
{shortcut && <kbd className="eq-context-menu-shortcut">{shortcut}</kbd>}
</button>
);
}
export function ContextMenuSeparator({ className, ...rest }: ComponentProps<"div">) {
return (
<div
role="separator"
{...rest}
className={["eq-context-menu-separator", className].filter(Boolean).join(" ")}
/>
);
}components/equip/context-menu.css152 lines
@layer equip {
/* The target region. It draws nothing; it only stops the touch callout competing with long press. */
.eq-context-menu {
-webkit-touch-callout: none;
}
.eq-context-menu-content {
--_shift: -4px;
box-sizing: border-box;
/* Undo the UA's centring: left and top are set at the pointer when it opens. */
position: fixed;
inset: auto;
margin: 0;
min-width: 200px;
max-width: calc(100vw - 2 * var(--eq-space-2));
max-height: calc(100dvh - 2 * var(--eq-space-2));
overflow: auto;
/* Rows carry their own padding, so the surface keeps only room for the focus ring. */
padding: var(--eq-space-1);
border: 1px solid var(--eq-line-strong);
border-radius: var(--eq-radius-l);
background: var(--eq-raised);
color: var(--eq-ink);
box-shadow: var(--eq-shadow);
-webkit-backdrop-filter: var(--eq-blur);
backdrop-filter: var(--eq-blur);
opacity: 0;
scale: 0.96;
translate: 0 var(--_shift);
transform-origin: top left;
transition:
opacity var(--eq-dur) var(--eq-ease),
scale var(--eq-dur) var(--eq-ease),
translate var(--eq-dur) var(--eq-ease);
}
.eq-context-menu-content[data-side="top"] {
--_shift: 4px;
}
.eq-context-menu-content:popover-open {
display: flex;
flex-direction: column;
opacity: 1;
scale: none;
translate: none;
transition:
opacity var(--eq-dur) var(--eq-ease),
scale var(--eq-dur-move) var(--eq-spring),
translate var(--eq-dur-move) var(--eq-spring);
}
@starting-style {
.eq-context-menu-content:popover-open {
opacity: 0;
scale: 0.96;
translate: 0 var(--_shift);
}
}
.eq-context-menu-item {
display: flex;
align-items: center;
gap: var(--eq-space-3);
flex: none;
width: 100%;
min-height: var(--eq-h-s);
padding: 0 var(--eq-space-2);
border: 0;
border-radius: var(--eq-radius-s);
background: transparent;
color: var(--eq-ink);
font: var(--eq-weight) var(--eq-text-m) / 1.3 var(--eq-font);
text-align: start;
white-space: nowrap;
cursor: pointer;
transition: scale var(--eq-dur) var(--eq-ease);
}
.eq-context-menu-item:active:not([aria-disabled="true"]) {
scale: var(--eq-press);
}
.eq-context-menu-item[data-tone="danger"] {
color: var(--eq-danger);
}
.eq-context-menu-item:focus {
outline: none;
background: var(--eq-tint);
}
.eq-context-menu-item[data-tone="danger"]:focus {
background: color-mix(in srgb, var(--eq-danger) 10%, transparent);
}
.eq-context-menu-item:focus-visible {
outline: 2px solid var(--eq-focus);
outline-offset: -2px;
}
.eq-context-menu-item[aria-disabled="true"] {
opacity: 0.45;
cursor: not-allowed;
}
.eq-context-menu-item[aria-disabled="true"]:focus {
background: transparent;
}
.eq-context-menu-item-label {
flex: 1;
}
.eq-context-menu-shortcut {
margin-inline-start: auto;
color: var(--eq-ink-2);
font-family: var(--eq-font-mono);
font-variation-settings: var(--eq-font-mono-settings);
font-size: var(--eq-text-xs);
}
.eq-context-menu-separator {
flex: none;
height: 1px;
margin: var(--eq-space-1) calc(-1 * var(--eq-space-1));
background: var(--eq-line);
}
/* Keeping the surface in the top layer while it fades out needs overlay, which only some
engines can transition. Elsewhere it closes at once. */
@supports (overlay: auto) {
.eq-context-menu-content {
transition:
opacity var(--eq-dur) var(--eq-ease),
scale var(--eq-dur) var(--eq-ease),
translate var(--eq-dur) var(--eq-ease),
display var(--eq-dur) allow-discrete,
overlay var(--eq-dur) allow-discrete;
}
.eq-context-menu-content:popover-open {
transition:
opacity var(--eq-dur) var(--eq-ease),
scale var(--eq-dur-move) var(--eq-spring),
translate var(--eq-dur-move) var(--eq-spring),
display var(--eq-dur) allow-discrete,
overlay var(--eq-dur) allow-discrete;
}
}
}