FLIP Layout Animation
A developer-facing grid that animates its items between layouts using the FLIP technique — First, Last, Invert, Play. Shuffle, sort, filter, add, and remove cards while every item glides on a transform-only path, cross-fades on enter and exit, and reflows with GPU-friendly motion. A labeled note breaks down each FLIP stage, and controls plus a reduced-motion toggle let you feel how position deltas become buttery, jank-free transitions from any starting state.
MCP
Code
:root {
--bg: #0c0d10;
--surface: #15161b;
--surface-2: #1d1f27;
--ink: #e9eaf0;
--muted: #9a9cab;
--accent: #8b5cf6;
--accent-2: #22d3ee;
--ok: #34d399;
--warn: #fbbf24;
--danger: #f87171;
--line: rgba(255, 255, 255, 0.1);
--line-2: rgba(255, 255, 255, 0.18);
--r-sm: 8px;
--r-md: 12px;
--r-lg: 16px;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
-webkit-text-size-adjust: 100%;
}
body {
margin: 0;
background: radial-gradient(1200px 600px at 80% -10%, rgba(139, 92, 246, 0.12), transparent 60%),
radial-gradient(900px 500px at -10% 10%, rgba(34, 211, 238, 0.08), transparent 55%), var(--bg);
color: var(--ink);
font-family: "Inter", system-ui, -apple-system, "Segoe UI", sans-serif;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
min-height: 100vh;
}
code {
font-family: "JetBrains Mono", ui-monospace, "SF Mono", monospace;
font-size: 0.9em;
background: var(--surface-2);
border: 1px solid var(--line);
padding: 0.05em 0.4em;
border-radius: 6px;
color: var(--accent-2);
}
.wrap {
max-width: 980px;
margin: 0 auto;
padding: clamp(20px, 4vw, 44px) clamp(16px, 4vw, 32px) 64px;
}
/* ---------- Header ---------- */
.head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 20px;
flex-wrap: wrap;
margin-bottom: 26px;
}
.head h1 {
margin: 0 0 8px;
font-size: clamp(1.5rem, 4vw, 2rem);
font-weight: 700;
letter-spacing: -0.02em;
}
.sub {
margin: 0;
color: var(--muted);
max-width: 60ch;
font-size: 0.95rem;
}
.sub strong {
color: var(--ink);
font-weight: 600;
}
/* ---------- Reduced-motion switch ---------- */
.switch {
display: inline-flex;
align-items: center;
gap: 10px;
cursor: pointer;
user-select: none;
flex-shrink: 0;
padding: 8px 12px;
border: 1px solid var(--line);
border-radius: 999px;
background: var(--surface);
}
.switch input {
position: absolute;
opacity: 0;
width: 1px;
height: 1px;
}
.switch__track {
width: 40px;
height: 22px;
border-radius: 999px;
background: var(--surface-2);
border: 1px solid var(--line-2);
position: relative;
transition: background 0.2s ease, border-color 0.2s ease;
}
.switch__thumb {
position: absolute;
top: 2px;
left: 2px;
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--muted);
transition: transform 0.2s ease, background 0.2s ease;
}
.switch input:checked + .switch__track {
background: rgba(139, 92, 246, 0.35);
border-color: var(--accent);
}
.switch input:checked + .switch__track .switch__thumb {
transform: translateX(18px);
background: var(--accent);
}
.switch input:focus-visible + .switch__track {
outline: 2px solid var(--accent-2);
outline-offset: 2px;
}
.switch__label {
font-size: 0.85rem;
color: var(--muted);
font-weight: 500;
}
/* ---------- Toolbar ---------- */
.toolbar {
display: flex;
flex-wrap: wrap;
gap: 14px 22px;
align-items: center;
padding: 14px 16px;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-lg);
margin-bottom: 14px;
}
.toolbar__group {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.toolbar__label {
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--muted);
font-weight: 600;
}
.btn {
appearance: none;
border: 1px solid var(--line-2);
background: var(--surface-2);
color: var(--ink);
font: inherit;
font-weight: 600;
font-size: 0.88rem;
padding: 9px 14px;
border-radius: var(--r-sm);
cursor: pointer;
transition: background 0.15s ease, border-color 0.15s ease, transform 0.08s ease;
}
.btn:hover {
background: #262933;
border-color: var(--line-2);
}
.btn:active {
transform: translateY(1px);
}
.btn:focus-visible {
outline: 2px solid var(--accent-2);
outline-offset: 2px;
}
.btn--accent {
background: linear-gradient(180deg, #9b6cf8, var(--accent));
border-color: transparent;
color: #fff;
}
.btn--accent:hover {
background: linear-gradient(180deg, #a880fa, #7c4fee);
}
.btn--ghost {
background: transparent;
}
/* ---------- Category chips ---------- */
.chips {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.chip {
appearance: none;
border: 1px solid var(--line);
background: transparent;
color: var(--muted);
font: inherit;
font-weight: 600;
font-size: 0.82rem;
padding: 6px 12px;
border-radius: 999px;
cursor: pointer;
transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease;
}
.chip:hover {
color: var(--ink);
border-color: var(--line-2);
}
.chip[aria-selected="true"] {
background: rgba(34, 211, 238, 0.14);
border-color: var(--accent-2);
color: var(--accent-2);
}
.chip:focus-visible {
outline: 2px solid var(--accent-2);
outline-offset: 2px;
}
/* ---------- Status ---------- */
.status {
margin: 0 0 18px;
font-size: 0.85rem;
color: var(--muted);
font-family: "JetBrains Mono", ui-monospace, monospace;
min-height: 1.2em;
}
.status b {
color: var(--ok);
font-weight: 600;
}
/* ---------- Grid ---------- */
.grid {
list-style: none;
margin: 0 0 34px;
padding: 0;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 12px;
}
.card {
position: relative;
background: linear-gradient(180deg, var(--surface-2), var(--surface));
border: 1px solid var(--line);
border-radius: var(--r-md);
padding: 14px;
min-height: 108px;
display: flex;
flex-direction: column;
gap: 8px;
overflow: hidden;
will-change: transform, opacity;
cursor: default;
}
.card::before {
content: "";
position: absolute;
inset: 0 auto 0 0;
width: 3px;
background: var(--cat, var(--accent));
}
.card__top {
display: flex;
align-items: center;
gap: 9px;
}
.card__dot {
width: 26px;
height: 26px;
border-radius: 8px;
display: grid;
place-items: center;
font-size: 0.9rem;
background: color-mix(in srgb, var(--cat) 22%, transparent);
color: var(--cat, var(--accent));
flex-shrink: 0;
border: 1px solid color-mix(in srgb, var(--cat) 40%, transparent);
}
.card__name {
font-weight: 600;
font-size: 0.95rem;
letter-spacing: -0.01em;
}
.card__cat {
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--muted);
font-weight: 600;
}
.card__meta {
margin-top: auto;
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
font-family: "JetBrains Mono", ui-monospace, monospace;
font-size: 0.78rem;
color: var(--muted);
}
.card__weight {
color: var(--ink);
font-weight: 600;
}
/* enter / exit states (transform + opacity only) */
.card.is-enter {
opacity: 0;
transform: scale(0.86);
}
.card.is-exit {
opacity: 0;
transform: scale(0.86);
}
/* ---------- FLIP note ---------- */
.note {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-lg);
padding: clamp(18px, 3vw, 26px);
}
.note__title {
margin: 0 0 16px;
font-size: 1.05rem;
font-weight: 700;
letter-spacing: -0.01em;
}
.flip-steps {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 12px;
grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
}
.flip-step {
display: flex;
gap: 12px;
align-items: flex-start;
padding: 14px;
background: var(--surface-2);
border: 1px solid var(--line);
border-radius: var(--r-md);
}
.flip-step__k {
flex-shrink: 0;
width: 34px;
height: 34px;
border-radius: 10px;
display: grid;
place-items: center;
font-family: "JetBrains Mono", monospace;
font-weight: 700;
font-size: 1.1rem;
color: #fff;
background: linear-gradient(135deg, var(--accent), var(--accent-2));
}
.flip-step__body {
font-size: 0.88rem;
color: var(--muted);
}
.flip-step__body strong {
color: var(--ink);
font-weight: 600;
}
.note__foot {
margin: 16px 0 0;
font-size: 0.85rem;
color: var(--muted);
border-top: 1px solid var(--line);
padding-top: 14px;
}
/* ---------- Responsive ---------- */
@media (max-width: 520px) {
.head {
flex-direction: column;
gap: 14px;
}
.toolbar {
gap: 12px;
}
.toolbar__group {
width: 100%;
}
.grid {
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
}
.btn {
flex: 1 1 auto;
}
}
@media (prefers-reduced-motion: reduce) {
.card {
will-change: auto;
}
}/* FLIP Layout Animation — First · Last · Invert · Play
* Vanilla JS, no dependencies. Animates only transform + opacity. */
(function () {
"use strict";
var grid = document.getElementById("grid");
var status = document.getElementById("status");
var chipsWrap = document.getElementById("chips");
var reduceToggle = document.getElementById("reduce");
var DURATION = 460; // ms
var EASING = "cubic-bezier(0.22, 0.61, 0.36, 1)";
// Respect OS-level reduced-motion by default.
var mq = window.matchMedia("(prefers-reduced-motion: reduce)");
reduceToggle.checked = mq.matches;
try {
mq.addEventListener("change", function (e) {
reduceToggle.checked = e.matches;
});
} catch (_) {
/* older Safari — ignore */
}
var CATEGORIES = {
core: { label: "Core", color: "#8b5cf6", icon: "◆" },
data: { label: "Data", color: "#22d3ee", icon: "▲" },
ui: { label: "UI", color: "#34d399", icon: "●" },
net: { label: "Net", color: "#fbbf24", icon: "◇" }
};
var CAT_KEYS = Object.keys(CATEGORIES);
var NAMES = [
"Aurora", "Basalt", "Cobalt", "Dune", "Ember", "Flux", "Glint", "Halo",
"Ionic", "Jade", "Krypton", "Lumen", "Mistral", "Nimbus", "Onyx",
"Prism", "Quartz", "Ripple", "Sable", "Talon", "Umbra", "Vertex",
"Willow", "Xenon", "Yonder", "Zephyr"
];
var uid = 0;
function makeItem(name, cat) {
return {
id: ++uid,
name: name,
cat: cat || CAT_KEYS[(Math.random() * CAT_KEYS.length) | 0],
weight: Math.round((Math.random() * 9.4 + 0.5) * 10) / 10
};
}
// Seed model
var items = [];
for (var i = 0; i < 10; i++) {
items.push(makeItem(NAMES[i], CAT_KEYS[i % CAT_KEYS.length]));
}
var activeFilter = "all";
var nextName = 10;
/* ---------- Rendering ---------- */
function visibleItems() {
if (activeFilter === "all") return items.slice();
return items.filter(function (it) {
return it.cat === activeFilter;
});
}
function cardEl(it) {
var meta = CATEGORIES[it.cat];
var li = document.createElement("li");
li.className = "card";
li.dataset.id = String(it.id);
li.style.setProperty("--cat", meta.color);
li.innerHTML =
'<div class="card__top">' +
'<span class="card__dot" aria-hidden="true">' + meta.icon + "</span>" +
'<span class="card__name"></span>' +
"</div>" +
'<span class="card__cat">' + meta.label + "</span>" +
'<div class="card__meta">' +
"<span>weight</span>" +
'<span class="card__weight"></span>' +
"</div>";
li.querySelector(".card__name").textContent = it.name;
li.querySelector(".card__weight").textContent = it.weight.toFixed(1) + " kg";
return li;
}
// Map id -> element currently in DOM
function currentEls() {
var map = new Map();
var nodes = grid.children;
for (var i = 0; i < nodes.length; i++) {
map.set(nodes[i].dataset.id, nodes[i]);
}
return map;
}
var animating = false;
/* ---------- The FLIP pass ---------- */
function render(reason) {
var reduce = reduceToggle.checked;
var wanted = visibleItems();
var wantedIds = new Set(
wanted.map(function (it) {
return String(it.id);
})
);
var existing = currentEls();
// ---- FIRST: record positions of elements already on screen ----
var firstRects = new Map();
existing.forEach(function (el, id) {
firstRects.set(id, el.getBoundingClientRect());
});
// ---- Determine exits (present now, not wanted) ----
var exits = [];
existing.forEach(function (el, id) {
if (!wantedIds.has(id)) exits.push(el);
});
// ---- LAST: build the target DOM order ----
// Remove exiting nodes from flow immediately for measurement,
// but keep them for the exit animation as absolutely-positioned ghosts.
if (reduce) {
// No animation: just rebuild directly.
grid.replaceChildren();
wanted.forEach(function (it) {
grid.appendChild(cardEl(it));
});
report(reason, wanted.length, 0, exits.length, 0);
return;
}
// Freeze exiting elements as ghosts so they can fade without holding space.
exits.forEach(function (el) {
var r = firstRects.get(el.dataset.id);
var host = grid.getBoundingClientRect();
el.style.position = "absolute";
el.style.zIndex = "0";
el.style.margin = "0";
el.style.width = r.width + "px";
el.style.height = r.height + "px";
el.style.left = r.left - host.left + grid.scrollLeft + "px";
el.style.top = r.top - host.top + grid.scrollTop + "px";
});
// Reorder / create the kept & new elements into wanted order.
var enters = [];
var moved = 0;
var frag = document.createDocumentFragment();
wanted.forEach(function (it) {
var id = String(it.id);
var el = existing.get(id);
if (el) {
frag.appendChild(el); // moving a live node preserves it
} else {
el = cardEl(it);
el.classList.add("is-enter");
enters.push(el);
frag.appendChild(el);
}
});
// Put ghosts back so they render during the transition.
grid.appendChild(frag);
exits.forEach(function (el) {
grid.appendChild(el);
});
// ---- LAST measure + INVERT for kept nodes ----
wanted.forEach(function (it) {
var id = String(it.id);
var first = firstRects.get(id);
if (!first) return; // newly entered, handled separately
var el = grid.querySelector('.card[data-id="' + id + '"]');
if (!el) return;
var last = el.getBoundingClientRect();
var dx = first.left - last.left;
var dy = first.top - last.top;
if (dx || dy) {
moved++;
el.style.transition = "none";
el.style.transform = "translate(" + dx + "px," + dy + "px)";
}
});
// Force a reflow so the inverted transforms are committed before we play.
void grid.offsetWidth;
// ---- PLAY ----
animating = true;
requestAnimationFrame(function () {
requestAnimationFrame(function () {
// Kept nodes slide to zero.
wanted.forEach(function (it) {
var id = String(it.id);
if (!firstRects.has(id)) return;
var el = grid.querySelector('.card[data-id="' + id + '"]');
if (!el) return;
el.style.transition =
"transform " + DURATION + "ms " + EASING;
el.style.transform = "translate(0,0)";
});
// Entering nodes scale+fade in.
enters.forEach(function (el) {
el.style.transition =
"transform " + DURATION + "ms " + EASING +
", opacity " + DURATION + "ms ease";
el.classList.remove("is-enter");
});
// Exiting ghosts fade + shrink, then are removed.
exits.forEach(function (el) {
el.style.transition =
"transform " + DURATION * 0.7 + "ms ease, opacity " +
DURATION * 0.7 + "ms ease";
el.classList.add("is-exit");
});
});
});
// Cleanup after the transition finishes.
window.setTimeout(function () {
wanted.forEach(function (it) {
var el = grid.querySelector('.card[data-id="' + it.id + '"]');
if (el) {
el.style.transition = "";
el.style.transform = "";
}
});
exits.forEach(function (el) {
if (el.parentNode) el.parentNode.removeChild(el);
});
animating = false;
}, DURATION + 40);
report(reason, wanted.length, moved, exits.length, enters.length);
}
function report(reason, total, moved, removed, added) {
var parts = [];
if (reason) parts.push(reason);
parts.push(total + " shown");
if (moved) parts.push("<b>" + moved + " moved</b>");
if (added) parts.push(added + " added");
if (removed) parts.push(removed + " removed");
status.innerHTML = parts.join(" · ");
}
/* ---------- Actions ---------- */
function shuffle() {
for (var i = items.length - 1; i > 0; i--) {
var j = (Math.random() * (i + 1)) | 0;
var t = items[i];
items[i] = items[j];
items[j] = t;
}
render("Shuffled");
}
function sortByName() {
items.sort(function (a, b) {
return a.name.localeCompare(b.name);
});
render("Sorted A–Z");
}
function sortByWeight() {
items.sort(function (a, b) {
return b.weight - a.weight;
});
render("Sorted by weight");
}
function addCard() {
var name = NAMES[nextName % NAMES.length] +
(nextName >= NAMES.length ? " " + (Math.floor(nextName / NAMES.length) + 1) : "");
nextName++;
var it = makeItem(name);
// Insert near the front so the move is visible.
items.splice((Math.random() * (items.length + 1)) | 0, 0, it);
render("Added a card");
}
function removeLast() {
if (!items.length) {
report("Nothing to remove", 0, 0, 0, 0);
return;
}
items.pop();
render("Removed a card");
}
/* ---------- Wiring ---------- */
var ACTIONS = {
shuffle: shuffle,
"sort-name": sortByName,
"sort-weight": sortByWeight,
add: addCard,
remove: removeLast
};
document.querySelector(".toolbar").addEventListener("click", function (e) {
var btn = e.target.closest("[data-act]");
if (!btn) return;
if (animating) return; // avoid overlapping FLIP passes
var fn = ACTIONS[btn.dataset.act];
if (fn) fn();
});
// Build filter chips.
function buildChips() {
var defs = [{ id: "all", label: "All" }].concat(
CAT_KEYS.map(function (k) {
return { id: k, label: CATEGORIES[k].label };
})
);
defs.forEach(function (d) {
var b = document.createElement("button");
b.type = "button";
b.className = "chip";
b.role = "tab";
b.dataset.filter = d.id;
b.textContent = d.label;
b.setAttribute("aria-selected", d.id === activeFilter ? "true" : "false");
chipsWrap.appendChild(b);
});
}
chipsWrap.addEventListener("click", function (e) {
var chip = e.target.closest(".chip");
if (!chip || animating) return;
activeFilter = chip.dataset.filter;
Array.prototype.forEach.call(chipsWrap.children, function (c) {
c.setAttribute(
"aria-selected",
c.dataset.filter === activeFilter ? "true" : "false"
);
});
render("Filter: " + (activeFilter === "all" ? "All" : CATEGORIES[activeFilter].label));
});
reduceToggle.addEventListener("change", function () {
render(reduceToggle.checked ? "Reduced motion on" : "Reduced motion off");
});
/* ---------- Init ---------- */
buildChips();
// Initial paint (no FLIP needed — nothing to invert from).
visibleItems().forEach(function (it) {
grid.appendChild(cardEl(it));
});
report("Ready", items.length, 0, 0, 0);
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>FLIP Layout Animation</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<main class="wrap">
<header class="head">
<div class="head__title">
<h1>FLIP Layout Animation</h1>
<p class="sub">
A reflowing grid where every item glides between layouts using the
<strong>First · Last · Invert · Play</strong> technique —
only <code>transform</code> and <code>opacity</code> are animated.
</p>
</div>
<label class="switch" title="Snap instantly, no motion">
<input type="checkbox" id="reduce" />
<span class="switch__track" aria-hidden="true"><span class="switch__thumb"></span></span>
<span class="switch__label">Reduced motion</span>
</label>
</header>
<section class="toolbar" aria-label="Layout controls">
<div class="toolbar__group" role="group" aria-label="Rearrange">
<button class="btn btn--accent" data-act="shuffle" type="button">Shuffle</button>
<button class="btn" data-act="sort-name" type="button">Sort A–Z</button>
<button class="btn" data-act="sort-weight" type="button">Sort by weight</button>
</div>
<div class="toolbar__group" role="group" aria-label="Filter by category">
<span class="toolbar__label">Filter</span>
<div class="chips" id="chips" role="tablist" aria-label="Category filter"></div>
</div>
<div class="toolbar__group" role="group" aria-label="Add or remove">
<button class="btn" data-act="add" type="button">+ Add card</button>
<button class="btn btn--ghost" data-act="remove" type="button">− Remove last</button>
</div>
</section>
<p class="status" id="status" role="status" aria-live="polite">Ready.</p>
<ul class="grid" id="grid" aria-label="Animated card grid"></ul>
<section class="note" aria-label="How FLIP works">
<h2 class="note__title">How the FLIP technique works</h2>
<ol class="flip-steps">
<li class="flip-step">
<span class="flip-step__k">F</span>
<span class="flip-step__body">
<strong>First</strong> — before touching the DOM, record every item's current
bounding box with <code>getBoundingClientRect()</code>.
</span>
</li>
<li class="flip-step">
<span class="flip-step__k">L</span>
<span class="flip-step__body">
<strong>Last</strong> — mutate the DOM (reorder, filter, add) and let the browser
reflow to the final layout. Measure the new boxes.
</span>
</li>
<li class="flip-step">
<span class="flip-step__k">I</span>
<span class="flip-step__body">
<strong>Invert</strong> — apply a <code>transform</code> that offsets each item back
to where it started, so visually nothing moved yet.
</span>
</li>
<li class="flip-step">
<span class="flip-step__k">P</span>
<span class="flip-step__body">
<strong>Play</strong> — on the next frame, clear the transform with a transition.
The GPU composites the slide — no layout thrash, no jank.
</span>
</li>
</ol>
<p class="note__foot">
Because only <code>transform</code> / <code>opacity</code> animate, the work stays on the
compositor thread. Animating <code>top</code>/<code>left</code>/<code>width</code> instead
would trigger layout on every frame.
</p>
</section>
</main>
<script src="script.js"></script>
</body>
</html>FLIP Layout Animation shows how to animate a reflowing grid without animating layout properties directly. Instead of tweening top, left, width, or order — which force expensive reflows and cannot be GPU-composited — the component records each item’s First bounding box, mutates the DOM into its Last state, then Inverts the visual delta with a transform and Plays the transform back to zero. The browser only ever composites transform and opacity, keeping the motion on the fast path.
Use the toolbar to shuffle the deck, sort by name or weight, filter by category, or add and remove cards. Every mutation triggers a fresh First/Last measurement pass, so items smoothly slide to their new slots while entering cards scale-and-fade in and leaving cards fade out before removal. A live status line reports how many items moved on the last transition, and an annotated panel maps each on-screen stage back to the four FLIP letters.
The implementation is dependency-free vanilla JS and CSS. It is DPR-agnostic, keyboard-usable, and honors prefers-reduced-motion by snapping straight to the Last state with no inversion. It is a compact, production-minded reference for building sortable dashboards, kanban boards, gallery filters, or any interface where content rearranges and you want the motion to explain the change.