Anchored — the placement flips
Variant: Placement flip
One trigger, one surface, opened twice: below the row while there is room, and above it once the list has scrolled that row against the bottom edge. The boundary is a real clip rather than a drawn line, so the second placement is forced rather than asserted. What flips is the transform-origin and nothing else — the surface still grows out of its beak, so both openings point back at the same 18px button and neither reads as a different component arriving. Both cost 210ms in and 163ms out for the same reason. The beak is a child of the surface rather than a sibling parked beside it, which makes it impossible for the two to disagree on any frame, and its offset is not a tuned number: it is the row’s padding plus half the trigger, which is the trigger’s centre measured from the edge they are both aligned to. The popovers are children of the row itself, so a scroll cannot leave a surface stranded where the anchor used to be.
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 — the placement flips</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 — 6.8s. Both openings cost 210ms and both closings 163ms: the
flipped placement is a different position, not a different component,
and anything that made it feel slower would be saying otherwise. */
--cycle: 6.8s;
--t: calc(var(--cycle) / var(--speed));
--ease-out: cubic-bezier(0.23, 1, 0.32, 1);
--ease-in-out: cubic-bezier(0.77, 0, 0.175, 1);
/* The list's geometry. The scroll offset is the distance between the
anchor row's two resting places and nothing else, so moving a row
re-times nothing. */
--_pitch: 26px;
--_row: 21px;
--_scroll: 78px;
/* Where the beak sits inside the surface — and therefore where the
surface grows from, on both placements. It is not a tuned number: it
is the row's right padding plus half the trigger, which is the
trigger's centre measured from the edge both are aligned to. */
--_beak: 16px;
}
* {
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;
border-radius: calc(12px * var(--round));
background: var(--ivory);
}
/* The boundary is the whole subject, so it is a real clip and not a
drawn line: whatever will not fit inside this box genuinely does not
get shown. */
.stage {
position: relative;
width: 172px;
height: 144px;
border-radius: calc(10px * var(--round));
background: var(--paper);
border: 1px solid color-mix(in srgb, var(--slate) 11%, transparent);
overflow: hidden;
}
.list {
position: absolute;
left: 0;
right: 0;
top: 8px;
animation: scroll var(--t) infinite;
}
.row {
position: relative;
height: var(--_row);
margin-bottom: calc(var(--_pitch) - var(--_row));
display: flex;
align-items: center;
gap: 7px;
padding: 0 7px;
margin-left: 10px;
margin-right: 10px;
border-radius: calc(5px * var(--round));
background: color-mix(in srgb, var(--oat) 26%, var(--paper));
font-size: 11px;
line-height: 1;
letter-spacing: -0.005em;
}
.row .dot {
width: 5px;
height: 5px;
flex: none;
border-radius: 50%;
background: color-mix(in srgb, var(--slate) 22%, transparent);
}
.anchor {
background: color-mix(in srgb, var(--oat) 52%, var(--paper));
}
.trigger {
margin-left: auto;
width: 18px;
height: 15px;
display: grid;
place-items: center;
border-radius: calc(4px * var(--round));
color: color-mix(in srgb, var(--slate) 55%, transparent);
background: transparent;
animation: trigger var(--t) infinite;
}
.trigger svg {
width: 12px;
height: 12px;
display: block;
}
/* The popover belongs to its row, but the rows after it are positioned
siblings and would otherwise paint over it: a later sibling wins on
equal footing, and being nested inside an earlier one does not help. */
.pop {
position: absolute;
z-index: 2;
right: 0;
width: 116px;
height: 60px;
padding: 9px;
display: grid;
align-content: start;
gap: 6px;
border-radius: calc(8px * var(--round));
background: var(--ivory);
border: 1px solid color-mix(in srgb, var(--slate) 12%, transparent);
box-shadow: 0 8px 20px color-mix(in srgb, var(--slate) 15%, transparent);
opacity: 0;
}
/* The beak is a child of the surface rather than a sibling parked beside
it, which is what makes it impossible for the two to disagree: it
scales, moves and fades on exactly the frames the surface does, so it
is still touching the trigger on every one of them. */
.pop::after {
content: '';
position: absolute;
right: var(--_beak);
width: 7px;
height: 7px;
margin-right: -3.5px;
background: var(--ivory);
border: 1px solid color-mix(in srgb, var(--slate) 12%, transparent);
transform: rotate(45deg);
}
/* Below the trigger: the surface hangs off its own top edge, at the beak. */
.pop.down {
top: calc(100% + 7px);
transform-origin: calc(100% - var(--_beak)) 0;
transform: translateY(-4px) scale(0.94);
animation: pop-down var(--t) infinite;
}
.pop.down::after {
top: 0;
margin-top: -4px;
border-right: 0;
border-bottom: 0;
}
/* Above it: the same surface standing on its own bottom edge. The origin
is the only thing that changed, and it is the only thing that had to. */
.pop.up {
bottom: calc(100% + 7px);
transform-origin: calc(100% - var(--_beak)) 100%;
transform: translateY(4px) scale(0.94);
animation: pop-up var(--t) infinite;
}
.pop.up::after {
bottom: 0;
margin-bottom: -4px;
border-left: 0;
border-top: 0;
}
.pop .title {
font-size: 11px;
font-weight: 600;
line-height: 1;
letter-spacing: -0.01em;
}
.pop .line {
height: 6px;
border-radius: 3px;
background: color-mix(in srgb, var(--slate) 14%, transparent);
}
.pop .line.key {
background: var(--clay);
}
/* The list is a transform rather than a scroll position, so the popovers
ride it for free: they are children of the row they belong to, which is
the only build where a scroll cannot leave a surface behind. */
@keyframes scroll {
0%,
37% {
transform: translateY(calc(-1 * var(--_scroll)));
animation-timing-function: var(--ease-in-out);
}
42%,
80% {
transform: translateY(0);
animation-timing-function: var(--ease-in-out);
}
85%,
100% {
transform: translateY(calc(-1 * var(--_scroll)));
}
}
@keyframes pop-down {
0%,
6.5% {
opacity: 0;
transform: translateY(-4px) scale(0.94);
animation-timing-function: var(--ease-out);
}
9.6%,
32% {
opacity: 1;
transform: translateY(0) scale(1);
animation-timing-function: ease;
}
34.4%,
100% {
opacity: 0;
transform: translateY(-3px) scale(0.96);
}
}
@keyframes pop-up {
0%,
46% {
opacity: 0;
transform: translateY(4px) scale(0.94);
animation-timing-function: var(--ease-out);
}
49.1%,
74% {
opacity: 1;
transform: translateY(0) scale(1);
animation-timing-function: ease;
}
76.4%,
100% {
opacity: 0;
transform: translateY(3px) scale(0.96);
}
}
@keyframes trigger {
0%,
5% {
background: transparent;
animation-timing-function: ease;
}
6.5%,
33% {
background: color-mix(in srgb, var(--clay) 18%, transparent);
animation-timing-function: ease;
}
35.5%,
44.5% {
background: transparent;
animation-timing-function: ease;
}
46%,
75% {
background: color-mix(in srgb, var(--clay) 18%, transparent);
animation-timing-function: ease;
}
77.5%,
100% {
background: transparent;
}
}
@media (prefers-reduced-motion: reduce) {
/* Scale and travel go; the flip stays, because the flip is not motion.
It is a placement decision, and a popover drawn over the boundary it
was supposed to respect is wrong in a still frame too. The scroll
becomes a cut for the same reason it does everywhere else in this
library: a list moving by itself is the one thing reduced motion is
actually being asked about. */
.pop.down {
animation-name: pop-down-rm;
}
.pop.up {
animation-name: pop-up-rm;
}
.list {
animation-name: scroll-step;
}
.list,
.pop,
.trigger {
animation-duration: calc(var(--t) * 1.6);
}
@keyframes scroll-step {
0%,
37% {
transform: translateY(calc(-1 * var(--_scroll)));
animation-timing-function: steps(1, end);
}
42%,
80% {
transform: translateY(0);
animation-timing-function: steps(1, end);
}
85%,
100% {
transform: translateY(calc(-1 * var(--_scroll)));
}
}
@keyframes pop-down-rm {
0%,
6.5% {
opacity: 0;
animation-timing-function: ease;
}
9.6%,
32% {
opacity: 1;
animation-timing-function: ease;
}
34.4%,
100% {
opacity: 0;
}
}
@keyframes pop-up-rm {
0%,
46% {
opacity: 0;
animation-timing-function: ease;
}
49.1%,
74% {
opacity: 1;
animation-timing-function: ease;
}
76.4%,
100% {
opacity: 0;
}
}
}
</style>
</head>
<body>
<div
class="frame"
role="img"
aria-label="One trigger opening the same popover twice: below it when there is room, and flipped above it once the list has scrolled the row against the bottom edge"
>
<div class="stage">
<div class="list">
<div class="row"><span class="dot"></span>Sessions</div>
<div class="row"><span class="dot"></span>Referrers</div>
<div class="row"><span class="dot"></span>Devices</div>
<div class="row"><span class="dot"></span>Countries</div>
<div class="row anchor">
<span class="dot"></span>Retention
<span class="trigger">
<svg viewBox="0 0 24 24" aria-hidden="true" fill="currentColor">
<circle cx="5" cy="12" r="2.1" />
<circle cx="12" cy="12" r="2.1" />
<circle cx="19" cy="12" r="2.1" />
</svg>
</span>
<div class="pop down">
<span class="title">Retention</span>
<span class="line key" style="width: 62%"></span>
<span class="line" style="width: 84%"></span>
</div>
<div class="pop up">
<span class="title">Retention</span>
<span class="line key" style="width: 62%"></span>
<span class="line" style="width: 84%"></span>
</div>
</div>
<div class="row"><span class="dot"></span>Funnels</div>
<div class="row"><span class="dot"></span>Cohorts</div>
<div class="row"><span class="dot"></span>Exports</div>
</div>
</div>
</div>
</body>
</html>
The variant family
Same idea, one axis moved. Compare them side by side before you commit.