Anchored — at the pointer
Variant: At the pointer
The case where the anchor is not an element at all: a context menu owns the point that was clicked, so its origin is a coordinate rather than a corner. It opens twice in two different places, because one opening proves nothing — a surface that always grows from the same spot is a decoration, and the only way to show the origin is a property of the event is to move the event. Then the nested half, which is where most menu trees fail: the submenu hinges on its parent row’s vertical centre, not on the parent surface’s corner, or it appears to come from the one place it did not come from. It closes 46ms before its parent rather than with it, since a tree has to come apart leaf first — close both on the same frame and the child is briefly a surface hanging off nothing. This one scales as a single layer where the dropdown in this family does not: at 92px wide and 0.9 the type moves under a pixel, and the contents fade in behind the surface so the worst frames are the faintest.
The source
A complete HTML document. Paste it into an empty .html file and it plays — no stylesheet, script, font, or image to fetch.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Anchored — at the pointer</title>
<style>
:root {
/* Neutrals — fixed, never remixed. */
--ivory: #faf9f5;
--paper: #ffffff;
--oat: #e3dacc;
--slate: #141413;
/* Knobs — the remix API. A knob exists iff declared here and used below. */
--hue: 38.8;
--lift: 0;
--chroma: 1;
--round: 1;
--speed: 1;
/* Accents — derived from the knobs with the family's offsets baked in,
so one hue roll moves them together. Defaults reproduce the original hex. */
--clay: oklch(calc(0.6724 + var(--lift)) calc(0.1308 * var(--chroma)) var(--hue));
--clay-d: oklch(calc(0.5797 + var(--lift)) calc(0.127 * var(--chroma)) var(--hue));
--olive: oklch(
calc(0.6118 + var(--lift)) calc(0.0713 * var(--chroma)) calc(var(--hue) + 88.3)
);
--rust: oklch(
calc(0.5408 + var(--lift)) calc(0.1357 * var(--chroma)) calc(var(--hue) - 10.3)
);
/* Clock — 7.6s. The menu costs 160ms and the submenu 152ms: a nested
surface is not a second decision, so it must not read as a second
wait. */
--cycle: 7.6s;
--t: calc(var(--cycle) / var(--speed));
--ease-out: cubic-bezier(0.23, 1, 0.32, 1);
/* The submenu's origin: the vertical centre of the row it hangs off,
measured down from the submenu's own top edge. */
--_hinge: 15px;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
min-height: 100vh;
display: grid;
place-items: center;
background: var(--ivory);
font-family:
system-ui,
-apple-system,
'Segoe UI',
Roboto,
sans-serif;
color: var(--slate);
}
.frame {
position: relative;
width: clamp(224px, 46vw, 330px);
height: min(66vh, 72vw, 224px);
display: grid;
place-items: center;
background: var(--ivory);
}
.canvas {
position: relative;
width: 204px;
height: 140px;
border-radius: calc(10px * var(--round));
background: var(--paper);
border: 1px solid color-mix(in srgb, var(--slate) 11%, transparent);
overflow: hidden;
}
.tile {
position: absolute;
width: 60px;
height: 34px;
border-radius: calc(6px * var(--round));
background: color-mix(in srgb, var(--oat) 34%, var(--paper));
border: 1px solid color-mix(in srgb, var(--slate) 8%, transparent);
}
/* The two contact points. One ripple proves nothing — a wash that always
starts in the same place is a decoration — so the event has to move for
the origin to be legible as a property of it. */
.tap {
position: absolute;
width: 18px;
height: 18px;
margin: -9px 0 0 -9px;
border-radius: 50%;
border: 2px solid var(--clay);
opacity: 0;
transform: scale(0.35);
}
.tap-a {
left: 96px;
top: 20px;
animation: tap-a var(--t) infinite;
}
.tap-b {
left: 22px;
top: 40px;
animation: tap-b var(--t) infinite;
}
.menu {
position: absolute;
width: 92px;
padding: 5px 0;
border-radius: calc(8px * var(--round));
background: var(--ivory);
border: 1px solid color-mix(in srgb, var(--slate) 12%, transparent);
box-shadow: 0 10px 22px color-mix(in srgb, var(--slate) 16%, transparent);
/* The click point, and nothing about the element's own geometry. */
transform-origin: 0 0;
transform: scale(0.9);
opacity: 0;
}
.menu-a {
left: 96px;
top: 20px;
animation: menu-a var(--t) infinite;
}
.menu-b {
left: 22px;
top: 40px;
animation: menu-b var(--t) infinite;
}
.item {
position: relative;
display: flex;
align-items: center;
height: 20px;
padding: 0 9px;
font-size: 11px;
line-height: 1;
letter-spacing: -0.005em;
}
.item.danger {
color: var(--rust);
}
.item .caret {
margin-left: auto;
width: 9px;
height: 9px;
color: color-mix(in srgb, var(--slate) 45%, transparent);
}
.item .caret svg {
width: 100%;
height: 100%;
display: block;
}
/* The surface scales as one piece here, unlike the dropdown in this family
— a 92px menu at 0.9 distorts its type by under a pixel, and the fade
below hides the frames where it is worst. */
.items {
opacity: 0;
}
.items-a {
animation: items-a var(--t) infinite;
}
.items-b {
animation: items-b var(--t) infinite;
}
.hot {
animation: hot var(--t) infinite;
}
/* Nested anchoring: the child's origin is its parent row's edge, not the
parent surface's corner. Get this wrong and a submenu appears to come
from the top of the menu, which is the one place it did not come from. */
.sub {
position: absolute;
left: 117px;
top: 60px;
width: 74px;
padding: 5px 0;
border-radius: calc(8px * var(--round));
background: var(--ivory);
border: 1px solid color-mix(in srgb, var(--slate) 12%, transparent);
box-shadow: 0 10px 22px color-mix(in srgb, var(--slate) 16%, transparent);
transform-origin: 0 var(--_hinge);
transform: translateX(-4px) scale(0.94);
opacity: 0;
animation: sub var(--t) infinite;
}
.hand {
position: absolute;
left: 0;
top: 0;
width: 15px;
height: 15px;
animation: hand var(--t) infinite;
}
.hand svg {
width: 100%;
height: 100%;
display: block;
filter: drop-shadow(0 1px 2px color-mix(in srgb, var(--slate) 30%, transparent));
}
/* var() does not resolve inside an SVG presentation attribute, so the
pointer's fill is set from CSS to stay inside the token API. */
.hand path {
fill: var(--paper);
}
.arrow {
position: absolute;
inset: 0;
color: var(--slate);
opacity: 0;
animation: arrow var(--t) infinite;
}
@keyframes hand {
0%,
3% {
transform: translate(-24px, 132px);
animation-timing-function: var(--ease-out);
}
8.2%,
29.6% {
transform: translate(94px, 18px);
animation-timing-function: var(--ease-out);
}
35.2%,
45% {
transform: translate(20px, 38px);
animation-timing-function: var(--ease-out);
}
/* Down the menu to the row that has a child. */
47.6%,
79% {
transform: translate(58px, 72px);
animation-timing-function: var(--ease-out);
}
84%,
100% {
transform: translate(218px, 154px);
}
}
@keyframes arrow {
0%,
1.6% {
opacity: 0;
animation-timing-function: ease;
}
3%,
81% {
opacity: 1;
animation-timing-function: ease;
}
84.5%,
100% {
opacity: 0;
}
}
@keyframes tap-a {
0%,
8.2% {
opacity: 0;
transform: scale(0.35);
animation-timing-function: var(--ease-out);
}
8.6% {
opacity: 0.65;
transform: scale(0.55);
animation-timing-function: var(--ease-out);
}
10.9%,
100% {
opacity: 0;
transform: scale(1.5);
}
}
@keyframes tap-b {
0%,
35.2% {
opacity: 0;
transform: scale(0.35);
animation-timing-function: var(--ease-out);
}
35.6% {
opacity: 0.65;
transform: scale(0.55);
animation-timing-function: var(--ease-out);
}
37.9%,
100% {
opacity: 0;
transform: scale(1.5);
}
}
@keyframes menu-a {
0%,
8.6% {
opacity: 0;
transform: scale(0.9);
animation-timing-function: var(--ease-out);
}
10.7%,
28% {
opacity: 1;
transform: scale(1);
animation-timing-function: ease;
}
29.6%,
100% {
opacity: 0;
transform: scale(0.95);
}
}
@keyframes menu-b {
0%,
35.6% {
opacity: 0;
transform: scale(0.9);
animation-timing-function: var(--ease-out);
}
37.7%,
76.9% {
opacity: 1;
transform: scale(1);
animation-timing-function: ease;
}
78.5%,
100% {
opacity: 0;
transform: scale(0.95);
}
}
@keyframes items-a {
0%,
9.3% {
opacity: 0;
animation-timing-function: ease;
}
11.4%,
27.6% {
opacity: 1;
animation-timing-function: ease;
}
28.8%,
100% {
opacity: 0;
}
}
@keyframes items-b {
0%,
36.3% {
opacity: 0;
animation-timing-function: ease;
}
38.4%,
76.5% {
opacity: 1;
animation-timing-function: ease;
}
77.7%,
100% {
opacity: 0;
}
}
@keyframes hot {
0%,
46.6% {
background: transparent;
animation-timing-function: ease;
}
47.6%,
76% {
background: color-mix(in srgb, var(--clay) 16%, transparent);
animation-timing-function: ease;
}
77%,
100% {
background: transparent;
}
}
/* The submenu opens 152ms after the row lights and closes 46ms before its
parent does. A menu tree has to come apart leaf first: close the parent
on the same frame and the child is briefly a surface hanging off
nothing, which is the frame everyone screenshots. */
@keyframes sub {
0%,
49.6% {
opacity: 0;
transform: translateX(-4px) scale(0.94);
animation-timing-function: var(--ease-out);
}
51.6%,
76% {
opacity: 1;
transform: translateX(0) scale(1);
animation-timing-function: ease;
}
77.3%,
100% {
opacity: 0;
transform: translateX(-2px) scale(0.97);
}
}
@media (prefers-reduced-motion: reduce) {
/* The scale goes, so the origin goes with it and the piece loses its
subject entirely — which is why the contact rings stay. They are the
only thing left that says the surface belongs to a point rather than
to the canvas, and a ring expanding in place is not travel. */
.menu-a {
animation-name: menu-a-rm;
}
.menu-b {
animation-name: menu-b-rm;
}
.sub {
animation-name: sub-rm;
}
.hand {
animation-name: hand-step;
}
.hand,
.arrow,
.menu,
.items,
.sub,
.hot,
.tap {
animation-duration: calc(var(--t) * 1.55);
}
@keyframes hand-step {
0%,
3% {
transform: translate(-24px, 132px);
animation-timing-function: steps(1, end);
}
8.2%,
29.6% {
transform: translate(94px, 18px);
animation-timing-function: steps(1, end);
}
35.2%,
45% {
transform: translate(20px, 38px);
animation-timing-function: steps(1, end);
}
47.6%,
79% {
transform: translate(58px, 72px);
animation-timing-function: steps(1, end);
}
84%,
100% {
transform: translate(218px, 154px);
}
}
@keyframes menu-a-rm {
0%,
8.6% {
opacity: 0;
animation-timing-function: ease;
}
10.7%,
28% {
opacity: 1;
animation-timing-function: ease;
}
29.6%,
100% {
opacity: 0;
}
}
@keyframes menu-b-rm {
0%,
35.6% {
opacity: 0;
animation-timing-function: ease;
}
37.7%,
76.9% {
opacity: 1;
animation-timing-function: ease;
}
78.5%,
100% {
opacity: 0;
}
}
@keyframes sub-rm {
0%,
49.6% {
opacity: 0;
animation-timing-function: ease;
}
51.6%,
76% {
opacity: 1;
animation-timing-function: ease;
}
77.3%,
100% {
opacity: 0;
}
}
}
</style>
</head>
<body>
<div
class="frame"
role="img"
aria-label="A context menu opened twice in two different places on a canvas, each time growing out of the exact point that was clicked, with a submenu on the second opening sideways out of its parent row"
>
<div class="canvas">
<div class="tile" style="left: 14px; top: 12px"></div>
<div class="tile" style="left: 84px; top: 12px"></div>
<div class="tile" style="left: 14px; top: 58px"></div>
<div class="menu menu-a">
<div class="items items-a">
<div class="item">Rename</div>
<div class="item">
Move to
<span class="caret">
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="m9 5 7 7-7 7"
stroke="currentColor"
stroke-width="2.6"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</span>
</div>
<div class="item">Duplicate</div>
<div class="item danger">Delete</div>
</div>
</div>
<div class="menu menu-b">
<div class="items items-b">
<div class="item">Rename</div>
<div class="item hot">
Move to
<span class="caret">
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="m9 5 7 7-7 7"
stroke="currentColor"
stroke-width="2.6"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</span>
</div>
<div class="item">Duplicate</div>
<div class="item danger">Delete</div>
</div>
</div>
<div class="sub">
<div class="item">Inbox</div>
<div class="item">Projects</div>
<div class="item">Archive</div>
</div>
<span class="tap tap-a"></span>
<span class="tap tap-b"></span>
<span class="hand">
<span class="arrow">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path
d="M5 2.5 19.5 12 13 13.2l3 6.4-2.7 1.3-3-6.4L5 19z"
stroke="currentColor"
stroke-width="1.7"
stroke-linejoin="round"
/>
</svg>
</span>
</span>
</div>
</div>
</body>
</html>
The variant family
Same idea, one axis moved. Compare them side by side before you commit.