overlaysclient

context menu

the dropdown menu's panel, opened where you right-click. long-press works on touch, shift f10 on a keyboard.

installation

new to blurrow? this sets up your project in one go: the package, the styles and the font. already done it? skip straight to usage. the installation page has the details.

bash
npx @blurrow/ui init
›rather own the code? copy the source instead

let the cli copy it, along with anything it needs:

bash
npx @blurrow/ui add context-menu

or by hand: drop these files into components/ui and import from there. context menu leans on DropdownMenu.tsx, Kbd.tsx and Floating.tsx, so those come along too.

components/ui/ContextMenu.tsxtsx
"use client";
import React, { useEffect, useId, useRef, useState } from "react";
import type { DropdownMenuItem } from "./DropdownMenu";
import { LAYER, Portal } from "./Floating";
import { Kbd } from "./Kbd";
export type ContextMenuItem = DropdownMenuItem;
interface ContextMenuProps {
/** The area that answers a right-click */
children: React.ReactNode;
items: ContextMenuItem[];
onSelect?: (key: string) => void;
disabled?: boolean;
className?: string;
}
const PAD = 8;
const LONG_PRESS = 500;
/* The same panel as DropdownMenu, opened where you right-click instead of
under a button. A long press opens it on touch, and the context-menu
key or shift+F10 opens it from the keyboard at the focused element.
It grows out of the corner nearest the pointer and flips to stay on
screen. */
export function ContextMenu({ children, items, onSelect, disabled = false, className = "" }: ContextMenuProps) {
const uid = useId();
const areaRef = useRef<HTMLDivElement>(null);
const menuRef = useRef<HTMLDivElement | null>(null);
const [point, setPoint] = useState<{ x: number; y: number } | null>(null);
const [mounted, setMounted] = useState(false);
const [visible, setVisible] = useState(false);
const press = useRef<ReturnType<typeof setTimeout> | null>(null);
const open = point !== null;
if (open && !mounted) setMounted(true);
if (!open && visible) setVisible(false);
const enabledItems = () =>
Array.from(menuRef.current?.querySelectorAll<HTMLElement>('[role="menuitem"]:not([disabled])') ?? []);
useEffect(() => {
if (open) {
const id = requestAnimationFrame(() =>
requestAnimationFrame(() => {
setVisible(true);
menuRef.current?.querySelector<HTMLElement>('[role="menuitem"]:not([disabled])')?.focus();
}),
);
return () => cancelAnimationFrame(id);
}
const t = setTimeout(() => setMounted(false), 160);
return () => clearTimeout(t);
}, [open]);
useEffect(() => {
if (!open) return;
const shut = (e: Event) => {
if (menuRef.current?.contains(e.target as Node)) return;
setPoint(null);
};
const onScroll = () => setPoint(null);
document.addEventListener("pointerdown", shut);
window.addEventListener("scroll", onScroll, true);
window.addEventListener("resize", onScroll);
window.addEventListener("blur", onScroll);
return () => {
document.removeEventListener("pointerdown", shut);
window.removeEventListener("scroll", onScroll, true);
window.removeEventListener("resize", onScroll);
window.removeEventListener("blur", onScroll);
};
}, [open]);
/* Placed as soon as it's attached: beside the pointer, flipped left or
up if it would run off the edge. Only writes to the element, so it
can run on every render without setting state. */
const attach = (el: HTMLDivElement | null) => {
menuRef.current = el;
if (!el || !point) return;
const w = el.offsetWidth;
const h = el.offsetHeight;
const vw = document.documentElement.clientWidth;
const vh = window.innerHeight;
const flipX = point.x + w > vw - PAD;
const flipY = point.y + h > vh - PAD;
el.style.left = `${Math.max(PAD, flipX ? point.x - w : point.x)}px`;
el.style.top = `${Math.max(PAD, flipY ? point.y - h : point.y)}px`;
el.style.transformOrigin = `${flipY ? "bottom" : "top"} ${flipX ? "right" : "left"}`;
};
const openAt = (x: number, y: number) => {
if (disabled) return;
setPoint({ x, y });
};
const close = (refocus = true) => {
setPoint(null);
if (refocus) areaRef.current?.focus({ preventScroll: true });
};
const onMenuKeyDown = (e: React.KeyboardEvent) => {
const list = enabledItems();
const at = list.indexOf(document.activeElement as HTMLElement);
const go = (i: number) => list[(i + list.length) % list.length]?.focus();
switch (e.key) {
case "ArrowDown":
e.preventDefault();
go(at + 1);
break;
case "ArrowUp":
e.preventDefault();
go(at - 1);
break;
case "Home":
e.preventDefault();
go(0);
break;
case "End":
e.preventDefault();
go(list.length - 1);
break;
case "Escape":
e.preventDefault();
e.stopPropagation();
close();
break;
case "Tab":
e.preventDefault();
close();
break;
}
};
const cancelPress = () => {
if (press.current) clearTimeout(press.current);
};
return (
<>
<div
ref={areaRef}
tabIndex={0}
aria-haspopup="menu"
aria-expanded={open}
aria-controls={open ? `${uid}-menu` : undefined}
aria-describedby={`${uid}-how`}
className={["outline-none focus-visible:ring-2 focus-visible:ring-ink focus-visible:ring-offset-2 focus-visible:ring-offset-page", className].join(" ")}
onContextMenu={(e) => {
if (disabled) return;
e.preventDefault();
openAt(e.clientX, e.clientY);
}}
onKeyDown={(e) => {
if (e.key === "ContextMenu" || (e.shiftKey && e.key === "F10")) {
e.preventDefault();
const r = areaRef.current!.getBoundingClientRect();
openAt(r.left + 16, r.top + 16);
}
}}
onPointerDown={(e) => {
if (e.pointerType !== "touch") return;
const { clientX, clientY } = e;
cancelPress();
press.current = setTimeout(() => openAt(clientX, clientY), LONG_PRESS);
}}
onPointerUp={cancelPress}
onPointerMove={cancelPress}
onPointerCancel={cancelPress}
>
{children}
<span id={`${uid}-how`} className="sr-only">
right-click, long-press, or press shift f10 for options
</span>
</div>
{mounted && (
<Portal>
<div
ref={attach}
id={`${uid}-menu`}
role="menu"
onKeyDown={onMenuKeyDown}
onContextMenu={(e) => e.preventDefault()}
className="min-w-[200px] rounded-[16px] p-1.5 transition-[opacity,transform] duration-150 ease-[var(--ease-spring)]"
style={{
position: "fixed",
top: 0,
left: 0,
zIndex: LAYER.floating,
opacity: visible ? 1 : 0,
transform: visible ? "none" : "scale(0.94)",
background: "var(--panel-bezel)",
boxShadow: "var(--overlay-shadow)",
}}
>
<div className="flex flex-col rounded-[11px] bg-panel-face p-1" style={{ boxShadow: "var(--panel-face-shadow)" }}>
{items.map((item) =>
item.divider ? (
<div key={item.key} role="separator" className="mx-2 my-1 h-px bg-line" />
) : (
<button
key={item.key}
type="button"
role="menuitem"
tabIndex={-1}
disabled={item.disabled}
onClick={() => {
onSelect?.(item.key);
close();
}}
onMouseEnter={(e) => e.currentTarget.focus()}
className={[
"group/item flex w-full cursor-pointer items-center gap-2.5 rounded-[8px] px-2.5 py-2 text-[13px] font-medium outline-none",
"transition-[background,color] duration-100 disabled:pointer-events-none disabled:opacity-35",
item.danger ? "text-danger focus:bg-danger-wash" : "text-ink-label focus:bg-surface-active/80 focus:text-ink",
].join(" ")}
>
{item.icon && (
<span
className={[
"flex h-4 w-4 shrink-0 items-center justify-center transition-colors duration-100",
item.danger ? "text-danger-soft" : "text-ink-faint group-focus/item:text-ink-label",
].join(" ")}
>
{item.icon}
</span>
)}
<span className="flex-1 text-left">{item.label}</span>
{item.shortcut && <Kbd className="shrink-0">{item.shortcut}</Kbd>}
</button>
),
)}
</div>
</div>
</Portal>
)}
</>
);
}
components/ui/DropdownMenu.tsxtsx
"use client";
import React, { useState, useRef, useEffect, useId, useCallback, ReactNode } from "react";
import { Kbd } from "./Kbd";
import { ENTER_FROM, LAYER, Portal, useAnchoredPosition } from "./Floating";
type DropdownMenuPosition =
| "bottom-start"
| "bottom-end"
| "top-start"
| "top-end";
export interface DropdownMenuItem {
key: string;
label: string;
icon?: ReactNode;
shortcut?: string;
danger?: boolean;
disabled?: boolean;
divider?: boolean;
}
interface DropdownMenuProps {
trigger: ReactNode;
items: DropdownMenuItem[];
position?: DropdownMenuPosition;
onSelect?: (key: string) => void;
className?: string;
}
export function DropdownMenu({
trigger,
items,
position = "bottom-end",
onSelect,
className = "",
}: DropdownMenuProps) {
const [open, setOpen] = useState(false);
const [visible, setVisible] = useState(false);
const [mounted, setMounted] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const menuRef = useRef<HTMLDivElement | null>(null);
const uid = useId();
const [v, h] = position.split("-") as ["top" | "bottom", "start" | "end"];
// Portalled and pinned to the trigger; flips up or over when it runs out of room
const { setFloating, side } = useAnchoredPosition(ref, { open: mounted, side: v, align: h });
const attachMenu = useCallback(
(el: HTMLDivElement | null) => {
menuRef.current = el;
setFloating(el);
},
[setFloating],
);
const enabledItems = () => Array.from(menuRef.current?.querySelectorAll<HTMLElement>('[role="menuitem"]:not([disabled])') ?? []);
const focusItem = (index: number) => {
const list = enabledItems();
if (list.length === 0) return;
list[(index + list.length) % list.length].focus();
};
/* Mounted and faded out during render; the effect only times the rest */
if (open && !mounted) setMounted(true);
if (!open && visible) setVisible(false);
useEffect(() => {
if (open) {
requestAnimationFrame(() =>
requestAnimationFrame(() => {
setVisible(true);
// Land on the first item so arrow keys work straight away
enabledItems()[0]?.focus();
})
);
} else {
const t = setTimeout(() => setMounted(false), 180);
return () => clearTimeout(t);
}
}, [open]);
useEffect(() => {
function onOutside(e: MouseEvent) {
const t = e.target as Node;
if (ref.current?.contains(t) || menuRef.current?.contains(t)) return;
setOpen(false);
}
document.addEventListener("mousedown", onOutside);
return () => document.removeEventListener("mousedown", onOutside);
}, []);
const returnFocus = () => {
ref.current?.querySelector<HTMLElement>("[data-menu-trigger] button, [data-menu-trigger] a")?.focus();
};
const onMenuKeyDown = (e: React.KeyboardEvent) => {
const list = enabledItems();
const at = list.indexOf(document.activeElement as HTMLElement);
switch (e.key) {
case "ArrowDown":
e.preventDefault();
focusItem(at + 1);
break;
case "ArrowUp":
e.preventDefault();
focusItem(at - 1);
break;
case "Home":
e.preventDefault();
focusItem(0);
break;
case "End":
e.preventDefault();
focusItem(list.length - 1);
break;
case "Escape":
e.preventDefault();
setOpen(false);
returnFocus();
break;
case "Tab":
setOpen(false);
break;
}
};
function handleSelect(item: DropdownMenuItem) {
if (item.disabled) return;
onSelect?.(item.key);
setOpen(false);
returnFocus();
}
return (
<div ref={ref} className={["relative inline-flex", className].join(" ")}>
<div
data-menu-trigger
onClick={() => {
setOpen(!open);
}}
className="inline-flex cursor-pointer"
>
{React.isValidElement(trigger)
? React.cloneElement(trigger as React.ReactElement<Record<string, unknown>>, {
"aria-haspopup": "menu",
"aria-expanded": open,
"aria-controls": open ? `${uid}-menu` : undefined,
})
: trigger}
</div>
{mounted && (
<Portal>
<div
ref={attachMenu}
id={`${uid}-menu`}
role="menu"
onKeyDown={onMenuKeyDown}
className="min-w-[200px] rounded-[16px] p-1.5 transition-[opacity,transform] duration-200 ease-[var(--ease-spring)]"
style={{
position: "fixed",
top: 0,
left: 0,
zIndex: LAYER.floating,
transformOrigin: `${side === "top" ? "bottom" : "top"} ${h === "start" ? "left" : "right"}`,
opacity: visible ? 1 : 0,
transform: visible ? "none" : `${ENTER_FROM[side]} scale(0.96)`,
background: "var(--panel-bezel)",
boxShadow: "var(--overlay-shadow)",
}}
>
<div
className="bg-panel-face rounded-[11px] p-1 flex flex-col"
style={{ boxShadow: "var(--panel-face-shadow)" }}
>
{items.map((item) => {
if (item.divider) {
return <div key={item.key} role="separator" className="h-px bg-line my-1 mx-2" />;
}
return (
<button
key={item.key}
type="button"
role="menuitem"
tabIndex={-1}
onClick={() => handleSelect(item)}
onMouseEnter={(e) => e.currentTarget.focus()}
disabled={item.disabled}
className={[
"group/item w-full flex items-center gap-2.5 px-2.5 py-2 rounded-[8px] text-[13px] font-medium cursor-pointer outline-none",
"transition-[background,color] duration-100",
"disabled:opacity-35 disabled:pointer-events-none",
item.danger
? "text-danger focus:bg-danger-wash"
: "text-ink-label focus:text-ink focus:bg-surface-active/80",
].join(" ")}
>
{item.icon && (
<span
className={[
"shrink-0 w-4 h-4 flex items-center justify-center transition-colors duration-100",
item.danger ? "text-danger-soft" : "text-ink-faint group-focus/item:text-ink-label",
].join(" ")}
>
{item.icon}
</span>
)}
<span className="flex-1 text-left">{item.label}</span>
{item.shortcut && <Kbd className="shrink-0">{item.shortcut}</Kbd>}
</button>
);
})}
</div>
</div>
</Portal>
)}
</div>
);
}
components/ui/Kbd.tsxtsx
import { ReactNode } from "react";
export function Kbd({
children,
className = "",
}: {
children: ReactNode;
className?: string;
}) {
return (
<kbd
className={`inline-flex items-center justify-center min-w-[22px] h-[22px] px-1.5 rounded-md text-[11px] font-semibold text-ink-muted ${className}`}
style={{
/* A keycap: lit top, solid 1.5px skirt below so it reads as pressable */
background: "var(--face)",
boxShadow: "var(--key-shadow)",
textShadow: "var(--emboss)",
}}
>
{children}
</kbd>
);
}
components/ui/Floating.tsxtsx
"use client";
import React, { RefObject, useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
import { createPortal } from "react-dom";
/* ── Portal ───────────────────────────────────────────────────────── */
/* Anything that floats over the page is rendered at the end of <body>.
Left where it was declared, a panel lives inside whatever stacking
context its parents made (a transform, a filter, an isolate), and a
later sibling on the page can paint straight over it, whatever its
z-index says. */
const noop = () => () => {};
export function Portal({ children }: { children: React.ReactNode }) {
const client = useSyncExternalStore(noop, () => true, () => false);
return client ? createPortal(children, document.body) : null;
}
/* Layers, lowest first: dialogs, then toasts, then anything anchored to
a control (so a select inside a modal opens over the modal), then
tooltips over all of it. */
export const LAYER = { dialog: 90, toast: 95, floating: 100, tooltip: 110 } as const;
/* ── Anchored position ────────────────────────────────────────────── */
export type Side = "top" | "bottom" | "left" | "right";
export type Align = "start" | "center" | "end";
const OPPOSITE: Record<Side, Side> = { top: "bottom", bottom: "top", left: "right", right: "left" };
const PAD = 8;
interface AnchorOptions {
open: boolean;
side?: Side;
align?: Align;
/** Space between the anchor and the panel */
gap?: number;
/** Make the panel at least as wide as the anchor (selects, comboboxes) */
matchWidth?: boolean;
}
/* Places a fixed-position panel next to its anchor: on the side asked
for unless the other side has more room, then nudged back inside the
viewport. Follows the anchor while the page scrolls or resizes. The
position is written straight onto the element, so scrolling never
re-renders the component. */
export function useAnchoredPosition(
anchor: RefObject<HTMLElement | null>,
{ open, side = "bottom", align = "start", gap = 8, matchWidth = false }: AnchorOptions,
) {
const [placed, setPlaced] = useState<Side>(side);
const floating = useRef<HTMLElement | null>(null);
const update = useCallback(() => {
const a = anchor.current?.getBoundingClientRect();
const el = floating.current;
if (!a || !el) return;
if (matchWidth) el.style.minWidth = `${a.width}px`;
// offset sizes ignore the enter transform, so these are the settled size
const w = el.offsetWidth;
const h = el.offsetHeight;
const vw = document.documentElement.clientWidth;
const vh = window.innerHeight;
const room: Record<Side, number> = { top: a.top, bottom: vh - a.bottom, left: a.left, right: vw - a.right };
const vertical = side === "top" || side === "bottom";
const need = (vertical ? h : w) + gap + PAD;
let s = side;
if (room[s] < need && room[OPPOSITE[s]] > room[s]) s = OPPOSITE[s];
let top: number;
let left: number;
if (s === "top" || s === "bottom") {
top = s === "bottom" ? a.bottom + gap : a.top - gap - h;
left = align === "start" ? a.left : align === "end" ? a.right - w : a.left + (a.width - w) / 2;
} else {
left = s === "right" ? a.right + gap : a.left - gap - w;
top = align === "start" ? a.top : align === "end" ? a.bottom - h : a.top + (a.height - h) / 2;
}
left = Math.max(PAD, Math.min(left, vw - w - PAD));
top = Math.max(PAD, Math.min(top, vh - h - PAD));
el.style.top = `${Math.round(top)}px`;
el.style.left = `${Math.round(left)}px`;
el.dataset.side = s;
setPlaced(s);
}, [anchor, side, align, gap, matchWidth]);
/* Attach this as the panel's ref: it's placed before its first paint */
const setFloating = useCallback(
(el: HTMLElement | null) => {
floating.current = el;
if (el) update();
},
[update],
);
useEffect(() => {
if (!open) return;
let frame = 0;
const schedule = () => {
cancelAnimationFrame(frame);
frame = requestAnimationFrame(update);
};
window.addEventListener("scroll", schedule, true);
window.addEventListener("resize", schedule);
// The panel can change size while open (a filtered list, a new month)
const ro = new ResizeObserver(schedule);
if (floating.current) ro.observe(floating.current);
if (anchor.current) ro.observe(anchor.current);
return () => {
cancelAnimationFrame(frame);
window.removeEventListener("scroll", schedule, true);
window.removeEventListener("resize", schedule);
ro.disconnect();
};
}, [open, update, anchor]);
return { setFloating, side: placed, floatingRef: floating, update };
}
/* Where the panel grows from, and where it slides in from, for each side */
export const ORIGIN: Record<Side, string> = {
top: "bottom",
bottom: "top",
left: "right",
right: "left",
};
export const ENTER_FROM: Record<Side, string> = {
top: "translateY(4px)",
bottom: "translateY(-4px)",
left: "translateX(4px)",
right: "translateX(-4px)",
};

usage

tsx
import { ContextMenu } from "@blurrow/ui";
tsx
<ContextMenu items={items} onSelect={handle}>…</ContextMenu>

props

straight from the source, so they're always up to date.

propsts
type ContextMenuItem = DropdownMenuItem;
interface ContextMenuProps {
/** The area that answers a right-click */
children: React.ReactNode;
items: ContextMenuItem[];
onSelect?: (key: string) => void;
disabled?: boolean;
className?: string;
}