Layout — sort flip
Variant: Sort flip
Four bars re-sort from recency order to size order in one 448ms crossing: every bar rides the same window on the same ease-in-out curve, so the far movers are simply faster. Staggering a sort would claim the comparisons happened one at a time — they did not; the order arrived whole, and the single shared motion is what says so. A 2px ivory keyline travels with each bar so two of them crossing read as passing, not merging, and the caption is sequenced rather than crossfaded: the old name leaves before the new one lands with the settled order. Use it on sortable tables, leaderboards, and charts where identity must survive the shuffle.
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>Layout — sort flip</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. Each re-sort is one 448ms crossing; the rest is spent
reading the order that crossing produced. */
--cycle: 5.6s;
--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);
/* Slot pitch for the four columns. */
--_sw: 46px;
}
* {
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);
}
.panel {
display: grid;
gap: 10px;
}
.label {
position: relative;
height: 14px;
font-size: 11px;
letter-spacing: 0.01em;
color: color-mix(in srgb, var(--slate) 55%, transparent);
}
.label span {
position: absolute;
left: 0;
top: 0;
}
.lbl-recent {
animation: lbl-recent var(--t) infinite;
}
.lbl-largest {
opacity: 0;
animation: lbl-largest var(--t) infinite;
}
.chart {
position: relative;
width: 174px;
height: 80px;
border-bottom: 1px solid var(--oat);
}
/* A 2px ivory keyline around every bar: when two bars cross mid-flight
the gap travels with them, so they read as passing, not merging. The
two-slot travellers ride above the one-slot movers — longer flight,
higher layer — and of that pair the shorter bar sits in front, so the
taller one stays readable behind it at their shared crossover. */
.bar {
position: absolute;
bottom: 0;
width: 36px;
border-radius: calc(4px * var(--round)) calc(4px * var(--round)) 0 0;
box-shadow: 0 0 0 2px var(--ivory);
}
.b1 {
left: 0;
height: 40px;
background: var(--clay);
z-index: 3;
animation: go-plus-two var(--t) infinite;
}
.b2 {
left: 46px;
height: 72px;
background: var(--clay-d);
animation: go-minus-one var(--t) infinite;
}
.b3 {
left: 92px;
height: 24px;
background: color-mix(in srgb, var(--slate) 18%, var(--oat));
animation: go-plus-one var(--t) infinite;
}
.b4 {
left: 138px;
height: 56px;
background: var(--olive);
z-index: 2;
animation: go-minus-two var(--t) infinite;
}
/* Sorting is one decision, so the answer is one motion: all four bars
share the same 448ms window and the same curve, and distance sets
speed. Staggering a sort would claim the comparisons happened one at a
time; they did not — the order arrived whole. */
@keyframes go-plus-two {
0%,
12% {
transform: translateX(0);
animation-timing-function: var(--ease-in-out);
}
20%,
55% {
transform: translateX(calc(2 * var(--_sw)));
animation-timing-function: var(--ease-in-out);
}
63%,
100% {
transform: translateX(0);
}
}
@keyframes go-minus-one {
0%,
12% {
transform: translateX(0);
animation-timing-function: var(--ease-in-out);
}
20%,
55% {
transform: translateX(calc(-1 * var(--_sw)));
animation-timing-function: var(--ease-in-out);
}
63%,
100% {
transform: translateX(0);
}
}
@keyframes go-plus-one {
0%,
12% {
transform: translateX(0);
animation-timing-function: var(--ease-in-out);
}
20%,
55% {
transform: translateX(var(--_sw));
animation-timing-function: var(--ease-in-out);
}
63%,
100% {
transform: translateX(0);
}
}
@keyframes go-minus-two {
0%,
12% {
transform: translateX(0);
animation-timing-function: var(--ease-in-out);
}
20%,
55% {
transform: translateX(calc(-2 * var(--_sw)));
animation-timing-function: var(--ease-in-out);
}
63%,
100% {
transform: translateX(0);
}
}
/* The labels are sequenced, not crossfaded symmetrically: the old name
leaves before the new one lands, so they never share a frame at half
opacity. The incoming label finishes with the bars — the caption and
the order it names arrive together. */
@keyframes lbl-recent {
0%,
12% {
opacity: 1;
transform: translateY(0);
animation-timing-function: var(--ease-out);
}
15.5%,
59% {
opacity: 0;
transform: translateY(-3px);
animation-timing-function: var(--ease-out);
}
63%,
100% {
opacity: 1;
transform: translateY(0);
}
}
@keyframes lbl-largest {
0%,
16.5% {
opacity: 0;
transform: translateY(3px);
animation-timing-function: var(--ease-out);
}
20%,
55% {
opacity: 1;
transform: translateY(0);
animation-timing-function: var(--ease-out);
}
58.5%,
100% {
opacity: 0;
transform: translateY(3px);
}
}
@media (prefers-reduced-motion: reduce) {
/* The bars swap on a single frame — order is the content, travel was
the explanation — while the label keeps its fade with the roll
removed, so the loop still visibly breathes. */
.b1 {
animation-name: go-plus-two-step;
}
.b2 {
animation-name: go-minus-one-step;
}
.b3 {
animation-name: go-plus-one-step;
}
.b4 {
animation-name: go-minus-two-step;
}
.lbl-recent,
.lbl-largest {
transform: none !important;
}
.b1,
.b2,
.b3,
.b4,
.lbl-recent,
.lbl-largest {
animation-duration: calc(var(--t) * 1.5);
}
@keyframes go-plus-two-step {
0%,
12% {
transform: translateX(0);
animation-timing-function: steps(1, end);
}
20%,
55% {
transform: translateX(calc(2 * var(--_sw)));
animation-timing-function: steps(1, end);
}
63%,
100% {
transform: translateX(0);
}
}
@keyframes go-minus-one-step {
0%,
12% {
transform: translateX(0);
animation-timing-function: steps(1, end);
}
20%,
55% {
transform: translateX(calc(-1 * var(--_sw)));
animation-timing-function: steps(1, end);
}
63%,
100% {
transform: translateX(0);
}
}
@keyframes go-plus-one-step {
0%,
12% {
transform: translateX(0);
animation-timing-function: steps(1, end);
}
20%,
55% {
transform: translateX(var(--_sw));
animation-timing-function: steps(1, end);
}
63%,
100% {
transform: translateX(0);
}
}
@keyframes go-minus-two-step {
0%,
12% {
transform: translateX(0);
animation-timing-function: steps(1, end);
}
20%,
55% {
transform: translateX(calc(-2 * var(--_sw)));
animation-timing-function: steps(1, end);
}
63%,
100% {
transform: translateX(0);
}
}
}
</style>
</head>
<body>
<div
class="panel"
role="img"
aria-label="Four chart bars re-sort from recency order to size order: every bar travels in the same window on the same curve, then the sort flips back"
>
<div class="label">
<span class="lbl-recent">Recent first</span>
<span class="lbl-largest">Largest first</span>
</div>
<div class="chart">
<div class="bar b1"></div>
<div class="bar b2"></div>
<div class="bar b3"></div>
<div class="bar b4"></div>
</div>
</div>
</body>
</html>
The variant family
Same idea, one axis moved. Compare them side by side before you commit.