View change — expand in place
Variant: Expand in place
The one where nothing navigates: the destination was already on screen, so the row discloses and the rows beneath make room. The panel is out of flow and the displacement is a transform of exactly its height plus one gap, which is what keeps this off the layout pass a real height animation would spend on every frame — the price is that the panel has to have a height you know in advance, which a settings disclosure does and arbitrary content does not. The clip edge and the shift share one window and one curve; finish the clip early and the panel is briefly drawn over the row it is still pushing out of the way. The chevron turns 39ms before any of it, because it is the only part of the row the pointer was actually on, and the list rearranging is the consequence rather than the acknowledgement.
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>View change — expand in place</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.6s. Opening costs 6% of it (336ms) and closing 5% (280ms):
the disclosure is a decision, the dismissal is housekeeping. */
--cycle: 5.6s;
--t: calc(var(--cycle) / var(--speed));
--ease-accent: cubic-bezier(0.32, 0.72, 0, 1);
/* The list's geometry is one set of numbers, because the displacement
below has to equal the space that opened above it to the pixel — the
panel is out of flow, so nothing reflows to correct a mismatch. */
--_row: 21px;
--_gap: 6px;
--_panel: 44px;
--_open: calc(var(--_panel) + var(--_gap));
}
* {
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);
}
.list {
position: relative;
width: clamp(212px, 72vw, 268px);
display: grid;
gap: var(--_gap);
}
.row {
position: relative;
display: flex;
align-items: center;
gap: 8px;
height: var(--_row);
padding: 0 9px;
border-radius: calc(6px * var(--round));
background: var(--paper);
border: 1px solid color-mix(in srgb, var(--slate) 9%, transparent);
font-size: 11px;
letter-spacing: -0.005em;
}
/* Rows below the disclosure are pushed by a transform, not by a reflow.
That is the trade this pattern makes: a real layout animation would let
the panel be any height, and would cost a layout pass on every frame of
the open. A fixed displacement costs nothing and demands that the panel
have a height you know in advance — which a settings disclosure does. */
.r3,
.r4 {
animation: displace var(--t) infinite;
}
.r2 {
animation: selected var(--t) infinite;
}
.value {
margin-left: auto;
font-size: 10.5px;
color: color-mix(in srgb, var(--slate) 45%, transparent);
}
.chev {
width: 10px;
height: 10px;
flex: none;
color: color-mix(in srgb, var(--slate) 40%, transparent);
transform: rotate(0deg);
animation: chevron var(--t) infinite;
}
.chev svg {
width: 100%;
height: 100%;
display: block;
}
/* Out of flow, parked in the gap it is about to occupy: two rows and two
gaps down from the top of the list. */
.panel {
position: absolute;
left: 0;
right: 0;
top: calc(2 * var(--_row) + 2 * var(--_gap));
height: var(--_panel);
display: flex;
align-items: center;
gap: 9px;
padding: 0 10px;
border-radius: calc(6px * var(--round));
background: color-mix(in srgb, var(--oat) 32%, var(--paper));
clip-path: inset(0 0 100% 0 round calc(6px * var(--round)));
animation: unroll var(--t) infinite;
}
.panel .swatch {
width: 15px;
height: 15px;
flex: none;
border-radius: calc(4px * var(--round));
background: var(--clay);
}
.panel .lines {
flex: 1;
display: grid;
gap: 5px;
}
.panel .line {
height: 6px;
border-radius: 3px;
background: color-mix(in srgb, var(--slate) 14%, transparent);
}
/* The panel's contents are not what opened; the panel is. They arrive
after it has stopped, which is also the only way they avoid being
read through a clip edge travelling over them. */
.panel .swatch,
.panel .lines {
opacity: 0;
animation: panel-content var(--t) infinite;
}
/* One curve, one clock, one window: the clip edge and the displacement are
the two halves of the same opening. Finish the clip early and the panel
is briefly drawn over the row it is still pushing out of the way. */
@keyframes unroll {
0%,
10.7% {
clip-path: inset(0 0 100% 0 round calc(6px * var(--round)));
animation-timing-function: var(--ease-accent);
}
16.7%,
57.8% {
clip-path: inset(0 0 0 0 round calc(6px * var(--round)));
animation-timing-function: var(--ease-accent);
}
62.8%,
100% {
clip-path: inset(0 0 100% 0 round calc(6px * var(--round)));
}
}
@keyframes displace {
0%,
10.7% {
transform: translateY(0);
animation-timing-function: var(--ease-accent);
}
16.7%,
57.8% {
transform: translateY(var(--_open));
animation-timing-function: var(--ease-accent);
}
62.8%,
100% {
transform: translateY(0);
}
}
/* The chevron turns 39ms before anything moves. It is the only part of the
row the pointer was actually on, so it is the part that has to answer
first — the list rearranging is the consequence, not the acknowledgement. */
@keyframes chevron {
0%,
10% {
transform: rotate(0deg);
animation-timing-function: var(--ease-accent);
}
14%,
57.8% {
transform: rotate(90deg);
animation-timing-function: var(--ease-accent);
}
61.4%,
100% {
transform: rotate(0deg);
}
}
@keyframes selected {
0%,
10.7% {
background: var(--paper);
animation-timing-function: ease;
}
14%,
57.8% {
background: color-mix(in srgb, var(--clay) 9%, var(--paper));
animation-timing-function: ease;
}
61%,
100% {
background: var(--paper);
}
}
@keyframes panel-content {
0%,
16.7% {
opacity: 0;
animation-timing-function: ease;
}
20.5%,
56% {
opacity: 1;
animation-timing-function: ease;
}
57.8%,
100% {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
/* The displacement cannot simply be dropped — remove it and the panel
opens on top of the rows it is meant to move. So it keeps its
distance and gives up its interpolation: steps(1) puts the rows in
their new places on the frame the disclosure changes state, which is
what a list with no animation at all does. The panel then arrives the
only way left to it, as opacity. */
.r3,
.r4 {
animation-name: displace-step;
}
.chev {
animation-name: chevron-step;
}
.panel {
clip-path: none;
opacity: 0;
animation-name: panel-content;
}
/* The panel itself now carries the fade, so its contents stop being a
second beat and simply come with it. */
.panel .swatch,
.panel .lines {
animation: none;
opacity: 1;
}
.r2,
.r3,
.r4,
.chev,
.panel {
animation-duration: calc(var(--t) * 1.5);
}
@keyframes displace-step {
0%,
10.7% {
transform: translateY(0);
animation-timing-function: steps(1, end);
}
16.7%,
57.8% {
transform: translateY(var(--_open));
animation-timing-function: steps(1, end);
}
62.8%,
100% {
transform: translateY(0);
}
}
@keyframes chevron-step {
0%,
10% {
transform: rotate(0deg);
animation-timing-function: steps(1, end);
}
14%,
57.8% {
transform: rotate(90deg);
animation-timing-function: steps(1, end);
}
61.4%,
100% {
transform: rotate(0deg);
}
}
}
</style>
</head>
<body>
<div
class="list"
role="img"
aria-label="A settings row disclosing a panel in place: the chevron turns, the rows below move down by exactly the panel's height, and the panel unrolls into the gap"
>
<div class="row r1">
<span>Alerts</span>
<span class="value">On</span>
</div>
<div class="row r2">
<span class="chev">
<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>
<span>Appearance</span>
<span class="value">Clay</span>
</div>
<div class="panel">
<span class="swatch"></span>
<span class="lines">
<span class="line"></span>
<span class="line" style="width: 64%"></span>
</span>
</div>
<div class="row r3">
<span>Sounds</span>
<span class="value">Off</span>
</div>
<div class="row r4">
<span>Storage</span>
<span class="value">2.1 GB</span>
</div>
</div>
</body>
</html>
The variant family
Same idea, one axis moved. Compare them side by side before you commit.