Popover Anchor Flip
A popover opens below its trigger, or flips above when space runs out — the arrow follows.
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, useId, useRef, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
/**
* Vibary · Popover Anchor Flip
*
* A row menu opens below the control that summoned it — unless the frame
* runs out of room, in which case it flips above and the arrow moves with
* it. The panel always grows out of the corner nearest its trigger, so
* the eye never loses the thread back to what was clicked.
*
* Self-contained: depends only on `motion` (react ships with your app).
* Surfaces are mixed from the inherited text color and the floating panel
* uses the CSS system colors, so both read correctly on a light page and
* on a dark one.
* Works with zero props; tune via `variant`, `preferred`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type PopoverAnchorFlipProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Side the popover uses when there is room for it. */
preferred?: "below" | "above";
/** Notified with the side the popover actually landed on. */
onPlacement?: (placement: "below" | "above") => void;
};
type VariantConfig = {
spring: { type: "spring"; stiffness: number; damping: number };
/** px the panel travels out of its anchor edge. */
travel: number;
/** How much of full size the panel starts at. */
scaleFrom: number;
fadeSeconds: number;
};
// Quality rule: a popover lands under the pointer and is read
// immediately, so it may not still be moving when the eye arrives. Every
// spring sits at or above a 0.8 damping ratio (ζ = damping / 2√stiffness)
// and the scale never dips far enough to make the labels look resized.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// ζ ≈ 1.00 — arrives flat, and barely moves to get there. For dense
// tables where this opens all day.
subtle: {
spring: { type: "spring", stiffness: 700, damping: 53 },
travel: 2,
scaleFrom: 0.99,
fadeSeconds: 0.09,
},
// ζ ≈ 0.93 — a single soft settle. The all-purpose setting.
default: {
spring: { type: "spring", stiffness: 420, damping: 38 },
travel: 7,
scaleFrom: 0.97,
fadeSeconds: 0.14,
},
// ζ ≈ 0.90 — well clear of the anchor and a longer glide into place.
// Still one settle; the extra energy is all travel and time.
playful: {
spring: { type: "spring", stiffness: 240, damping: 28 },
travel: 16,
scaleFrom: 0.92,
fadeSeconds: 0.24,
},
};
const ACCENT = "#7C7CF0";
/** 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)`;
/** Space the panel needs on a side before it will commit to it. */
const PANEL_HEIGHT = 126;
const GAP = 8;
const DOCUMENTS = [
{ id: "roadmap", name: "Q3 roadmap", meta: "Edited 2h ago · Priya" },
{ id: "brand", name: "Brand guidelines", meta: "Edited yesterday · Marco" },
{ id: "release", name: "Release notes v4.2", meta: "Edited Monday · Dana" },
{ id: "pricing", name: "Pricing experiments", meta: "Edited Aug 4 · Aiko" },
] as const;
const ACTIONS = [
{
label: "Rename",
path: "M3.5 12.8 12 4.3l3.2 3.2-8.5 8.5H3.5z",
},
{
label: "Duplicate",
path: "M7 3.5h9.5v9.5M3.5 7h9.5v9.5H3.5z",
},
{
label: "Move to archive",
path: "M3.2 5.5h13.6v3H3.2zM4.6 8.5h10.8v7H4.6zM8 11.5h4",
},
] as const;
export default function PopoverAnchorFlip({
variant = "default",
preferred = "below",
onPlacement,
}: PopoverAnchorFlipProps) {
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const uid = useId();
const frameRef = useRef<HTMLDivElement>(null);
const [open, setOpen] = useState<{
id: string;
placement: "below" | "above";
} | null>(null);
useEffect(() => {
if (!open) return;
const onKey = (event: KeyboardEvent) => {
if (event.key === "Escape") setOpen(null);
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open]);
const toggle = (id: string, trigger: HTMLElement) => {
if (open?.id === id) {
setOpen(null);
return;
}
// The flip decision, and the whole pattern: measure the room left on
// the preferred side, and only cross over when the other side is
// genuinely roomier. Measured against the frame below because this
// popover is scoped to a card; for an app-level popover compare
// against `window.innerHeight` (and render into a portal) instead.
let placement = preferred;
const frame = frameRef.current?.getBoundingClientRect();
if (frame) {
const anchor = trigger.getBoundingClientRect();
const roomBelow = frame.bottom - anchor.bottom;
const roomAbove = anchor.top - frame.top;
const need = PANEL_HEIGHT + GAP;
if (preferred === "below" && roomBelow < need && roomAbove > roomBelow) {
placement = "above";
} else if (preferred === "above" && roomAbove < need && roomBelow > roomAbove) {
placement = "below";
}
}
setOpen({ id, placement });
onPlacement?.(placement);
};
return (
<div
ref={frameRef}
style={{
position: "relative",
width: 320,
height: 292,
padding: "12px 10px",
borderRadius: 18,
background: tone(6),
color: "inherit",
border: `1px solid ${tone(12)}`,
boxShadow: "0 14px 36px rgba(0,0,0,0.16)",
}}
>
<div
style={{
display: "flex",
alignItems: "baseline",
justifyContent: "space-between",
padding: "0 6px 8px",
}}
>
<span style={{ fontSize: 13, fontWeight: 650 }}>Documents</span>
<span style={{ fontSize: 11, opacity: 0.45 }}>{DOCUMENTS.length} files</span>
</div>
{/* Click-away layer: mounted only while something is open, so the
rows stay clickable the rest of the time. */}
{open && (
<div
aria-hidden
onClick={() => setOpen(null)}
style={{ position: "absolute", inset: 0, zIndex: 1 }}
/>
)}
{DOCUMENTS.map((doc) => {
const isOpen = open?.id === doc.id;
const placement = open?.placement ?? preferred;
const above = isOpen && placement === "above";
return (
<div
key={doc.id}
style={{
position: "relative",
// The open row rides above the click-away layer so its
// trigger and panel stay live.
zIndex: isOpen ? 2 : 0,
display: "flex",
alignItems: "center",
gap: 10,
padding: "9px 6px 9px 8px",
borderRadius: 12,
background: isOpen ? tone(8) : "transparent",
}}
>
<span
aria-hidden
style={{
flexShrink: 0,
width: 30,
height: 30,
borderRadius: 9,
// Stands in for a file thumbnail, not a UI surface, so it
// keeps its own colors in both themes.
background: "linear-gradient(140deg, #5C6BC8 0%, #6FA6B8 100%)",
}}
/>
<span style={{ flex: 1, minWidth: 0 }}>
<span
style={{
display: "block",
fontSize: 12.5,
fontWeight: 600,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{doc.name}
</span>
<span style={{ display: "block", fontSize: 11, opacity: 0.5 }}>
{doc.meta}
</span>
</span>
<span style={{ position: "relative", flexShrink: 0 }}>
<button
type="button"
aria-haspopup="menu"
aria-expanded={isOpen}
aria-label={`Actions for ${doc.name}`}
onClick={(event) => toggle(doc.id, event.currentTarget)}
style={{
display: "grid",
placeItems: "center",
width: 26,
height: 26,
borderRadius: 8,
border: 0,
background: isOpen ? tone(14) : "transparent",
color: "inherit",
cursor: "pointer",
}}
>
<svg width="15" height="15" viewBox="0 0 20 20" fill="currentColor" aria-hidden>
<circle cx="10" cy="4.5" r="1.5" />
<circle cx="10" cy="10" r="1.5" />
<circle cx="10" cy="15.5" r="1.5" />
</svg>
</button>
<AnimatePresence>
{isOpen && (
<motion.div
key="panel"
role="menu"
aria-label={`Actions for ${doc.name}`}
id={`${uid}-menu`}
// Reduced motion: the panel is placed rather than
// grown — the flip still happens, it just doesn't
// travel to announce itself.
initial={
reduceMotion
? { opacity: 0 }
: {
opacity: 0,
scale: cfg.scaleFrom,
y: above ? cfg.travel : -cfg.travel,
}
}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={
reduceMotion
? { opacity: 0, transition: { duration: 0.1 } }
: {
opacity: 0,
scale: cfg.scaleFrom,
y: above ? cfg.travel * 0.5 : -cfg.travel * 0.5,
transition: { duration: 0.12, ease: "easeIn" },
}
}
transition={{
default: cfg.spring,
opacity: { duration: cfg.fadeSeconds, ease: "easeOut" },
}}
style={{
position: "absolute",
zIndex: 3,
right: -2,
top: above ? "auto" : `calc(100% + ${GAP}px)`,
bottom: above ? `calc(100% + ${GAP}px)` : "auto",
// Growing out of the corner nearest the trigger is
// what ties the panel to the control that opened it
// — and it is the half of the flip people notice
// without being able to name.
transformOrigin: above ? "bottom right" : "top right",
width: 176,
padding: 5,
borderRadius: 13,
// A popover floats over the content it acts on, so
// it needs opaque ground rather than a tinted one.
// `Canvas`/`CanvasText` are the CSS system colors
// for page background and page text: they follow
// the host app's light or dark surface with no
// configuration, and every tone() inside the panel
// is then mixed from CanvasText.
background: "Canvas",
color: "CanvasText",
border: `1px solid ${tone(14)}`,
boxShadow: "0 16px 38px rgba(0,0,0,0.24)",
}}
>
{/* The arrow is part of the panel, so it flips with
it for free. Drawn as an open path: the fill
closes the triangle while the stroke covers only
the two slanted edges, hiding the seam where it
meets the panel border. */}
<svg
width="16"
height="8"
viewBox="0 0 16 8"
aria-hidden
style={{
position: "absolute",
right: 12,
top: above ? "auto" : -7,
bottom: above ? -7 : "auto",
transform: above ? "rotate(180deg)" : "none",
}}
>
<path
d="M0 7.6 8 1 16 7.6"
fill="Canvas"
stroke={tone(14)}
strokeWidth="1"
strokeLinejoin="round"
/>
</svg>
{ACTIONS.map((action) => (
<button
key={action.label}
type="button"
role="menuitem"
onClick={() => setOpen(null)}
style={{
display: "flex",
alignItems: "center",
gap: 9,
width: "100%",
padding: "8px 9px",
borderRadius: 9,
border: 0,
background: "transparent",
color: "inherit",
fontSize: 12.5,
fontWeight: 550,
fontFamily: "inherit",
textAlign: "left",
cursor: "pointer",
}}
>
<svg
width="15"
height="15"
viewBox="0 0 20 20"
fill="none"
stroke={ACCENT}
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
<path d={action.path} />
</svg>
{action.label}
</button>
))}
</motion.div>
)}
</AnimatePresence>
</span>
</div>
);
})}
</div>
);
}About this pattern
Row actions, overflow menus and any panel that belongs to one control. The motion is two decisions made together: measure the room left below the trigger and cross to the other side only when it is genuinely roomier, then grow the panel out of the corner nearest that trigger so the tie back to what was clicked is never broken. The arrow is drawn inside the panel, so it flips with it rather than being repositioned separately. Springs stay near critical damping because a menu is read the instant it lands, and the scale starts high enough that the labels never look resized on the way in.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Sidebar navigation
A menu grows from the corner nearest its button and changes side close to a screen edge.
Related patterns
- Dropdown Menu OpenA menu unfolds from the corner of the control that opened it, with its items arriving a frame apart.
- Anchor Scroll HighlightJumping to a section briefly washes its background so the eye lands in the right place.
- Gallery Lightbox OpenThe pressed thumbnail flies out of the grid into the large view while the backdrop dims behind it.