overlaysclient
context menu
the dropdown menu's panel, opened where you right-click. long-press works on touch, shift f10 on a keyboard.
right-click in here
or long-press on a phone
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-menuor 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
1"use client";2
3import React, { useEffect, useId, useRef, useState } from "react";4import type { DropdownMenuItem } from "./DropdownMenu";5import { LAYER, Portal } from "./Floating";6import { Kbd } from "./Kbd";7
8export type ContextMenuItem = DropdownMenuItem;9
10interface ContextMenuProps {11 /** The area that answers a right-click */12 children: React.ReactNode;13 items: ContextMenuItem[];14 onSelect?: (key: string) => void;15 disabled?: boolean;16 className?: string;17}18
19const PAD = 8;20const LONG_PRESS = 500;21
22/* The same panel as DropdownMenu, opened where you right-click instead of23 under a button. A long press opens it on touch, and the context-menu24 key or shift+F10 opens it from the keyboard at the focused element.25 It grows out of the corner nearest the pointer and flips to stay on26 screen. */27export function ContextMenu({ children, items, onSelect, disabled = false, className = "" }: ContextMenuProps) {28 const uid = useId();29 const areaRef = useRef<HTMLDivElement>(null);30 const menuRef = useRef<HTMLDivElement | null>(null);31 const [point, setPoint] = useState<{ x: number; y: number } | null>(null);32 const [mounted, setMounted] = useState(false);33 const [visible, setVisible] = useState(false);34 const press = useRef<ReturnType<typeof setTimeout> | null>(null);35 const open = point !== null;36
37 if (open && !mounted) setMounted(true);38 if (!open && visible) setVisible(false);39
40 const enabledItems = () =>41 Array.from(menuRef.current?.querySelectorAll<HTMLElement>('[role="menuitem"]:not([disabled])') ?? []);42
43 useEffect(() => {44 if (open) {45 const id = requestAnimationFrame(() =>46 requestAnimationFrame(() => {47 setVisible(true);48 menuRef.current?.querySelector<HTMLElement>('[role="menuitem"]:not([disabled])')?.focus();49 }),50 );51 return () => cancelAnimationFrame(id);52 }53 const t = setTimeout(() => setMounted(false), 160);54 return () => clearTimeout(t);55 }, [open]);56
57 useEffect(() => {58 if (!open) return;59 const shut = (e: Event) => {60 if (menuRef.current?.contains(e.target as Node)) return;61 setPoint(null);62 };63 const onScroll = () => setPoint(null);64 document.addEventListener("pointerdown", shut);65 window.addEventListener("scroll", onScroll, true);66 window.addEventListener("resize", onScroll);67 window.addEventListener("blur", onScroll);68 return () => {69 document.removeEventListener("pointerdown", shut);70 window.removeEventListener("scroll", onScroll, true);71 window.removeEventListener("resize", onScroll);72 window.removeEventListener("blur", onScroll);73 };74 }, [open]);75
76 /* Placed as soon as it's attached: beside the pointer, flipped left or77 up if it would run off the edge. Only writes to the element, so it78 can run on every render without setting state. */79 const attach = (el: HTMLDivElement | null) => {80 menuRef.current = el;81 if (!el || !point) return;82 const w = el.offsetWidth;83 const h = el.offsetHeight;84 const vw = document.documentElement.clientWidth;85 const vh = window.innerHeight;86 const flipX = point.x + w > vw - PAD;87 const flipY = point.y + h > vh - PAD;88 el.style.left = `${Math.max(PAD, flipX ? point.x - w : point.x)}px`;89 el.style.top = `${Math.max(PAD, flipY ? point.y - h : point.y)}px`;90 el.style.transformOrigin = `${flipY ? "bottom" : "top"} ${flipX ? "right" : "left"}`;91 };92
93 const openAt = (x: number, y: number) => {94 if (disabled) return;95 setPoint({ x, y });96 };97
98 const close = (refocus = true) => {99 setPoint(null);100 if (refocus) areaRef.current?.focus({ preventScroll: true });101 };102
103 const onMenuKeyDown = (e: React.KeyboardEvent) => {104 const list = enabledItems();105 const at = list.indexOf(document.activeElement as HTMLElement);106 const go = (i: number) => list[(i + list.length) % list.length]?.focus();107 switch (e.key) {108 case "ArrowDown":109 e.preventDefault();110 go(at + 1);111 break;112 case "ArrowUp":113 e.preventDefault();114 go(at - 1);115 break;116 case "Home":117 e.preventDefault();118 go(0);119 break;120 case "End":121 e.preventDefault();122 go(list.length - 1);123 break;124 case "Escape":125 e.preventDefault();126 e.stopPropagation();127 close();128 break;129 case "Tab":130 e.preventDefault();131 close();132 break;133 }134 };135
136 const cancelPress = () => {137 if (press.current) clearTimeout(press.current);138 };139
140 return (141 <>142 <div143 ref={areaRef}144 tabIndex={0}145 aria-haspopup="menu"146 aria-expanded={open}147 aria-controls={open ? `${uid}-menu` : undefined}148 aria-describedby={`${uid}-how`}149 className={["outline-none focus-visible:ring-2 focus-visible:ring-ink focus-visible:ring-offset-2 focus-visible:ring-offset-page", className].join(" ")}150 onContextMenu={(e) => {151 if (disabled) return;152 e.preventDefault();153 openAt(e.clientX, e.clientY);154 }}155 onKeyDown={(e) => {156 if (e.key === "ContextMenu" || (e.shiftKey && e.key === "F10")) {157 e.preventDefault();158 const r = areaRef.current!.getBoundingClientRect();159 openAt(r.left + 16, r.top + 16);160 }161 }}162 onPointerDown={(e) => {163 if (e.pointerType !== "touch") return;164 const { clientX, clientY } = e;165 cancelPress();166 press.current = setTimeout(() => openAt(clientX, clientY), LONG_PRESS);167 }}168 onPointerUp={cancelPress}169 onPointerMove={cancelPress}170 onPointerCancel={cancelPress}171 >172 {children}173 <span id={`${uid}-how`} className="sr-only">174 right-click, long-press, or press shift f10 for options175 </span>176 </div>177
178 {mounted && (179 <Portal>180 <div181 ref={attach}182 id={`${uid}-menu`}183 role="menu"184 onKeyDown={onMenuKeyDown}185 onContextMenu={(e) => e.preventDefault()}186 className="min-w-[200px] rounded-[16px] p-1.5 transition-[opacity,transform] duration-150 ease-[var(--ease-spring)]"187 style={{188 position: "fixed",189 top: 0,190 left: 0,191 zIndex: LAYER.floating,192 opacity: visible ? 1 : 0,193 transform: visible ? "none" : "scale(0.94)",194 background: "var(--panel-bezel)",195 boxShadow: "var(--overlay-shadow)",196 }}197 >198 <div className="flex flex-col rounded-[11px] bg-panel-face p-1" style={{ boxShadow: "var(--panel-face-shadow)" }}>199 {items.map((item) =>200 item.divider ? (201 <div key={item.key} role="separator" className="mx-2 my-1 h-px bg-line" />202 ) : (203 <button204 key={item.key}205 type="button"206 role="menuitem"207 tabIndex={-1}208 disabled={item.disabled}209 onClick={() => {210 onSelect?.(item.key);211 close();212 }}213 onMouseEnter={(e) => e.currentTarget.focus()}214 className={[215 "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",216 "transition-[background,color] duration-100 disabled:pointer-events-none disabled:opacity-35",217 item.danger ? "text-danger focus:bg-danger-wash" : "text-ink-label focus:bg-surface-active/80 focus:text-ink",218 ].join(" ")}219 >220 {item.icon && (221 <span222 className={[223 "flex h-4 w-4 shrink-0 items-center justify-center transition-colors duration-100",224 item.danger ? "text-danger-soft" : "text-ink-faint group-focus/item:text-ink-label",225 ].join(" ")}226 >227 {item.icon}228 </span>229 )}230 <span className="flex-1 text-left">{item.label}</span>231 {item.shortcut && <Kbd className="shrink-0">{item.shortcut}</Kbd>}232 </button>233 ),234 )}235 </div>236 </div>237 </Portal>238 )}239 </>240 );241}components/ui/DropdownMenu.tsxtsx
1"use client";2
3import React, { useState, useRef, useEffect, useId, useCallback, ReactNode } from "react";4import { Kbd } from "./Kbd";5import { ENTER_FROM, LAYER, Portal, useAnchoredPosition } from "./Floating";6
7type DropdownMenuPosition =8 | "bottom-start"9 | "bottom-end"10 | "top-start"11 | "top-end";12
13export interface DropdownMenuItem {14 key: string;15 label: string;16 icon?: ReactNode;17 shortcut?: string;18 danger?: boolean;19 disabled?: boolean;20 divider?: boolean;21}22
23interface DropdownMenuProps {24 trigger: ReactNode;25 items: DropdownMenuItem[];26 position?: DropdownMenuPosition;27 onSelect?: (key: string) => void;28 className?: string;29}30
31export function DropdownMenu({32 trigger,33 items,34 position = "bottom-end",35 onSelect,36 className = "",37}: DropdownMenuProps) {38 const [open, setOpen] = useState(false);39 const [visible, setVisible] = useState(false);40 const [mounted, setMounted] = useState(false);41 const ref = useRef<HTMLDivElement>(null);42 const menuRef = useRef<HTMLDivElement | null>(null);43 const uid = useId();44 const [v, h] = position.split("-") as ["top" | "bottom", "start" | "end"];45 // Portalled and pinned to the trigger; flips up or over when it runs out of room46 const { setFloating, side } = useAnchoredPosition(ref, { open: mounted, side: v, align: h });47 const attachMenu = useCallback(48 (el: HTMLDivElement | null) => {49 menuRef.current = el;50 setFloating(el);51 },52 [setFloating],53 );54
55 const enabledItems = () => Array.from(menuRef.current?.querySelectorAll<HTMLElement>('[role="menuitem"]:not([disabled])') ?? []);56
57 const focusItem = (index: number) => {58 const list = enabledItems();59 if (list.length === 0) return;60 list[(index + list.length) % list.length].focus();61 };62
63 /* Mounted and faded out during render; the effect only times the rest */64 if (open && !mounted) setMounted(true);65 if (!open && visible) setVisible(false);66
67 useEffect(() => {68 if (open) {69 requestAnimationFrame(() =>70 requestAnimationFrame(() => {71 setVisible(true);72 // Land on the first item so arrow keys work straight away73 enabledItems()[0]?.focus();74 })75 );76 } else {77 const t = setTimeout(() => setMounted(false), 180);78 return () => clearTimeout(t);79 }80 }, [open]);81
82 useEffect(() => {83 function onOutside(e: MouseEvent) {84 const t = e.target as Node;85 if (ref.current?.contains(t) || menuRef.current?.contains(t)) return;86 setOpen(false);87 }88 document.addEventListener("mousedown", onOutside);89 return () => document.removeEventListener("mousedown", onOutside);90 }, []);91
92 const returnFocus = () => {93 ref.current?.querySelector<HTMLElement>("[data-menu-trigger] button, [data-menu-trigger] a")?.focus();94 };95
96 const onMenuKeyDown = (e: React.KeyboardEvent) => {97 const list = enabledItems();98 const at = list.indexOf(document.activeElement as HTMLElement);99 switch (e.key) {100 case "ArrowDown":101 e.preventDefault();102 focusItem(at + 1);103 break;104 case "ArrowUp":105 e.preventDefault();106 focusItem(at - 1);107 break;108 case "Home":109 e.preventDefault();110 focusItem(0);111 break;112 case "End":113 e.preventDefault();114 focusItem(list.length - 1);115 break;116 case "Escape":117 e.preventDefault();118 setOpen(false);119 returnFocus();120 break;121 case "Tab":122 setOpen(false);123 break;124 }125 };126
127 function handleSelect(item: DropdownMenuItem) {128 if (item.disabled) return;129 onSelect?.(item.key);130 setOpen(false);131 returnFocus();132 }133
134 return (135 <div ref={ref} className={["relative inline-flex", className].join(" ")}>136 <div137 data-menu-trigger138 onClick={() => {139 setOpen(!open);140 }}141 className="inline-flex cursor-pointer"142 >143 {React.isValidElement(trigger)144 ? React.cloneElement(trigger as React.ReactElement<Record<string, unknown>>, {145 "aria-haspopup": "menu",146 "aria-expanded": open,147 "aria-controls": open ? `${uid}-menu` : undefined,148 })149 : trigger}150 </div>151 {mounted && (152 <Portal>153 <div154 ref={attachMenu}155 id={`${uid}-menu`}156 role="menu"157 onKeyDown={onMenuKeyDown}158 className="min-w-[200px] rounded-[16px] p-1.5 transition-[opacity,transform] duration-200 ease-[var(--ease-spring)]"159 style={{160 position: "fixed",161 top: 0,162 left: 0,163 zIndex: LAYER.floating,164 transformOrigin: `${side === "top" ? "bottom" : "top"} ${h === "start" ? "left" : "right"}`,165 opacity: visible ? 1 : 0,166 transform: visible ? "none" : `${ENTER_FROM[side]} scale(0.96)`,167 background: "var(--panel-bezel)",168 boxShadow: "var(--overlay-shadow)",169 }}170 >171 <div172 className="bg-panel-face rounded-[11px] p-1 flex flex-col"173 style={{ boxShadow: "var(--panel-face-shadow)" }}174 >175 {items.map((item) => {176 if (item.divider) {177 return <div key={item.key} role="separator" className="h-px bg-line my-1 mx-2" />;178 }179
180 return (181 <button182 key={item.key}183 type="button"184 role="menuitem"185 tabIndex={-1}186 onClick={() => handleSelect(item)}187 onMouseEnter={(e) => e.currentTarget.focus()}188 disabled={item.disabled}189 className={[190 "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",191 "transition-[background,color] duration-100",192 "disabled:opacity-35 disabled:pointer-events-none",193 item.danger194 ? "text-danger focus:bg-danger-wash"195 : "text-ink-label focus:text-ink focus:bg-surface-active/80",196 ].join(" ")}197 >198 {item.icon && (199 <span200 className={[201 "shrink-0 w-4 h-4 flex items-center justify-center transition-colors duration-100",202 item.danger ? "text-danger-soft" : "text-ink-faint group-focus/item:text-ink-label",203 ].join(" ")}204 >205 {item.icon}206 </span>207 )}208 <span className="flex-1 text-left">{item.label}</span>209 {item.shortcut && <Kbd className="shrink-0">{item.shortcut}</Kbd>}210 </button>211 );212 })}213 </div>214 </div>215 </Portal>216 )}217 </div>218 );219}components/ui/Kbd.tsxtsx
1import { ReactNode } from "react";2
3export function Kbd({4 children,5 className = "",6}: {7 children: ReactNode;8 className?: string;9}) {10 return (11 <kbd12 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}`}13 style={{14 /* A keycap: lit top, solid 1.5px skirt below so it reads as pressable */15 background: "var(--face)",16 boxShadow: "var(--key-shadow)",17 textShadow: "var(--emboss)",18 }}19 >20 {children}21 </kbd>22 );23}components/ui/Floating.tsxtsx
1"use client";2
3import React, { RefObject, useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";4import { createPortal } from "react-dom";5
6/* ── Portal ───────────────────────────────────────────────────────── */7/* Anything that floats over the page is rendered at the end of <body>.8 Left where it was declared, a panel lives inside whatever stacking9 context its parents made (a transform, a filter, an isolate), and a10 later sibling on the page can paint straight over it, whatever its11 z-index says. */12const noop = () => () => {};13
14export function Portal({ children }: { children: React.ReactNode }) {15 const client = useSyncExternalStore(noop, () => true, () => false);16 return client ? createPortal(children, document.body) : null;17}18
19/* Layers, lowest first: dialogs, then toasts, then anything anchored to20 a control (so a select inside a modal opens over the modal), then21 tooltips over all of it. */22export const LAYER = { dialog: 90, toast: 95, floating: 100, tooltip: 110 } as const;23
24/* ── Anchored position ────────────────────────────────────────────── */25export type Side = "top" | "bottom" | "left" | "right";26export type Align = "start" | "center" | "end";27
28const OPPOSITE: Record<Side, Side> = { top: "bottom", bottom: "top", left: "right", right: "left" };29const PAD = 8;30
31interface AnchorOptions {32 open: boolean;33 side?: Side;34 align?: Align;35 /** Space between the anchor and the panel */36 gap?: number;37 /** Make the panel at least as wide as the anchor (selects, comboboxes) */38 matchWidth?: boolean;39}40
41/* Places a fixed-position panel next to its anchor: on the side asked42 for unless the other side has more room, then nudged back inside the43 viewport. Follows the anchor while the page scrolls or resizes. The44 position is written straight onto the element, so scrolling never45 re-renders the component. */46export function useAnchoredPosition(47 anchor: RefObject<HTMLElement | null>,48 { open, side = "bottom", align = "start", gap = 8, matchWidth = false }: AnchorOptions,49) {50 const [placed, setPlaced] = useState<Side>(side);51 const floating = useRef<HTMLElement | null>(null);52
53 const update = useCallback(() => {54 const a = anchor.current?.getBoundingClientRect();55 const el = floating.current;56 if (!a || !el) return;57 if (matchWidth) el.style.minWidth = `${a.width}px`;58
59 // offset sizes ignore the enter transform, so these are the settled size60 const w = el.offsetWidth;61 const h = el.offsetHeight;62 const vw = document.documentElement.clientWidth;63 const vh = window.innerHeight;64 const room: Record<Side, number> = { top: a.top, bottom: vh - a.bottom, left: a.left, right: vw - a.right };65 const vertical = side === "top" || side === "bottom";66 const need = (vertical ? h : w) + gap + PAD;67
68 let s = side;69 if (room[s] < need && room[OPPOSITE[s]] > room[s]) s = OPPOSITE[s];70
71 let top: number;72 let left: number;73 if (s === "top" || s === "bottom") {74 top = s === "bottom" ? a.bottom + gap : a.top - gap - h;75 left = align === "start" ? a.left : align === "end" ? a.right - w : a.left + (a.width - w) / 2;76 } else {77 left = s === "right" ? a.right + gap : a.left - gap - w;78 top = align === "start" ? a.top : align === "end" ? a.bottom - h : a.top + (a.height - h) / 2;79 }80 left = Math.max(PAD, Math.min(left, vw - w - PAD));81 top = Math.max(PAD, Math.min(top, vh - h - PAD));82
83 el.style.top = `${Math.round(top)}px`;84 el.style.left = `${Math.round(left)}px`;85 el.dataset.side = s;86 setPlaced(s);87 }, [anchor, side, align, gap, matchWidth]);88
89 /* Attach this as the panel's ref: it's placed before its first paint */90 const setFloating = useCallback(91 (el: HTMLElement | null) => {92 floating.current = el;93 if (el) update();94 },95 [update],96 );97
98 useEffect(() => {99 if (!open) return;100 let frame = 0;101 const schedule = () => {102 cancelAnimationFrame(frame);103 frame = requestAnimationFrame(update);104 };105 window.addEventListener("scroll", schedule, true);106 window.addEventListener("resize", schedule);107 // The panel can change size while open (a filtered list, a new month)108 const ro = new ResizeObserver(schedule);109 if (floating.current) ro.observe(floating.current);110 if (anchor.current) ro.observe(anchor.current);111 return () => {112 cancelAnimationFrame(frame);113 window.removeEventListener("scroll", schedule, true);114 window.removeEventListener("resize", schedule);115 ro.disconnect();116 };117 }, [open, update, anchor]);118
119 return { setFloating, side: placed, floatingRef: floating, update };120}121
122/* Where the panel grows from, and where it slides in from, for each side */123export const ORIGIN: Record<Side, string> = {124 top: "bottom",125 bottom: "top",126 left: "right",127 right: "left",128};129export const ENTER_FROM: Record<Side, string> = {130 top: "translateY(4px)",131 bottom: "translateY(-4px)",132 left: "translateX(4px)",133 right: "translateX(-4px)",134};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;}