Dropdown Menu Open
A menu unfolds from the corner of the control that opened it, with its items arriving a frame apart.
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 { useEffect, useRef, useState } from "react";
import {
AnimatePresence,
motion,
useReducedMotion,
type Variants,
} from "motion/react";
/**
* Vibary · Dropdown Menu Open
*
* The menu unfolds from the corner of the control that opened it, and
* the items arrive a frame apart so the list reads top to bottom. Arrow
* keys move, Escape closes and hands focus back.
*
* Self-contained: depends only on `motion` (react ships with your app).
* The surface follows the host app's color scheme and everything on it
* is mixed from the inherited text color, so the menu reads correctly on
* a light page and on a dark one.
* Works with zero props; tune via `variant`, `align`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type DropdownMenuOpenProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Which corner the menu unfolds from. */
align?: "end" | "start";
/** Notified with the id of the chosen item. */
onSelect?: (id: string) => void;
};
type VariantConfig = {
spring: { type: "spring"; stiffness: number; damping: number };
/** How far under full size the surface starts. */
scaleFrom: number;
/** Seconds between one item appearing and the next. */
stagger: number;
itemFade: number;
};
// Quality rule: the surface may grow into place, but it must never pass
// its resting size — a menu that overshoots drags every label in it past
// full size and back. Every spring is well above a 0.8 damping ratio, and
// the scale travel is small enough that the type inside is never visibly
// resized. Variants change the distance and the gap between items.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// Nearly a straight fade. For menus attached to every row of a list.
subtle: {
spring: { type: "spring", stiffness: 760, damping: 53 },
scaleFrom: 0.99,
stagger: 0.012,
itemFade: 0.07,
},
// A short unfold with the items clearly arriving in order. All-purpose.
default: {
spring: { type: "spring", stiffness: 520, damping: 42 },
scaleFrom: 0.96,
stagger: 0.024,
itemFade: 0.16,
},
// More travel and a wider gap, for a menu that is the main event.
playful: {
spring: { type: "spring", stiffness: 360, damping: 34 },
scaleFrom: 0.9,
stagger: 0.046,
itemFade: 0.2,
},
};
const ACCENT = "#7C7CF0";
const DANGER = "#E05260";
/** 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. Nothing to configure. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
type ItemIcon = "rename" | "duplicate" | "move" | "share" | "download" | "delete";
const ITEMS: readonly {
id: string;
label: string;
icon: ItemIcon;
danger?: boolean;
}[] = [
{ id: "rename", label: "Rename", icon: "rename" },
{ id: "duplicate", label: "Duplicate", icon: "duplicate" },
{ id: "move", label: "Move to folder", icon: "move" },
{ id: "share", label: "Copy share link", icon: "share" },
{ id: "download", label: "Download", icon: "download" },
{ id: "delete", label: "Delete", icon: "delete", danger: true },
];
function ItemGlyph({ name }: { name: ItemIcon }) {
const common = {
width: 15,
height: 15,
viewBox: "0 0 20 20",
fill: "none",
stroke: "currentColor",
strokeWidth: 1.5,
strokeLinecap: "round" as const,
strokeLinejoin: "round" as const,
"aria-hidden": true,
};
if (name === "rename") {
return (
<svg {...common}>
<path d="M13.4 3.6a1.9 1.9 0 0 1 2.7 2.7L7.6 14.8 4 16l1.2-3.6z" />
</svg>
);
}
if (name === "duplicate") {
return (
<svg {...common}>
<rect x="7" y="7" width="9.5" height="9.5" rx="2" />
<path d="M13 4.6a1.6 1.6 0 0 0-1.6-1.1H5.6A2.1 2.1 0 0 0 3.5 5.6v5.8c0 .7.4 1.3 1.1 1.6" />
</svg>
);
}
if (name === "move") {
return (
<svg {...common}>
<path d="M2.6 6.2a1.6 1.6 0 0 1 1.6-1.6h3l1.6 2h6.6a1.6 1.6 0 0 1 1.6 1.6v6.2a1.6 1.6 0 0 1-1.6 1.6H4.2a1.6 1.6 0 0 1-1.6-1.6z" />
</svg>
);
}
if (name === "share") {
return (
<svg {...common}>
<path d="M8.4 11.6a3 3 0 0 0 4.4.3l2.4-2.4a3 3 0 0 0-4.2-4.2l-1 1" />
<path d="M11.6 8.4a3 3 0 0 0-4.4-.3L4.8 10.5a3 3 0 0 0 4.2 4.2l1-1" />
</svg>
);
}
if (name === "download") {
return (
<svg {...common}>
<path d="M10 3v9" />
<path d="M6.5 8.5 10 12l3.5-3.5" />
<path d="M3.5 14.5v1.5a1 1 0 0 0 1 1h11a1 1 0 0 0 1-1v-1.5" />
</svg>
);
}
return (
<svg {...common}>
<path d="M4 5.6h12" />
<path d="M8 5.6V4a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1v1.6" />
<path d="M5.4 5.6l.7 9.4a1.6 1.6 0 0 0 1.6 1.5h4.6a1.6 1.6 0 0 0 1.6-1.5l.7-9.4" />
</svg>
);
}
export default function DropdownMenuOpen({
variant = "default",
align = "end",
onSelect,
}: DropdownMenuOpenProps) {
const [open, setOpen] = useState(false);
const [cursor, setCursor] = useState(0);
const [lastAction, setLastAction] = useState<string | null>(null);
const rootRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const itemRefs = useRef<(HTMLButtonElement | null)[]>([]);
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
// Keyboard focus follows the cursor; the pointer deliberately does not
// move it, so a stray mouse can't yank focus off the keyboard's place.
useEffect(() => {
if (open) itemRefs.current[cursor]?.focus();
}, [open, cursor]);
useEffect(() => {
if (!open) return;
const onPointerDown = (event: PointerEvent) => {
if (!rootRef.current?.contains(event.target as Node)) setOpen(false);
};
window.addEventListener("pointerdown", onPointerDown);
return () => window.removeEventListener("pointerdown", onPointerDown);
}, [open]);
const closeAndReturnFocus = () => {
setOpen(false);
triggerRef.current?.focus();
};
const choose = (id: string) => {
setLastAction(ITEMS.find((item) => item.id === id)?.label ?? null);
closeAndReturnFocus();
onSelect?.(id);
};
// Reduced motion: the menu still appears from its corner in order, it
// just stops growing and travelling to get there.
const menuVariants: Variants = reduceMotion
? {
hidden: { opacity: 0 },
visible: { opacity: 1, transition: { duration: 0.12, ease: "easeOut" } },
exit: { opacity: 0, transition: { duration: 0.1 } },
}
: {
hidden: { opacity: 0, scale: cfg.scaleFrom, y: -6 },
visible: {
opacity: 1,
scale: 1,
y: 0,
transition: {
...cfg.spring,
// Items start arriving while the surface is still unfolding,
// which is what makes the menu read as one gesture rather than
// a box followed by a list.
delayChildren: 0.02,
staggerChildren: cfg.stagger,
},
},
exit: {
opacity: 0,
scale: cfg.scaleFrom + (1 - cfg.scaleFrom) * 0.5,
y: -3,
transition: { duration: 0.11, ease: "easeIn" },
},
};
const itemVariants: Variants = reduceMotion
? {
hidden: { opacity: 0 },
visible: { opacity: 1, transition: { duration: 0.1 } },
exit: { opacity: 0, transition: { duration: 0.06 } },
}
: {
hidden: { opacity: 0, y: -5 },
visible: {
opacity: 1,
y: 0,
transition: { duration: cfg.itemFade, ease: "easeOut" },
},
// Leaving together is correct: a staggered exit would make the
// menu look like it was being dismantled.
exit: { opacity: 0, transition: { duration: 0.08 } },
};
return (
<div
ref={rootRef}
style={{
position: "relative",
width: 322,
borderRadius: 16,
background: tone(6),
color: "inherit",
border: `1px solid ${tone(12)}`,
boxShadow: "0 10px 28px rgba(0,0,0,0.16)",
}}
>
<div
style={{
display: "flex",
alignItems: "flex-start",
gap: 10,
padding: "14px 14px 12px",
}}
>
<span
aria-hidden
style={{
display: "grid",
placeItems: "center",
width: 34,
height: 34,
borderRadius: 10,
background: tone(10),
color: ACCENT,
}}
>
<ItemGlyph name="move" />
</span>
<span style={{ flex: 1, minWidth: 0 }}>
<span style={{ display: "block", fontSize: 13.5, fontWeight: 650 }}>
Q3 revenue summary
</span>
<span style={{ display: "block", fontSize: 11.5, opacity: 0.5, marginTop: 2 }}>
Documents · Edited 4 minutes ago
</span>
</span>
<button
ref={triggerRef}
type="button"
onClick={() => {
setCursor(0);
setOpen((current) => !current);
}}
aria-haspopup="menu"
aria-expanded={open}
aria-label="Document actions"
style={{
display: "grid",
placeItems: "center",
width: 28,
height: 28,
borderRadius: 8,
border: `1px solid ${tone(12)}`,
background: open ? tone(12) : tone(6),
color: "inherit",
cursor: "pointer",
}}
>
<svg width="15" height="15" viewBox="0 0 20 20" fill="currentColor" aria-hidden>
<circle cx="10" cy="4.6" r="1.5" />
<circle cx="10" cy="10" r="1.5" />
<circle cx="10" cy="15.4" r="1.5" />
</svg>
</button>
</div>
<div
style={{
padding: "10px 14px 14px",
borderTop: `1px solid ${tone(10)}`,
fontSize: 11.5,
opacity: 0.55,
}}
>
{lastAction ? `Last action: ${lastAction}` : "No action taken yet"}
</div>
<AnimatePresence>
{open && (
<motion.div
key="menu"
role="menu"
aria-label="Document actions"
variants={menuVariants}
initial="hidden"
animate="visible"
exit="exit"
onKeyDown={(event) => {
if (event.key === "ArrowDown") {
event.preventDefault();
setCursor((current) => (current + 1) % ITEMS.length);
} else if (event.key === "ArrowUp") {
event.preventDefault();
setCursor((current) => (current - 1 + ITEMS.length) % ITEMS.length);
} else if (event.key === "Home") {
event.preventDefault();
setCursor(0);
} else if (event.key === "End") {
event.preventDefault();
setCursor(ITEMS.length - 1);
} else if (event.key === "Escape" || event.key === "Tab") {
event.preventDefault();
closeAndReturnFocus();
}
}}
style={{
// Anchored to this card, not the viewport: the menu is a
// child of the control's own positioning context, so it
// travels with it and can be dropped into a preview. For a
// menu that must escape a clipping ancestor, render it in a
// portal with `position: fixed` and the trigger's measured
// rect instead.
position: "absolute",
top: 46,
right: align === "end" ? 12 : "auto",
left: align === "end" ? "auto" : 12,
width: 194,
zIndex: 2,
padding: 5,
borderRadius: 13,
// The corner it grows from is the corner it belongs to —
// that single property is what makes it read as unfolding
// out of the control rather than appearing beside it.
transformOrigin: align === "end" ? "top right" : "top left",
// The one surface here that cannot be translucent: it sits
// over the page it acts on. `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 16px 36px rgba(0,0,0,0.28)",
}}
>
{ITEMS.map((item, index) => (
<motion.button
key={item.id}
ref={(node) => {
itemRefs.current[index] = node;
}}
type="button"
role="menuitem"
tabIndex={index === cursor ? 0 : -1}
variants={itemVariants}
onClick={() => choose(item.id)}
onFocus={() => setCursor(index)}
style={{
display: "flex",
alignItems: "center",
gap: 10,
width: "100%",
padding: "8px 9px",
borderRadius: 9,
border: 0,
background: index === cursor ? tone(9) : "transparent",
color: item.danger ? DANGER : "inherit",
fontSize: 12.5,
fontWeight: 550,
fontFamily: "inherit",
textAlign: "left",
cursor: "pointer",
marginTop: item.danger ? 5 : 0,
borderTop: item.danger ? `1px solid ${tone(10)}` : undefined,
paddingTop: item.danger ? 10 : 8,
}}
>
<span aria-hidden style={{ display: "grid", opacity: item.danger ? 1 : 0.6 }}>
<ItemGlyph name={item.icon} />
</span>
{item.label}
</motion.button>
))}
</motion.div>
)}
</AnimatePresence>
</div>
);
}About this pattern
The most-used overlay in any product, and the one where a wrong transform origin is instantly noticeable: the menu has to grow out of the corner it hangs from, or it reads as a panel that happened to appear nearby. So transformOrigin follows the alignment, the surface travels only a few percent, and the spring is damped hard enough that it never passes its resting size — an overshooting menu drags every label in it past full size and back. The items are staggered by a couple of frames each and start before the surface has settled, which makes the whole thing read as one gesture rather than a box followed by a list. They leave together, though: a staggered exit looks like the menu is being dismantled. Keyboard focus moves with the cursor and returns to the control on Escape.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Code review
The overflow menu unfolds from the control it hangs off.
Related patterns
- Popover Anchor FlipA popover opens below its trigger, or flips above when space runs out — the arrow follows.
- Breadcrumb Trail AppendGoing one level deeper slides a new crumb in from the right while the trail behind it recedes.
- Context Menu OpenThe menu grows out of the point you pressed, from whichever corner keeps it on the surface.