View change — sibling crossfade
Variant: Sibling crossfade
Siblings, where there is no up or down to express. Nothing in the panel travels — the pill carries the entire spatial story and the content merely changes, which is the right division of labour for a switch this cheap and this frequent. Two decisions do the work. The panel starts swapping on the frame the pill lands and never on the frame it leaves, because the content is the outcome of a selection rather than its intention. And the crossfade is sequenced rather than symmetric: out in 125ms, in over 172ms starting 66ms later, since two panels sitting near half opacity on the same frame read as one ghosted set of shapes. The wrap from the third tab back to the first covers two segments in the same 297ms as a single step — a segmented control moves in a fixed duration, not at a fixed speed, because either way it is reporting one decision.
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 — sibling crossfade</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.6s, three equal dwells. Every pill move is 4.5% of it, or
297ms, including the wrap that covers two segments: a segmented
control moves in a fixed duration, not at a fixed speed, because the
thing being reported is one decision either way. */
--cycle: 6.6s;
--t: calc(var(--cycle) / var(--speed));
--ease-accent: cubic-bezier(0.32, 0.72, 0, 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(228px, 74vw, 292px);
aspect-ratio: 3 / 2;
display: grid;
grid-template-rows: auto 1fr;
gap: 10px;
padding: 11px;
border-radius: calc(12px * var(--round));
background: var(--paper);
border: 1px solid color-mix(in srgb, var(--slate) 10%, transparent);
overflow: hidden;
}
.segments {
position: relative;
display: grid;
grid-template-columns: repeat(3, 1fr);
padding: 3px;
border-radius: 999px;
background: color-mix(in srgb, var(--oat) 55%, transparent);
}
/* One segment wide, so its travel is written in whole segments: 0, 100%,
200%. Nothing has to know how wide the control rendered. */
.pill {
position: absolute;
top: 3px;
bottom: 3px;
left: 3px;
width: calc((100% - 6px) / 3);
border-radius: 999px;
background: var(--paper);
box-shadow: 0 1px 2px color-mix(in srgb, var(--slate) 16%, transparent);
animation: pill var(--t) infinite;
}
.tab {
position: relative;
z-index: 1;
display: grid;
place-items: center;
height: 21px;
font-size: 10.5px;
font-weight: 600;
letter-spacing: 0.01em;
opacity: 0.45;
}
.tab:nth-child(2) {
animation: tab-1 var(--t) infinite;
}
.tab:nth-child(3) {
animation: tab-2 var(--t) infinite;
}
.tab:nth-child(4) {
animation: tab-3 var(--t) infinite;
}
.panels {
position: relative;
}
.panel {
position: absolute;
inset: 0;
display: grid;
gap: 8px;
align-content: start;
opacity: 0;
}
.p1 {
animation: panel-1 var(--t) infinite;
}
.p2 {
grid-template-columns: 1fr 1fr;
animation: panel-2 var(--t) infinite;
}
.p3 {
animation: panel-3 var(--t) infinite;
}
.row {
height: 13px;
border-radius: calc(4px * var(--round));
background: color-mix(in srgb, var(--slate) 8%, transparent);
}
.p1 .row:first-child {
background: color-mix(in srgb, var(--clay) 28%, transparent);
}
.block {
height: 34px;
border-radius: calc(6px * var(--round));
background: color-mix(in srgb, var(--olive) 22%, transparent);
}
.block:nth-child(even) {
background: color-mix(in srgb, var(--slate) 8%, transparent);
}
.p3 .line {
height: 7px;
border-radius: 4px;
background: var(--oat);
}
.p3 .chip {
height: 20px;
width: 44%;
margin-top: 2px;
border-radius: calc(6px * var(--round));
background: var(--clay);
}
@keyframes pill {
0%,
30% {
transform: translateX(0);
animation-timing-function: var(--ease-accent);
}
34.5%,
63.3% {
transform: translateX(100%);
animation-timing-function: var(--ease-accent);
}
67.8%,
95.5% {
transform: translateX(200%);
animation-timing-function: var(--ease-accent);
}
100% {
transform: translateX(0);
}
}
/* The label brightens with the pill, not with the content: the pill is
what makes a segment selected, and the panel below is only the
consequence. */
@keyframes tab-1 {
0%,
30% {
opacity: 1;
animation-timing-function: ease;
}
34.5%,
95.5% {
opacity: 0.45;
animation-timing-function: ease;
}
100% {
opacity: 1;
}
}
@keyframes tab-2 {
0%,
30% {
opacity: 0.45;
animation-timing-function: ease;
}
34.5%,
63.3% {
opacity: 1;
animation-timing-function: ease;
}
67.8%,
100% {
opacity: 0.45;
}
}
@keyframes tab-3 {
0%,
63.3% {
opacity: 0.45;
animation-timing-function: ease;
}
67.8%,
95.5% {
opacity: 1;
animation-timing-function: ease;
}
100% {
opacity: 0.45;
}
}
/* Every panel change starts on the frame the pill lands, never on the
frame it leaves — the content is the outcome of the selection, and a
panel that begins swapping while the pill is still in transit claims
the decision was made mid-flight.
Out in 125ms, in over 172ms starting 66ms later. A symmetric crossfade
has both panels near half opacity at the same instant, which on
translucent marks reads as one ghosted set of shapes; sequencing them
keeps exactly one panel legible at every frame of the swap. */
@keyframes panel-1 {
0%,
0.9% {
opacity: 0;
animation-timing-function: ease;
}
3.5%,
34.5% {
opacity: 1;
animation-timing-function: ease;
}
36.4%,
100% {
opacity: 0;
}
}
@keyframes panel-2 {
0%,
35.5% {
opacity: 0;
animation-timing-function: ease;
}
38.1%,
67.8% {
opacity: 1;
animation-timing-function: ease;
}
69.7%,
100% {
opacity: 0;
}
}
@keyframes panel-3 {
0% {
opacity: 1;
animation-timing-function: ease;
}
1.9%,
68.8% {
opacity: 0;
animation-timing-function: ease;
}
71.4%,
100% {
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
/* The pill is the only travelling part, and it keeps its job by giving
up interpolation rather than position: steps(1) on the same keyframes
puts it under the new segment on the frame the selection changes.
A discrete state change is not motion — it is what an interface with
no animation at all does. The crossfades stay; they were never travel. */
.pill {
animation-name: pill-step;
}
.pill,
.tab,
.panel {
animation-duration: calc(var(--t) * 1.5);
}
@keyframes pill-step {
0%,
30% {
transform: translateX(0);
animation-timing-function: steps(1, end);
}
34.5%,
63.3% {
transform: translateX(100%);
animation-timing-function: steps(1, end);
}
67.8%,
95.5% {
transform: translateX(200%);
animation-timing-function: steps(1, end);
}
100% {
transform: translateX(0);
}
}
}
</style>
</head>
<body>
<div
class="frame"
role="img"
aria-label="A segmented control whose pill slides between three tabs while the panel below crossfades in place with no travel"
>
<div class="segments">
<span class="pill"></span>
<span class="tab">Feed</span>
<span class="tab">Grid</span>
<span class="tab">About</span>
</div>
<div class="panels">
<div class="panel p1">
<span class="row"></span>
<span class="row" style="width: 88%"></span>
<span class="row" style="width: 96%"></span>
<span class="row" style="width: 74%"></span>
</div>
<div class="panel p2">
<span class="block"></span>
<span class="block"></span>
<span class="block"></span>
<span class="block"></span>
</div>
<div class="panel p3">
<span class="line" style="width: 94%"></span>
<span class="line" style="width: 82%"></span>
<span class="line" style="width: 90%"></span>
<span class="chip"></span>
</div>
</div>
</div>
</body>
</html>
The variant family
Same idea, one axis moved. Compare them side by side before you commit.