Origin aware popover
Menus scale out of the exact trigger that opened them and flip above it when there is no room below.
It imports two small hooks from @/lib/motion and the colour tokens; the zip includes both.
Demo.tsx
"use client";
import { useEffect, useLayoutEffect, useRef, useState, type KeyboardEvent, type ReactNode } from "react";
import { useInView, usePrefersReducedMotion } from "@/lib/motion";
import styles from "./demo.module.css";
type Id = "file" | "share" | "account" | "status";
const STATUSES = ["Draft", "In review", "Published"];
const MENUS: Record<Id, { label: string; items: string[] }> = {
file: { label: "File", items: ["New page", "Duplicate", "Export as PDF", "Move to trash"] },
share: { label: "Share", items: ["Copy link", "Invite teammate", "Publish to web"] },
account: { label: "Account", items: ["Profile", "Preferences", "Sign out"] },
status: { label: "Status", items: STATUSES },
};
const DONE: Record<string, string> = {
"New page": "Page created", Duplicate: "Duplicated", "Export as PDF": "PDF exported", "Move to trash": "Moved to trash",
"Copy link": "Link copied", "Invite teammate": "Invite sent", "Publish to web": "Published", Profile: "Opened profile",
Preferences: "Opened preferences", "Sign out": "Signed out",
};
// The order the idle demo opens menus in, so a gallery tile shows each origin.
const TOUR: Id[] = ["file", "share", "account", "status"];
const GAP = 6;
export default function Demo() {
const root = useRef<HTMLDivElement>(null);
const card = useRef<HTMLDivElement>(null);
const menu = useRef<HTMLDivElement>(null);
const triggers = useRef<Partial<Record<Id, HTMLButtonElement | null>>>({});
const inView = useInView(root);
const reduced = usePrefersReducedMotion();
const [open, setOpen] = useState<Id | null>(null);
const [closing, setClosing] = useState(false);
const [touched, setTouched] = useState(false);
const [status, setStatus] = useState("Draft");
const [note, setNote] = useState("Saved");
const focusOnOpen = useRef(false);
// Before paint: place the menu next to its trigger, flip it above when there is no room below,
// and set transform-origin to the trigger's own point so the scale grows out of it.
useLayoutEffect(() => {
const m = menu.current, c = card.current, t = open && triggers.current[open];
if (!m || !c || !t) return;
const cr = c.getBoundingClientRect(), tr = t.getBoundingClientRect();
const w = m.offsetWidth, h = m.offsetHeight;
const tx = tr.left - cr.left, ty = tr.top - cr.top;
const centre = tx + tr.width / 2;
const left = Math.min(Math.max(centre - w / 2, 8), cr.width - w - 8);
const below = ty + tr.height + GAP + h <= cr.height - 4;
const top = below ? ty + tr.height + GAP : ty - GAP - h;
m.style.left = `${left}px`;
m.style.top = `${top}px`;
m.style.transformOrigin = `${Math.min(Math.max(centre - left, 12), w - 12)}px ${below ? "0" : "100%"}`;
m.dataset.side = below ? "below" : "above";
if (focusOnOpen.current) m.querySelector<HTMLElement>("[role^=menuitem]")?.focus();
}, [open]);
const close = (refocus: boolean) => {
if (!open) return;
if (refocus) triggers.current[open]?.focus();
if (reduced) setOpen(null);
else setClosing(true);
};
const toggle = (id: Id, byUser: boolean) => {
if (byUser) setTouched(true);
focusOnOpen.current = byUser;
setClosing(false);
setOpen((o) => (o === id ? null : id));
};
// Outside click and Escape close the menu.
useEffect(() => {
if (!open) return;
const onDown = (e: PointerEvent) => {
const t = e.target as Node;
if (menu.current?.contains(t) || triggers.current[open]?.contains(t)) return;
close(false);
};
document.addEventListener("pointerdown", onDown);
return () => document.removeEventListener("pointerdown", onDown);
});
// Idle tour: open each menu in turn until the user touches anything. Reduced motion shows one open.
useEffect(() => {
if (touched) return;
if (reduced) { focusOnOpen.current = false; setClosing(false); setOpen("status"); return; }
if (!inView) return;
let i = 0;
const timer = setInterval(() => {
if (i % 2 === 0) { focusOnOpen.current = false; setClosing(false); setOpen(TOUR[(i / 2) % TOUR.length]); }
else setClosing(true);
i++;
}, 1100);
return () => clearInterval(timer);
}, [touched, inView, reduced]);
const choose = (item: string) => {
if (open === "status") setStatus(item);
else setNote(DONE[item]);
close(true);
};
const onMenuKey = (e: KeyboardEvent<HTMLDivElement>) => {
const items = [...(menu.current?.querySelectorAll<HTMLElement>("[role^=menuitem]") ?? [])];
const i = items.indexOf(document.activeElement as HTMLElement);
if (e.key === "Escape") { e.preventDefault(); close(true); }
else if (e.key === "ArrowDown") { e.preventDefault(); items[(i + 1) % items.length]?.focus(); }
else if (e.key === "ArrowUp") { e.preventDefault(); items[(i - 1 + items.length) % items.length]?.focus(); }
else if (e.key === "Home") { e.preventDefault(); items[0]?.focus(); }
else if (e.key === "End") { e.preventDefault(); items[items.length - 1]?.focus(); }
else if (e.key === "Tab") close(false);
};
const trigger = (id: Id, className: string, content: ReactNode, label?: string) => (
<button
ref={(el) => { triggers.current[id] = el; }}
type="button"
className={className}
aria-haspopup="menu"
aria-expanded={open === id && !closing}
aria-controls={open === id ? "oap-menu" : undefined}
aria-label={label}
onClick={() => toggle(id, true)}
onKeyDown={(e) => {
if (e.key === "ArrowDown" && open !== id) { e.preventDefault(); toggle(id, true); }
}}
>
{content}
</button>
);
return (
<div ref={root} className={styles.root} onPointerDown={() => setTouched(true)} onFocus={() => setTouched(true)}>
<div ref={card} className={styles.card}>
<div className={styles.toolbar}>
{trigger("file", styles.trigger, <>File<Chevron /></>)}
{trigger("share", `${styles.trigger} ${styles.share}`, <><svg viewBox="0 0 16 16" aria-hidden="true"><path d="M8 10V2.5M5 5.5l3-3 3 3M3.5 9v3.5h9V9" /></svg>Share</>)}
{trigger("account", `${styles.trigger} ${styles.avatar}`, <>JL</>, "Account")}
</div>
<div className={styles.page} aria-hidden="true">
<p className={styles.pageTitle}>Launch checklist</p>
<span /><span /><span />
<ul className={styles.tasks}>
<li data-done="">Write release notes</li>
<li>Record the demo video</li>
<li>Schedule the announcement</li>
</ul>
</div>
<div className={styles.footer}>
{trigger("status", styles.trigger, <><i className={styles.dot} data-status={status} />{status}<Chevron /></>, `Status: ${status}`)}
<span className={styles.note} role="status" key={note}>{note}</span>
</div>
{open && (
<div
ref={menu}
id="oap-menu"
role="menu"
aria-label={MENUS[open].label}
className={styles.menu}
data-closing={closing || undefined}
onKeyDown={onMenuKey}
onAnimationEnd={() => { if (closing) { setOpen(null); setClosing(false); } }}
>
{MENUS[open].items.map((item) =>
open === "status" ? (
<button key={item} type="button" role="menuitemradio" aria-checked={item === status} className={styles.item} onClick={() => choose(item)}>
<i className={styles.dot} data-status={item} />{item}
{item === status && <svg className={styles.check} viewBox="0 0 16 16" aria-hidden="true"><path d="M3.5 8.5l3 3 6-7" /></svg>}
</button>
) : (
<button key={item} type="button" role="menuitem" className={styles.item} data-danger={item === "Move to trash" || undefined} onClick={() => choose(item)}>
{item}
</button>
),
)}
</div>
)}
</div>
</div>
);
}
function Chevron() {
return <svg className={styles.chev} viewBox="0 0 16 16" aria-hidden="true"><path d="M4.5 6.5L8 10l3.5-3.5" /></svg>;
}
demo.module.css
.root {
container-type: size; display: grid; place-items: center; width: 100%; height: 100%; padding: 14px;
box-sizing: border-box; background: var(--surface);
}
/* A small editor window: the toolbar triggers sit left, centre and right, the status trigger sits at the bottom. */
.card {
position: relative; width: min(560px, 100%); height: min(100%, 380px); display: grid; grid-template-rows: auto minmax(0, 1fr) auto;
border-radius: 14px; border: 1px solid var(--line); background: var(--white);
box-shadow: 0 12px 30px -20px color-mix(in srgb, var(--ink) 45%, transparent);
}
.toolbar, .footer { display: flex; align-items: center; gap: 8px; padding: 6px 8px; }
.toolbar { border-bottom: 1px solid var(--line); }
.footer { border-top: 1px solid var(--line); }
.trigger {
display: inline-flex; align-items: center; gap: 6px; height: 36px; padding: 0 10px; border: 0; border-radius: 9px;
background: transparent; color: var(--ink); font: 600 13px system-ui, sans-serif; cursor: pointer;
transition: background-color .2s, transform .15s var(--out);
}
.trigger:hover, .trigger[aria-expanded="true"] { background: var(--surface); }
.trigger:active { transform: scale(.96); }
.trigger:focus-visible, .item:focus-visible { outline: 2px solid var(--accent-text); outline-offset: 2px; }
.trigger svg { width: 14px; height: 14px; fill: none; stroke: currentColor; stroke-width: 1.7; stroke-linecap: round; stroke-linejoin: round; }
.chev { color: var(--muted); transition: rotate .25s var(--out); }
.trigger[aria-expanded="true"] .chev { rotate: 180deg; }
.share { margin: 0 auto; background: var(--ink); color: var(--white); }
.share:hover, .share[aria-expanded="true"] { background: var(--ink); opacity: .88; }
.avatar { width: 36px; padding: 0; justify-content: center; border-radius: 50%; background: var(--accent-soft); font-size: 12px; }
.avatar:hover, .avatar[aria-expanded="true"] { background: var(--accent-soft); box-shadow: 0 0 0 3px var(--accent-pale); }
.page { display: grid; align-content: start; gap: 9px; padding: 16px 18px; overflow: hidden; }
.pageTitle { margin: 0 0 2px; font-size: clamp(15px, 4.5cqi, 20px); font-weight: 700; letter-spacing: -0.01em; }
.page span { height: 7px; border-radius: 4px; background: var(--line); }
.page span:nth-of-type(2) { width: 82%; }
.page span:nth-of-type(3) { width: 64%; }
.tasks { list-style: none; margin: 8px 0 0; padding: 0; display: grid; gap: 8px; font-size: 13px; color: var(--muted); }
.tasks li { display: flex; align-items: center; gap: 8px; }
.tasks li::before { content: ""; width: 12px; height: 12px; border-radius: 4px; border: 1.5px solid var(--line); }
.tasks li[data-done] { text-decoration: line-through; }
.tasks li[data-done]::before { border-color: var(--accent); background: var(--accent); }
.dot { width: 8px; height: 8px; border-radius: 50%; background: var(--muted); flex: none; }
.dot[data-status="In review"] { background: var(--sand); box-shadow: inset 0 0 0 2px var(--muted); }
.dot[data-status="Published"] { background: var(--accent); }
.note { margin-left: auto; padding-right: 6px; font-size: 12px; font-weight: 600; color: var(--muted); animation: note .4s var(--out); }
@keyframes note { from { opacity: 0; translate: 0 4px; } }
/* The menu: left, top and transform-origin are set in JS from the trigger's position before paint. */
.menu {
position: absolute; z-index: 2; width: min(190px, calc(100% - 16px)); display: grid; padding: 5px; box-sizing: border-box;
border-radius: 12px; border: 1px solid var(--line); background: var(--white);
box-shadow: 0 16px 36px -16px color-mix(in srgb, var(--ink) 50%, transparent);
animation: in .26s var(--out);
}
.menu[data-closing] { animation: out .16s ease-in forwards; }
@keyframes in { from { opacity: 0; scale: .6; } }
@keyframes out { to { opacity: 0; scale: .8; } }
.item {
display: flex; align-items: center; gap: 8px; min-height: 34px; padding: 0 10px; border: 0; border-radius: 8px;
background: transparent; color: var(--ink); font: 500 13px system-ui, sans-serif; text-align: left; cursor: pointer;
}
.item:hover, .item:focus-visible { background: var(--accent-pale); outline-offset: -2px; }
.item[data-danger] { color: var(--muted); }
.check { margin-left: auto; width: 14px; height: 14px; fill: none; stroke: var(--accent-text); stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
/* Short tiles: tighter page so the flipped status menu still fits above its trigger. */
@container (max-height: 360px) {
.tasks { display: none; }
}
@container (max-height: 300px) {
.root { padding: 10px; }
.page { padding: 12px 14px; gap: 7px; }
.item { min-height: 30px; }
}
@media (prefers-reduced-motion: reduce) {
.menu, .menu[data-closing], .note { animation: none; }
.chev { transition: none; }
}
Use it for
- Dropdowns and action menus in toolbars and headers
- Status pickers that sit near the bottom of a panel
- Any popover whose trigger can sit anywhere on screen
Avoid
- Large dialogs, which should come from the centre of the screen
- Menus that open on hover, where scaling in feels slow
How it works
When a menu opens, a layout effect measures the trigger and the menu before paint, centres the menu on the trigger, clamps it inside the container and places it below, or above when it would not fit. It then sets transform-origin to the trigger's centre on the menu's near edge, so a CSS scale and fade keyframe grows the menu out of the button that opened it. Closing plays a shorter scale down from the same origin and unmounts on animationend.
Accessibility
Triggers are buttons with aria-haspopup and aria-expanded; opening moves focus to the first item, arrow keys, Home and End move between items, and Escape or choosing an item returns focus to the trigger. Outside clicks close the menu. Reduced motion shows one menu already open and opens and closes instantly.