Context Menu Open
The menu grows out of the point you pressed, from whichever corner keeps it on the surface.
The animated component in this preview is rendered from the canonical file shown here. The surrounding demo shell only provides context and is not part of the copied code.
import {
Fragment,
useEffect,
useRef,
useState,
type MouseEvent as ReactMouseEvent,
} from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
/**
* Vibary · Context Menu Open
*
* The menu grows out of the point you pressed, and out of the corner
* nearest that point — so near the right edge it opens leftward and near
* the bottom it opens upward, instead of hanging off the surface.
*
* Self-contained: depends only on `motion` (react ships with your app).
* Surfaces are mixed from the inherited text color, so the list reads
* correctly on a light page and on a dark one.
* Works with zero props; tune via `variant`.
* Right-click a row, or use the row's own menu control.
* Requires the automatic JSX runtime (default since React 17).
*/
export type ContextMenuOpenProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Fires with the chosen command. */
onCommand?: (command: string) => void;
};
type VariantConfig = {
/** Size the panel grows from, as a fraction of its resting size. */
scaleFrom: number;
/** Seconds between rows as they fade in. */
stagger: number;
/** Seconds for the menu to dismiss. */
exit: number;
spring: { type: "spring"; stiffness: number; damping: number };
};
// Quality rule: the panel carries text, so the growth is a few percent
// and no more — enough to read as coming from the pointer, small enough
// that no glyph is visibly resized on the way — and the springs sit at or
// above a 0.8 damping ratio so it arrives at full size and stops. The rows
// only fade; they never scale on their own. Variants change the travel and
// the interval, never the settle.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// Practically a cut, for a working tool where this menu is opened
// constantly.
subtle: {
scaleFrom: 0.99,
stagger: 0,
exit: 0.05,
spring: { type: "spring", stiffness: 850, damping: 52 },
},
// A short growth out of the pointer with the rows just behind it.
default: {
scaleFrom: 0.93,
stagger: 0.014,
exit: 0.11,
spring: { type: "spring", stiffness: 520, damping: 40 },
},
// More travel and a visible cascade down the rows — for a canvas or
// editor where the menu is part of the experience.
playful: {
scaleFrom: 0.85,
stagger: 0.042,
exit: 0.17,
spring: { type: "spring", stiffness: 290, damping: 29 },
},
};
const ACCENT = "#7C7CF0";
const DANGER = "#E05260";
/** The panel's own box, needed before it is rendered so the open can
* choose a corner rather than correct itself afterwards. */
const MENU_WIDTH = 182;
const MENU_HEIGHT = 174;
const EDGE_PADDING = 8;
/** Theme-adaptive neutral: `currentColor` is the text color this component
* inherits — near-black on a light page, near-white on a dark one — so
* mixing it with `transparent` yields a surface, border or fill that is
* correctly toned in either theme. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
type Command = { label: string; hint?: string; danger?: boolean; separated?: boolean };
const COMMANDS: Command[] = [
{ label: "Open", hint: "Enter" },
{ label: "Rename", hint: "F2" },
{ label: "Duplicate", hint: "Ctrl D" },
{ label: "Move to folder" },
{ label: "Delete", hint: "Del", danger: true, separated: true },
];
const FILES = [
["Q3 revenue summary", "Spreadsheet · 4.2 MB"],
["Churn by plan tier", "Report · 1.1 MB"],
["Seat usage export", "CSV · 820 KB"],
["Renewal forecast", "Spreadsheet · 640 KB"],
] as const;
type MenuState = {
left: number;
top: number;
origin: string;
file: string;
};
export default function ContextMenuOpen({
variant = "default",
onCommand,
}: ContextMenuOpenProps) {
const surfaceRef = useRef<HTMLDivElement>(null);
const [menu, setMenu] = useState<MenuState | null>(null);
const [last, setLast] = useState<string | null>(null);
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
/**
* Everything is measured against this panel's own box rather than the
* viewport, so the pattern drops into a card unchanged. For an
* app-level menu, render it in a portal with `position: fixed`, use the
* event's clientX/clientY directly, and compare against
* window.innerWidth / innerHeight in place of the rect below.
*/
const openAt = (clientX: number, clientY: number, file: string) => {
const rect = surfaceRef.current?.getBoundingClientRect();
if (!rect) return;
const x = clientX - rect.left;
const y = clientY - rect.top;
// Pick the corner before opening. Repositioning a menu after it has
// started growing is the visible mistake this avoids.
const flipX = x + MENU_WIDTH + EDGE_PADDING > rect.width;
const flipY = y + MENU_HEIGHT + EDGE_PADDING > rect.height;
const left = flipX ? x - MENU_WIDTH : x;
const top = flipY ? y - MENU_HEIGHT : y;
setMenu({
left: Math.min(
Math.max(left, EDGE_PADDING),
rect.width - MENU_WIDTH - EDGE_PADDING
),
top: Math.min(
Math.max(top, EDGE_PADDING),
rect.height - MENU_HEIGHT - EDGE_PADDING
),
// The growth starts at the corner the pointer is in, which is what
// ties the menu to the press rather than to the screen.
origin: `${flipX ? "right" : "left"} ${flipY ? "bottom" : "top"}`,
file,
});
};
const onRowContextMenu = (event: ReactMouseEvent, file: string) => {
event.preventDefault();
openAt(event.clientX, event.clientY, file);
};
useEffect(() => {
if (!menu) return;
const onKey = (event: KeyboardEvent) => {
if (event.key === "Escape") setMenu(null);
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [menu]);
const run = (command: string) => {
setLast(`${command} · ${menu?.file ?? ""}`);
onCommand?.(command);
setMenu(null);
};
// Reduced motion: the menu still appears at the point you pressed, it
// simply stops growing to get there.
const open = reduceMotion
? {
initial: { opacity: 0 },
animate: { opacity: 1 },
exit: { opacity: 0, transition: { duration: 0.09 } },
transition: { duration: 0.12, ease: "easeOut" as const },
}
: {
initial: { opacity: 0, scale: cfg.scaleFrom },
animate: { opacity: 1, scale: 1 },
exit: {
opacity: 0,
scale: cfg.scaleFrom + (1 - cfg.scaleFrom) * 0.5,
transition: { duration: cfg.exit, ease: "easeIn" as const },
},
transition: {
...cfg.spring,
opacity: { duration: 0.12, ease: "easeOut" as const },
},
};
return (
<div
ref={surfaceRef}
style={{
position: "relative",
width: 336,
height: 306,
borderRadius: 18,
background: tone(6),
color: "inherit",
border: `1px solid ${tone(12)}`,
boxShadow: "0 14px 34px rgba(0,0,0,0.18)",
overflow: "hidden",
}}
>
<div
style={{
display: "flex",
alignItems: "baseline",
justifyContent: "space-between",
padding: "15px 16px 10px",
}}
>
<span style={{ fontSize: 14.5, fontWeight: 650 }}>Documents</span>
<span
style={{
fontSize: 11,
opacity: 0.5,
maxWidth: 170,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{last ?? "Right-click a row"}
</span>
</div>
<div style={{ padding: "0 12px" }}>
{FILES.map(([name, meta]) => (
<div
key={name}
onContextMenu={(event) => onRowContextMenu(event, name)}
style={{
position: "relative",
display: "flex",
alignItems: "center",
gap: 10,
padding: "9px 10px",
marginBottom: 6,
borderRadius: 10,
background: tone(7),
border: `1px solid ${tone(10)}`,
}}
>
{/* The row the menu belongs to stays marked while it is open,
so a menu opened near the edge is never ambiguous about
what it is acting on. */}
<motion.span
aria-hidden
initial={false}
animate={{ opacity: menu?.file === name ? 1 : 0 }}
transition={{ duration: reduceMotion ? 0 : 0.14, ease: "easeOut" }}
style={{
position: "absolute",
inset: -1,
borderRadius: 11,
border: `1px solid ${ACCENT}`,
pointerEvents: "none",
}}
/>
<span
aria-hidden
style={{
display: "grid",
placeItems: "center",
width: 28,
height: 28,
flexShrink: 0,
borderRadius: 8,
background: tone(9),
}}
>
<svg
width="14"
height="14"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M4 2.4h5l3 3v8.2H4z" />
<path d="M9 2.4v3.1h3" />
</svg>
</span>
<span style={{ flex: 1, minWidth: 0 }}>
<span
style={{
display: "block",
fontSize: 12.5,
fontWeight: 600,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{name}
</span>
<span style={{ display: "block", fontSize: 11, opacity: 0.5 }}>
{meta}
</span>
</span>
{/* Touch has no right-click, so every row carries the same
menu on a control of its own. It opens from the control's
own corner, by the same rule. */}
<button
type="button"
aria-label={`Actions for ${name}`}
aria-haspopup="menu"
onClick={(event) => {
const box = event.currentTarget.getBoundingClientRect();
openAt(box.right, box.bottom, name);
}}
style={{
display: "grid",
placeItems: "center",
width: 24,
height: 24,
flexShrink: 0,
borderRadius: 7,
border: `1px solid ${tone(12)}`,
background: "transparent",
color: "inherit",
cursor: "pointer",
}}
>
<svg
width="13"
height="13"
viewBox="0 0 16 16"
fill="currentColor"
aria-hidden
>
<circle cx="4" cy="8" r="1.15" />
<circle cx="8" cy="8" r="1.15" />
<circle cx="12" cy="8" r="1.15" />
</svg>
</button>
</div>
))}
</div>
{/* A catcher, not a scrim: a context menu should not dim the thing
it is acting on, but the next press anywhere has to dismiss it.
It is invisible, so it leaves the instant the menu is dismissed
and does not need an exit of its own. */}
{menu && (
<div
onClick={() => setMenu(null)}
onContextMenu={(event) => {
event.preventDefault();
setMenu(null);
}}
style={{ position: "absolute", inset: 0, zIndex: 4 }}
/>
)}
{/* The menu is the only direct child here: AnimatePresence tracks
its own children, so wrapping it in a plain element would swallow
the exit. */}
<AnimatePresence>
{menu && (
<motion.div
key="menu"
role="menu"
aria-label={`Actions for ${menu.file}`}
{...open}
style={{
position: "absolute",
zIndex: 5,
left: menu.left,
top: menu.top,
width: MENU_WIDTH,
padding: "6px 0",
borderRadius: 12,
transformOrigin: menu.origin,
// Opaque, not toned: the menu sits directly over the rows it
// acts on, and a translucent panel would let their text read
// straight through it. `Canvas`/`CanvasText` are the CSS
// system colors for page background and page text, so it
// lands light in a light app and dark in a dark one.
background: "Canvas",
color: "CanvasText",
border: `1px solid ${tone(14)}`,
boxShadow: "0 18px 40px rgba(0,0,0,0.3)",
}}
>
{COMMANDS.map((command, index) => (
<Fragment key={command.label}>
{command.separated && (
<div
aria-hidden
style={{
height: 1,
margin: "5px 0",
background: tone(10),
}}
/>
)}
<motion.button
type="button"
role="menuitem"
onClick={() => run(command.label)}
initial={reduceMotion ? false : { opacity: 0 }}
animate={{ opacity: 1 }}
transition={{
duration: reduceMotion ? 0 : 0.14,
delay: reduceMotion ? 0 : index * cfg.stagger,
ease: "easeOut",
}}
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 10,
width: "100%",
height: 30,
padding: "0 11px",
border: 0,
background: "none",
color: command.danger ? DANGER : "inherit",
fontFamily: "inherit",
fontSize: 12.5,
fontWeight: 550,
textAlign: "left",
cursor: "pointer",
}}
>
<span>{command.label}</span>
{command.hint && (
<span
style={{
fontSize: 10.5,
opacity: 0.42,
letterSpacing: "0.02em",
}}
>
{command.hint}
</span>
)}
</motion.button>
</Fragment>
))}
</motion.div>
)}
</AnimatePresence>
</div>
);
}About this pattern
A right-click menu whose origin is the press itself. The corner is chosen before anything animates: the open measures the pointer against the surface, decides whether the panel should extend right or left, down or up, and sets the transform origin to match — so near an edge the menu grows back toward the middle instead of starting to hang off and correcting itself, which is the visible mistake in most implementations. Growth is a few percent and no more, because the panel is text and a menu that scales up from nothing resizes every glyph in it on the way; the rows themselves only fade. Dismissal is quicker than the open, a catcher rather than a scrim takes the next press, and the row the menu belongs to stays marked so there is never a question of what it is acting on.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Design canvas
A command list that opens at the pointer and stays inside the canvas near an edge.
Related patterns
- Dropdown Menu OpenA menu unfolds from the corner of the control that opened it, with its items arriving a frame apart.
- Nested Menu DrillA submenu pushes the parent list aside inside the same panel, and the panel resizes to fit it.
- Accordion ExpandOne section opens as the previous one closes, both heights easing over the same beat so the list never jumps.