Anchored — grown from the trigger
Variant: From the trigger
The base case for every surface that has a parent on screen, and the exact opposite of the dialog in the overlay family: a dialog is anchored to nothing and scales from its own centre, while a menu belongs to a button and has to come out of it. The whole build is that the surface and its rows are separate layers — only the surface scales, which lets it open faster on Y than on X, the way a menu actually unfurls, without shearing a single glyph. Scale the menu as one element and the type stretches on the way in, and that is the tell separating a zoom from an opening. Two smaller things carry the rest: the rows come down from above rather than up from below, because contents travelling against their own container read as two animations sharing a box, and the trigger stays visibly held for as long as the surface is up, since a button that pops back to rest has orphaned the thing it opened.
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 — grown from the trigger</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 — 5.2s. The surface opens in 166ms and closes in 125ms: a
dropdown sits in the 150-250ms band, and leaving is always cheaper
than arriving. */
--cycle: 5.2s;
--t: calc(var(--cycle) / var(--speed));
--ease-out: cubic-bezier(0.23, 1, 0.32, 1);
}
* {
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);
border-radius: calc(12px * var(--round));
background: var(--paper);
border: 1px solid color-mix(in srgb, var(--slate) 10%, transparent);
overflow: hidden;
}
.toolbar {
position: absolute;
left: 0;
right: 0;
top: 0;
height: 32px;
display: flex;
align-items: center;
gap: 8px;
padding: 0 10px;
border-bottom: 1px solid color-mix(in srgb, var(--slate) 8%, transparent);
}
.ghost {
height: 7px;
border-radius: 4px;
background: var(--oat);
}
/* The trigger stays visibly held for as long as the menu is up: the button
is still the parent of what is on screen, and a trigger that pops back
to rest orphans the surface it opened. */
.trigger {
display: flex;
align-items: center;
gap: 5px;
height: 20px;
padding: 0 7px;
border-radius: calc(6px * var(--round));
font-size: 11px;
font-weight: 550;
line-height: 1;
letter-spacing: -0.01em;
background: color-mix(in srgb, var(--slate) 6%, transparent);
animation: trigger var(--t) infinite;
}
.chev {
width: 9px;
height: 9px;
display: block;
color: color-mix(in srgb, var(--slate) 50%, transparent);
animation: chev var(--t) infinite;
}
.chev svg {
width: 100%;
height: 100%;
display: block;
}
.page {
position: absolute;
inset: 32px 0 0;
padding: 14px;
display: grid;
gap: 9px;
align-content: start;
}
.page .row {
height: 8px;
border-radius: 4px;
background: color-mix(in srgb, var(--oat) 62%, var(--paper));
}
/* The menu is aligned to the trigger's left edge, which is also where it
is going to grow from. */
.menu {
position: absolute;
left: 10px;
top: 37px;
width: 112px;
}
/* The surface and the contents are separate layers, and that is the whole
build. Only this one scales — so it can open faster on Y than on X, the
way a menu actually unfurls, without shearing a single glyph. Scale the
menu as one element and the type stretches on the way in, which is the
tell that separates a zoom from an opening. */
.surface {
position: absolute;
inset: 0;
border-radius: calc(9px * var(--round));
background: var(--ivory);
border: 1px solid color-mix(in srgb, var(--slate) 11%, transparent);
box-shadow: 0 10px 24px color-mix(in srgb, var(--slate) 14%, transparent);
transform-origin: top left;
transform: scale(0.94, 0.76);
opacity: 0;
animation: surface var(--t) infinite;
}
.items {
position: relative;
padding: 5px 0;
display: grid;
}
.item {
display: flex;
align-items: center;
gap: 6px;
height: 21px;
padding: 0 9px;
font-size: 11px;
line-height: 1;
letter-spacing: -0.005em;
opacity: 0;
animation: item var(--t) infinite;
animation-delay: calc(var(--_i) * 26ms / var(--speed));
}
.item .tick {
width: 5px;
height: 5px;
flex: none;
border-radius: 50%;
background: transparent;
}
.item.on .tick {
background: var(--clay);
}
.item.on {
font-weight: 550;
}
@keyframes surface {
0%,
11% {
opacity: 0;
transform: scale(0.94, 0.76);
animation-timing-function: var(--ease-out);
}
14.2%,
60% {
opacity: 1;
transform: scale(1, 1);
animation-timing-function: ease;
}
62.4%,
100% {
opacity: 0;
transform: scale(0.97, 0.88);
}
}
/* Rows come down from above rather than up from below, because the surface
beneath them is opening downward and contents that travel against their
own container read as two animations sharing a box. */
@keyframes item {
0%,
12.6% {
opacity: 0;
transform: translateY(-3px);
animation-timing-function: var(--ease-out);
}
15.4%,
59.6% {
opacity: 1;
transform: translateY(0);
animation-timing-function: ease;
}
61.2%,
100% {
opacity: 0;
transform: translateY(-2px);
}
}
@keyframes trigger {
0%,
10% {
transform: scale(1);
background: color-mix(in srgb, var(--slate) 6%, transparent);
animation-timing-function: var(--ease-out);
}
11.7% {
transform: scale(0.965);
background: color-mix(in srgb, var(--clay) 17%, transparent);
animation-timing-function: var(--ease-out);
}
15.2%,
60% {
transform: scale(1);
background: color-mix(in srgb, var(--clay) 17%, transparent);
animation-timing-function: ease;
}
63.5%,
100% {
transform: scale(1);
background: color-mix(in srgb, var(--slate) 6%, transparent);
}
}
@keyframes chev {
0%,
11% {
transform: rotate(0deg);
animation-timing-function: var(--ease-out);
}
14.2%,
60% {
transform: rotate(180deg);
animation-timing-function: var(--ease-out);
}
62.4%,
100% {
transform: rotate(0deg);
}
}
@media (prefers-reduced-motion: reduce) {
/* The scale goes and the origin goes with it, which costs this snippet
its subject — so what is left has to carry the anchoring on its own.
The trigger keeps its held tint for exactly as long as the surface is
up, and that pairing is the only remaining statement that these two
things belong to each other. */
.surface {
animation-name: surface-rm;
transform: none;
}
.item {
animation-name: item-rm;
animation-delay: 0s;
}
.chev {
animation-name: chev-rm;
}
.trigger,
.surface,
.item,
.chev {
animation-duration: calc(var(--t) * 1.7);
}
.trigger {
animation-name: trigger-rm;
}
@keyframes surface-rm {
0%,
11% {
opacity: 0;
animation-timing-function: ease;
}
14.2%,
60% {
opacity: 1;
animation-timing-function: ease;
}
62.4%,
100% {
opacity: 0;
}
}
@keyframes item-rm {
0%,
12.6% {
opacity: 0;
animation-timing-function: ease;
}
15.4%,
59.6% {
opacity: 1;
animation-timing-function: ease;
}
61.2%,
100% {
opacity: 0;
}
}
@keyframes chev-rm {
0%,
11% {
transform: rotate(0deg);
animation-timing-function: steps(1, end);
}
14.2%,
60% {
transform: rotate(180deg);
animation-timing-function: steps(1, end);
}
62.4%,
100% {
transform: rotate(0deg);
}
}
@keyframes trigger-rm {
0%,
10% {
background: color-mix(in srgb, var(--slate) 6%, transparent);
animation-timing-function: ease;
}
15.2%,
60% {
background: color-mix(in srgb, var(--clay) 17%, transparent);
animation-timing-function: ease;
}
63.5%,
100% {
background: color-mix(in srgb, var(--slate) 6%, transparent);
}
}
}
</style>
</head>
<body>
<div
class="frame"
role="img"
aria-label="A dropdown menu opening out of the corner of the button that triggered it, and closing back into it"
>
<div class="page">
<div class="row" style="width: 86%"></div>
<div class="row" style="width: 72%"></div>
<div class="row" style="width: 90%"></div>
<div class="row" style="width: 61%"></div>
<div class="row" style="width: 78%"></div>
</div>
<div class="toolbar">
<span class="trigger">
View
<span class="chev">
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="m5 9 7 7 7-7"
stroke="currentColor"
stroke-width="2.6"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</span>
</span>
<span class="ghost" style="width: 38px"></span>
<span class="ghost" style="width: 24px"></span>
</div>
<div class="menu">
<div class="surface"></div>
<div class="items">
<span class="item on" style="--_i: 0"><span class="tick"></span>Grid</span>
<span class="item" style="--_i: 1"><span class="tick"></span>List</span>
<span class="item" style="--_i: 2"><span class="tick"></span>Columns</span>
<span class="item" style="--_i: 3"><span class="tick"></span>Timeline</span>
</div>
</div>
</div>
</body>
</html>
The variant family
Same idea, one axis moved. Compare them side by side before you commit.