Glass stack FLIP reorder
Switching a filter tab re-sorts a stack of cards: each card slides from where it was to where it now is, the one that lands on top turns dark, and the sky takes its hue. The DOM order is the truth; the motion is derived from it.
How it works
- FLIP: First, Last, Invert, Play. Measure every card's
top;appendChildthem in the new order; measure again; settransform: translateY(first − last)with transitions disabled; flush layout with onegetBoundingClientRect(); remove the transform. The CSS transition ontransform(620ms) carries each card home. - The transition has to be switched off for the invert step — a
.movingclass setstransition: none— otherwise the card tweens to its old position first and then back, which reads as a double bounce. - The flush is the line people delete. Without a forced layout read between setting the inverted transform and clearing it, the browser batches both writes and the card simply appears in its new place.
- The top card takes the dark variant (
#ffffff1f, white type) and the sky takes its hue (--h, registered with@property, 900ms). Both are consequences of the order, computed after the reorder — nothing is stored about 'which card is active'. - The panel on the right prints the per-card Δy for every switch. A card with Δy 0 is skipped entirely; one with −212px is the card that just went from bottom to top.
- The showcase cycles the tabs every 3.2s until a tab is tapped.
Numbers
- Travel
- 620ms cubic-bezier(.22,.61,.36,1) on transform
- Invert
- .moving { transition: none } + one getBoundingClientRect() flush
- Top card
- #ffffff1f · white type · 600ms colour transition
- Sky
- --h → top card's hue · 900ms
Prompt
Paste this into any coding model to get the effect from scratch. It states every number explicitly — vague words like “smooth” or “modern” produce a different result every time.
Build a FLIP-reordered card stack in vanilla HTML/CSS/JS. Three 96px-tall frosted cards (radius 22, #ffffffa8, 1px #ffffffb0 border, backdrop-filter blur(22px)) in a flex column with 10px gap; each has a chip, a title, a subtitle, a colour dot, and a hue (275, 190, 228). Four pill tabs each define an order of the three cards. On tab change: record each card's getBoundingClientRect().top (First); appendChild the cards in the new order (Last); for each card compute dy = firstTop − newTop, add a class that sets transition: none, set transform: translateY(dy px), call getBoundingClientRect() once to flush layout, then remove the class and clear the transform so the stylesheet's transition: transform 620ms cubic-bezier(.22,.61,.36,1) plays (Invert, Play). Skip cards whose dy is 0. After reordering, give the first card a dark variant (#ffffff1f, white text, 600ms colour transitions) and set a registered custom property --h (@property, syntax '<number>', transition 900ms) on the body to that card's hue; the body background is linear-gradient(180deg, hsl(var(--h) 55% 16%), hsl(var(--h) 62% 44%) 34%, hsl(var(--h) 50% 80%) 66%, #f5f4f9 90%). Print each card's Δy and the new --h in a monospace panel. Auto-cycle the tabs every 3.2s until a tab is clicked. Under prefers-reduced-motion skip the transforms.Source
The whole file. It is what the sample above is running — copy it into an .html file and it works with nothing else.
<!doctype html>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Glass stack FLIP reorder</title>
<style>
@property --h { syntax: "<number>"; inherits: true; initial-value: 275; }
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { height: 100%; }
body {
font-family: "Inter", -apple-system, BlinkMacSystemFont, "SF Pro Text", sans-serif;
--h: 275; transition: --h 900ms cubic-bezier(.22,.61,.36,1);
background: linear-gradient(180deg, hsl(var(--h) 55% 16%) 0%, hsl(var(--h) 62% 44%) 34%, hsl(var(--h) 50% 80%) 66%, #f5f4f9 90%);
color: #17151f; overflow: hidden; -webkit-font-smoothing: antialiased;
}
.wrap { height: 100%; display: grid; grid-template-columns: 1fr 250px; gap: 28px; padding: 22px 26px; align-items: start; }
.tabs { display: flex; gap: 6px; }
.tab { height: 30px; padding: 0 13px; border-radius: 99px; border: 1px solid transparent; font-size: 12px; font-weight: 500; color: #ffffffb8; background: none; cursor: pointer; transition: border-color 400ms, background-color 400ms, color 400ms; }
.tab.on { border-color: #ffffff8c; background: #ffffff1f; color: #fff; }
.stack { margin-top: 12px; display: flex; flex-direction: column; gap: 10px; max-width: 360px; }
.card {
height: 96px; padding: 14px 16px; border-radius: 22px; position: relative;
background: #ffffffa8; border: 1px solid #ffffffb0;
backdrop-filter: blur(22px); -webkit-backdrop-filter: blur(22px);
/* FLIP: the transform is the animation. `moving` switches it off for one
frame so the inverted position lands without tweening. */
transition: transform 620ms cubic-bezier(.22,.61,.36,1), background-color 600ms, border-color 600ms, color 600ms;
will-change: transform;
}
.card.moving { transition: none; }
.card.dark { background: #ffffff1f; border-color: #ffffff38; color: #fff; }
.chip { display: inline-block; padding: 2px 9px; border-radius: 99px; font-size: 10px; font-weight: 500; border: 1px solid #17151f2a; background: #ffffff8c; }
.dark .chip { border-color: #ffffff59; background: #ffffff24; color: #fff; }
.card h3 { margin-top: 8px; font-size: 15px; font-weight: 600; letter-spacing: -.01em; }
.card p { font-size: 11px; opacity: .65; margin-top: 2px; }
.card i { position: absolute; right: 16px; top: 16px; width: 14px; height: 14px; border-radius: 50%; background: hsl(var(--c) 62% 44%); box-shadow: 0 0 0 3px #ffffff80; }
.log {
margin-top: 42px; padding: 14px 16px; border-radius: 16px; background: #ffffff8c; border: 1px solid #ffffffb0;
font: 500 10.5px/1.7 ui-monospace, Menlo, monospace; color: #17151fb0; white-space: pre; min-height: 150px;
}
.log b { color: #17151f; }
.foot { position: absolute; left: 0; right: 0; bottom: 10px; text-align: center; font-size: 10px; letter-spacing: .18em; text-transform: uppercase; color: #17151f80; }
@media (prefers-reduced-motion: reduce) { body, .card, .tab { transition: none; } .foot { display: none; } }
</style>
<div class="wrap">
<div>
<nav class="tabs" id="tabs"></nav>
<div class="stack" id="stack"></div>
</div>
<div class="log" id="log"></div>
</div>
<div class="foot">tap a tab · the stack re-sorts, the sky follows the top card</div>
<script>
const CARDS = {
relax: { h: 275, chip: "Family", t: "Relaxation Ideas", s: "Activities for couples" },
trip: { h: 190, chip: "Family", t: "Spring Trip", s: "Complete vacation planning" },
brain: { h: 228, chip: "Work", t: "Build Brain.ai", s: "Tasks to-do · 6" },
};
const TABS = [["Mind Space", ["relax", "trip", "brain"]], ["Personal", ["trip", "relax", "brain"]], ["Family", ["relax", "brain", "trip"]], ["Work", ["brain", "relax", "trip"]]];
const stack = document.getElementById("stack"), tabs = document.getElementById("tabs"), log = document.getElementById("log");
const reduce = matchMedia("(prefers-reduced-motion: reduce)").matches;
const els = {};
for (const [id, c] of Object.entries(CARDS)) {
const el = document.createElement("article"); el.className = "card"; el.dataset.id = id;
el.innerHTML = `<span class="chip">${c.chip}</span><i style="--c:${c.h}"></i><h3>${c.t}</h3><p>${c.s}</p>`;
els[id] = el;
}
let cur = 0, auto = 0;
TABS.forEach(([n], i) => { const b = document.createElement("button"); b.className = "tab"; b.textContent = n; b.onclick = () => { clearInterval(auto); setTab(i); }; tabs.appendChild(b); });
/* FLIP = First, Last, Invert, Play.
First: measure where every card is. Last: move the DOM to the new order
and measure again. Invert: transform each card back to where it was.
Play: drop the transform and let the CSS transition carry it home.
The DOM order is the truth; the motion is derived from it. */
function setTab(i) {
cur = i;
[...tabs.children].forEach((b, k) => b.classList.toggle("on", k === i));
const order = TABS[i][1];
const first = Object.fromEntries(order.map((id) => [id, els[id].getBoundingClientRect().top]));
for (const id of order) stack.appendChild(els[id]); // Last
const lines = [];
order.forEach((id, k) => {
const el = els[id]; el.classList.toggle("dark", k === 0);
const dy = first[id] - el.getBoundingClientRect().top; // Invert
lines.push(`${id.padEnd(6)} Δy ${String(Math.round(dy)).padStart(5)}px`);
if (reduce || !dy) return;
el.classList.add("moving"); el.style.transform = `translateY(${dy}px)`;
el.getBoundingClientRect(); // flush
el.classList.remove("moving"); el.style.transform = ""; // Play
});
document.body.style.setProperty("--h", CARDS[order[0]].h);
log.innerHTML = `<b>${TABS[i][0]}</b>\n${lines.join("\n")}\n--h → <b>${CARDS[order[0]].h}</b>`;
}
setTab(0);
if (!reduce) auto = setInterval(() => setTab((cur + 1) % TABS.length), 3200);
</script>Where it breaks
- FLIP on
transformis cheap; FLIP ontopormarginis a layout per frame. If the cards also change height between states, animatescaleYwithtransform-origin: topand counter-scale the children, or accept a crossfade. - Measure in the same coordinate space you transform in. Inside a scaled parent (a phone shell with
transform: scale(.9)) the rect delta is in viewport pixels but the transform is in local pixels — divide by the scale or the cards overshoot. - Rapid re-sorts interrupt the transition mid-flight, which FLIP handles correctly (it measures the current rendered position) — but only if the measurement happens before the DOM move, never after.
Seen in
@海森堡 (Douyin) · “自然人工智能设计探索的思维空间” · 0:03–0:11