Patterns Medium
Layout Animation
Elements smoothly animate their layout position when reordered or resized using the FLIP (First, Last, Invert, Play) technique. Shuffle and filter grid items with fluid transitions.
Open in Lab
MCP
css javascript vue svelte
Targets: TS JS HTML React Vue Svelte
Code
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: system-ui, -apple-system, sans-serif;
background: #0a0a0a;
color: #e4e4e7;
min-height: 100vh;
display: grid;
place-items: center;
padding: 2rem;
}
.demo {
width: min(560px, 100%);
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.demo-title {
font-size: 1.25rem;
font-weight: 700;
color: #f4f4f5;
}
.demo-subtitle {
font-size: 0.8rem;
color: #52525b;
margin-top: 0.25rem;
}
.controls {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.btn {
padding: 0.45rem 0.9rem;
font-size: 0.75rem;
font-weight: 600;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 0.5rem;
cursor: pointer;
background: rgba(255, 255, 255, 0.05);
color: #a1a1aa;
transition: background 0.15s, border-color 0.15s, color 0.15s;
}
.btn:hover {
background: rgba(255, 255, 255, 0.1);
color: #e4e4e7;
}
.btn.active {
background: #6d28d9;
border-color: #7c3aed;
color: #f4f4f5;
}
/* ── Grid ── */
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
gap: 0.75rem;
}
.grid-item {
aspect-ratio: 1;
border-radius: 0.75rem;
display: grid;
place-items: center;
font-size: 0.75rem;
font-weight: 700;
color: rgba(255, 255, 255, 0.7);
letter-spacing: 0.04em;
border: 1px solid rgba(255, 255, 255, 0.08);
transition: opacity 0.2s;
will-change: transform;
}
.grid-item.hidden {
display: none;
}
.grid-item .item-label {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.35rem;
}
.grid-item .item-emoji {
font-size: 1.5rem;
}(function () {
"use strict";
const grid = document.getElementById("grid");
const btnShuffle = document.getElementById("btn-shuffle");
const filterBtns = document.querySelectorAll("[data-filter]");
const items = [
{
id: 1,
label: "Figma",
emoji: "🎨",
cat: "design",
bg: "rgba(168,85,247,0.2)",
border: "rgba(168,85,247,0.4)",
},
{
id: 2,
label: "React",
emoji: "⚛️",
cat: "dev",
bg: "rgba(59,130,246,0.2)",
border: "rgba(59,130,246,0.4)",
},
{
id: 3,
label: "D3.js",
emoji: "📊",
cat: "data",
bg: "rgba(16,185,129,0.2)",
border: "rgba(16,185,129,0.4)",
},
{
id: 4,
label: "Sketch",
emoji: "💎",
cat: "design",
bg: "rgba(236,72,153,0.2)",
border: "rgba(236,72,153,0.4)",
},
{
id: 5,
label: "Node",
emoji: "🟢",
cat: "dev",
bg: "rgba(34,197,94,0.2)",
border: "rgba(34,197,94,0.4)",
},
{
id: 6,
label: "SQL",
emoji: "🗄️",
cat: "data",
bg: "rgba(245,158,11,0.2)",
border: "rgba(245,158,11,0.4)",
},
{
id: 7,
label: "Color",
emoji: "🌈",
cat: "design",
bg: "rgba(239,68,68,0.2)",
border: "rgba(239,68,68,0.4)",
},
{
id: 8,
label: "TS",
emoji: "📘",
cat: "dev",
bg: "rgba(14,165,233,0.2)",
border: "rgba(14,165,233,0.4)",
},
{
id: 9,
label: "Charts",
emoji: "📈",
cat: "data",
bg: "rgba(168,85,247,0.2)",
border: "rgba(168,85,247,0.4)",
},
{
id: 10,
label: "Proto",
emoji: "🖼️",
cat: "design",
bg: "rgba(109,40,217,0.2)",
border: "rgba(109,40,217,0.4)",
},
{
id: 11,
label: "Rust",
emoji: "🦀",
cat: "dev",
bg: "rgba(239,68,68,0.2)",
border: "rgba(239,68,68,0.4)",
},
{
id: 12,
label: "ML",
emoji: "🤖",
cat: "data",
bg: "rgba(59,130,246,0.2)",
border: "rgba(59,130,246,0.4)",
},
];
let currentFilter = "all";
let order = items.map((_, i) => i);
function renderGrid() {
grid.innerHTML = "";
order.forEach((idx) => {
const item = items[idx];
const el = document.createElement("div");
el.className = "grid-item";
el.dataset.id = item.id;
el.dataset.cat = item.cat;
el.style.background = item.bg;
el.style.borderColor = item.border;
if (currentFilter !== "all" && item.cat !== currentFilter) {
el.classList.add("hidden");
}
el.innerHTML = `<div class="item-label"><span class="item-emoji">${item.emoji}</span>${item.label}</div>`;
grid.appendChild(el);
});
}
// ── FLIP ──
function flipAnimate(callback) {
// FIRST: capture positions
const firstRects = {};
grid.querySelectorAll(".grid-item:not(.hidden)").forEach((el) => {
firstRects[el.dataset.id] = el.getBoundingClientRect();
});
// Execute the DOM change
callback();
// LAST: capture new positions
const els = grid.querySelectorAll(".grid-item:not(.hidden)");
els.forEach((el) => {
const id = el.dataset.id;
const first = firstRects[id];
const last = el.getBoundingClientRect();
if (first) {
// INVERT
const dx = first.left - last.left;
const dy = first.top - last.top;
if (dx === 0 && dy === 0) return;
el.style.transform = `translate(${dx}px, ${dy}px)`;
el.style.transition = "none";
// PLAY
requestAnimationFrame(() => {
requestAnimationFrame(() => {
el.style.transform = "";
el.style.transition = "transform 0.4s cubic-bezier(0.22, 1, 0.36, 1)";
el.addEventListener(
"transitionend",
() => {
el.style.transition = "";
},
{ once: true }
);
});
});
} else {
// New element appearing — fade in
el.style.opacity = "0";
el.style.transform = "scale(0.8)";
requestAnimationFrame(() => {
requestAnimationFrame(() => {
el.style.transition = "opacity 0.35s, transform 0.35s cubic-bezier(0.22,1,0.36,1)";
el.style.opacity = "1";
el.style.transform = "scale(1)";
el.addEventListener(
"transitionend",
() => {
el.style.transition = "";
},
{ once: true }
);
});
});
}
});
}
// ── Shuffle ──
function shuffle() {
flipAnimate(() => {
for (let i = order.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[order[i], order[j]] = [order[j], order[i]];
}
renderGrid();
});
}
// ── Filter ──
function filter(cat) {
flipAnimate(() => {
currentFilter = cat;
renderGrid();
});
filterBtns.forEach((btn) => {
btn.classList.toggle("active", btn.dataset.filter === cat);
});
}
// ── Events ──
btnShuffle.addEventListener("click", shuffle);
filterBtns.forEach((btn) => {
btn.addEventListener("click", () => filter(btn.dataset.filter));
});
// Initial render
renderGrid();
})();<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Layout Animation</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="demo">
<div>
<h2 class="demo-title">Layout Animation</h2>
<p class="demo-subtitle">FLIP technique for smooth layout transitions</p>
</div>
<div class="controls">
<button class="btn active" data-filter="all">All</button>
<button class="btn" data-filter="design">Design</button>
<button class="btn" data-filter="dev">Dev</button>
<button class="btn" data-filter="data">Data</button>
<button class="btn" id="btn-shuffle">Shuffle</button>
</div>
<div class="grid" id="grid"></div>
</div>
<script src="script.js"></script>
</body>
</html>import { useState, useRef, useCallback, useLayoutEffect } from "react";
interface GridItem {
id: number;
label: string;
emoji: string;
cat: string;
bg: string;
border: string;
}
const items: GridItem[] = [
{
id: 1,
label: "Figma",
emoji: "🎨",
cat: "design",
bg: "rgba(168,85,247,0.2)",
border: "rgba(168,85,247,0.4)",
},
{
id: 2,
label: "React",
emoji: "⚛️",
cat: "dev",
bg: "rgba(59,130,246,0.2)",
border: "rgba(59,130,246,0.4)",
},
{
id: 3,
label: "D3.js",
emoji: "📊",
cat: "data",
bg: "rgba(16,185,129,0.2)",
border: "rgba(16,185,129,0.4)",
},
{
id: 4,
label: "Sketch",
emoji: "💎",
cat: "design",
bg: "rgba(236,72,153,0.2)",
border: "rgba(236,72,153,0.4)",
},
{
id: 5,
label: "Node",
emoji: "🟢",
cat: "dev",
bg: "rgba(34,197,94,0.2)",
border: "rgba(34,197,94,0.4)",
},
{
id: 6,
label: "SQL",
emoji: "🗄️",
cat: "data",
bg: "rgba(245,158,11,0.2)",
border: "rgba(245,158,11,0.4)",
},
{
id: 7,
label: "Color",
emoji: "🌈",
cat: "design",
bg: "rgba(239,68,68,0.2)",
border: "rgba(239,68,68,0.4)",
},
{
id: 8,
label: "TS",
emoji: "📘",
cat: "dev",
bg: "rgba(14,165,233,0.2)",
border: "rgba(14,165,233,0.4)",
},
{
id: 9,
label: "Charts",
emoji: "📈",
cat: "data",
bg: "rgba(168,85,247,0.2)",
border: "rgba(168,85,247,0.4)",
},
{
id: 10,
label: "Proto",
emoji: "🖼️",
cat: "design",
bg: "rgba(109,40,217,0.2)",
border: "rgba(109,40,217,0.4)",
},
{
id: 11,
label: "Rust",
emoji: "🦀",
cat: "dev",
bg: "rgba(239,68,68,0.2)",
border: "rgba(239,68,68,0.4)",
},
{
id: 12,
label: "ML",
emoji: "🤖",
cat: "data",
bg: "rgba(59,130,246,0.2)",
border: "rgba(59,130,246,0.4)",
},
];
const filters = ["all", "design", "dev", "data"];
export default function LayoutAnimation() {
const [order, setOrder] = useState(() => items.map((_, i) => i));
const [filter, setFilter] = useState("all");
const gridRef = useRef<HTMLDivElement>(null);
const rectsRef = useRef<Record<number, DOMRect>>({});
// Capture positions before render
const captureRects = useCallback(() => {
if (!gridRef.current) return;
const rects: Record<number, DOMRect> = {};
gridRef.current.querySelectorAll<HTMLElement>("[data-id]").forEach((el) => {
rects[Number(el.dataset.id)] = el.getBoundingClientRect();
});
rectsRef.current = rects;
}, []);
// FLIP after render
useLayoutEffect(() => {
if (!gridRef.current) return;
const firstRects = rectsRef.current;
const els = gridRef.current.querySelectorAll<HTMLElement>("[data-id]");
els.forEach((el) => {
const id = Number(el.dataset.id);
const first = firstRects[id];
const last = el.getBoundingClientRect();
if (first) {
const dx = first.left - last.left;
const dy = first.top - last.top;
if (dx === 0 && dy === 0) return;
el.style.transform = `translate(${dx}px, ${dy}px)`;
el.style.transition = "none";
requestAnimationFrame(() => {
requestAnimationFrame(() => {
el.style.transform = "";
el.style.transition = "transform 0.4s cubic-bezier(0.22, 1, 0.36, 1)";
el.addEventListener(
"transitionend",
() => {
el.style.transition = "";
},
{ once: true }
);
});
});
} else {
el.style.opacity = "0";
el.style.transform = "scale(0.8)";
requestAnimationFrame(() => {
requestAnimationFrame(() => {
el.style.transition = "opacity 0.35s, transform 0.35s cubic-bezier(0.22,1,0.36,1)";
el.style.opacity = "1";
el.style.transform = "scale(1)";
el.addEventListener(
"transitionend",
() => {
el.style.transition = "";
},
{ once: true }
);
});
});
}
});
}, [order, filter]);
const shuffle = () => {
captureRects();
setOrder((prev) => {
const arr = [...prev];
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]];
}
return arr;
});
};
const changeFilter = (f: string) => {
captureRects();
setFilter(f);
};
const visible = order.filter((idx) => filter === "all" || items[idx].cat === filter);
return (
<div
style={{
minHeight: "100vh",
background: "#0a0a0a",
display: "grid",
placeItems: "center",
padding: "2rem",
fontFamily: "system-ui, -apple-system, sans-serif",
color: "#e4e4e7",
}}
>
<div
style={{
width: "min(560px, 100%)",
display: "flex",
flexDirection: "column",
gap: "1.25rem",
}}
>
<div>
<h2 style={{ fontSize: "1.25rem", fontWeight: 700, color: "#f4f4f5" }}>
Layout Animation
</h2>
<p style={{ fontSize: "0.8rem", color: "#52525b", marginTop: "0.25rem" }}>
FLIP technique for smooth layout transitions
</p>
</div>
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
{filters.map((f) => (
<button
key={f}
onClick={() => changeFilter(f)}
style={{
padding: "0.45rem 0.9rem",
fontSize: "0.75rem",
fontWeight: 600,
border: `1px solid ${filter === f ? "#7c3aed" : "rgba(255,255,255,0.1)"}`,
borderRadius: "0.5rem",
cursor: "pointer",
background: filter === f ? "#6d28d9" : "rgba(255,255,255,0.05)",
color: filter === f ? "#f4f4f5" : "#a1a1aa",
textTransform: "capitalize",
}}
>
{f}
</button>
))}
<button
onClick={shuffle}
style={{
padding: "0.45rem 0.9rem",
fontSize: "0.75rem",
fontWeight: 600,
border: "1px solid rgba(255,255,255,0.1)",
borderRadius: "0.5rem",
cursor: "pointer",
background: "rgba(255,255,255,0.05)",
color: "#a1a1aa",
}}
>
Shuffle
</button>
</div>
<div
ref={gridRef}
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(100px, 1fr))",
gap: "0.75rem",
}}
>
{visible.map((idx) => {
const item = items[idx];
return (
<div
key={item.id}
data-id={item.id}
style={{
aspectRatio: "1",
borderRadius: "0.75rem",
display: "grid",
placeItems: "center",
fontSize: "0.75rem",
fontWeight: 700,
color: "rgba(255,255,255,0.7)",
letterSpacing: "0.04em",
background: item.bg,
border: `1px solid ${item.border}`,
willChange: "transform",
}}
>
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: "0.35rem",
}}
>
<span style={{ fontSize: "1.5rem" }}>{item.emoji}</span>
{item.label}
</div>
</div>
);
})}
</div>
</div>
</div>
);
}<script setup>
import { ref, computed, nextTick, onMounted } from "vue";
const items = [
{
id: 1,
label: "Figma",
emoji: "\u{1F3A8}",
cat: "design",
bg: "rgba(168,85,247,0.2)",
border: "rgba(168,85,247,0.4)",
},
{
id: 2,
label: "React",
emoji: "\u269B\uFE0F",
cat: "dev",
bg: "rgba(59,130,246,0.2)",
border: "rgba(59,130,246,0.4)",
},
{
id: 3,
label: "D3.js",
emoji: "\u{1F4CA}",
cat: "data",
bg: "rgba(16,185,129,0.2)",
border: "rgba(16,185,129,0.4)",
},
{
id: 4,
label: "Sketch",
emoji: "\u{1F48E}",
cat: "design",
bg: "rgba(236,72,153,0.2)",
border: "rgba(236,72,153,0.4)",
},
{
id: 5,
label: "Node",
emoji: "\u{1F7E2}",
cat: "dev",
bg: "rgba(34,197,94,0.2)",
border: "rgba(34,197,94,0.4)",
},
{
id: 6,
label: "SQL",
emoji: "\u{1F5C4}\uFE0F",
cat: "data",
bg: "rgba(245,158,11,0.2)",
border: "rgba(245,158,11,0.4)",
},
{
id: 7,
label: "Color",
emoji: "\u{1F308}",
cat: "design",
bg: "rgba(239,68,68,0.2)",
border: "rgba(239,68,68,0.4)",
},
{
id: 8,
label: "TS",
emoji: "\u{1F4D8}",
cat: "dev",
bg: "rgba(14,165,233,0.2)",
border: "rgba(14,165,233,0.4)",
},
{
id: 9,
label: "Charts",
emoji: "\u{1F4C8}",
cat: "data",
bg: "rgba(168,85,247,0.2)",
border: "rgba(168,85,247,0.4)",
},
{
id: 10,
label: "Proto",
emoji: "\u{1F5BC}\uFE0F",
cat: "design",
bg: "rgba(109,40,217,0.2)",
border: "rgba(109,40,217,0.4)",
},
{
id: 11,
label: "Rust",
emoji: "\u{1F980}",
cat: "dev",
bg: "rgba(239,68,68,0.2)",
border: "rgba(239,68,68,0.4)",
},
{
id: 12,
label: "ML",
emoji: "\u{1F916}",
cat: "data",
bg: "rgba(59,130,246,0.2)",
border: "rgba(59,130,246,0.4)",
},
];
const filters = ["all", "design", "dev", "data"];
const order = ref(items.map((_, i) => i));
const filter = ref("all");
const gridEl = ref(null);
let savedRects = {};
function captureRects() {
if (!gridEl.value) return;
const rects = {};
gridEl.value.querySelectorAll("[data-id]").forEach((el) => {
rects[Number(el.dataset.id)] = el.getBoundingClientRect();
});
savedRects = rects;
}
function animateAfterUpdate() {
nextTick(() => {
if (!gridEl.value) return;
const els = gridEl.value.querySelectorAll("[data-id]");
els.forEach((el) => {
const id = Number(el.dataset.id);
const first = savedRects[id];
const last = el.getBoundingClientRect();
if (first) {
const dx = first.left - last.left;
const dy = first.top - last.top;
if (dx === 0 && dy === 0) return;
el.style.transform = `translate(${dx}px, ${dy}px)`;
el.style.transition = "none";
requestAnimationFrame(() => {
requestAnimationFrame(() => {
el.style.transform = "";
el.style.transition = "transform 0.4s cubic-bezier(0.22, 1, 0.36, 1)";
el.addEventListener(
"transitionend",
() => {
el.style.transition = "";
},
{ once: true }
);
});
});
} else {
el.style.opacity = "0";
el.style.transform = "scale(0.8)";
requestAnimationFrame(() => {
requestAnimationFrame(() => {
el.style.transition = "opacity 0.35s, transform 0.35s cubic-bezier(0.22,1,0.36,1)";
el.style.opacity = "1";
el.style.transform = "scale(1)";
el.addEventListener(
"transitionend",
() => {
el.style.transition = "";
},
{ once: true }
);
});
});
}
});
});
}
function shuffle() {
captureRects();
const arr = [...order.value];
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]];
}
order.value = arr;
animateAfterUpdate();
}
function changeFilter(f) {
captureRects();
filter.value = f;
animateAfterUpdate();
}
const visible = computed(() =>
order.value.filter((idx) => filter.value === "all" || items[idx].cat === filter.value)
);
function btnStyle(f) {
const active = filter.value === f;
return {
padding: "0.45rem 0.9rem",
fontSize: "0.75rem",
fontWeight: "600",
border: `1px solid ${active ? "#7c3aed" : "rgba(255,255,255,0.1)"}`,
borderRadius: "0.5rem",
cursor: "pointer",
background: active ? "#6d28d9" : "rgba(255,255,255,0.05)",
color: active ? "#f4f4f5" : "#a1a1aa",
textTransform: "capitalize",
};
}
</script>
<template>
<div style="min-height:100vh;background:#0a0a0a;display:grid;place-items:center;padding:2rem;font-family:system-ui,-apple-system,sans-serif;color:#e4e4e7">
<div style="width:min(560px,100%);display:flex;flex-direction:column;gap:1.25rem">
<div>
<h2 style="font-size:1.25rem;font-weight:700;color:#f4f4f5">Layout Animation</h2>
<p style="font-size:0.8rem;color:#52525b;margin-top:0.25rem">FLIP technique for smooth layout transitions</p>
</div>
<div style="display:flex;gap:0.5rem;flex-wrap:wrap">
<button
v-for="f in filters"
:key="f"
:style="btnStyle(f)"
@click="changeFilter(f)"
>{{ f }}</button>
<button
:style="{
padding: '0.45rem 0.9rem',
fontSize: '0.75rem',
fontWeight: '600',
border: '1px solid rgba(255,255,255,0.1)',
borderRadius: '0.5rem',
cursor: 'pointer',
background: 'rgba(255,255,255,0.05)',
color: '#a1a1aa',
}"
@click="shuffle"
>Shuffle</button>
</div>
<div ref="gridEl" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(100px,1fr));gap:0.75rem">
<div
v-for="idx in visible"
:key="items[idx].id"
:data-id="items[idx].id"
:style="{
aspectRatio: '1',
borderRadius: '0.75rem',
display: 'grid',
placeItems: 'center',
fontSize: '0.75rem',
fontWeight: '700',
color: 'rgba(255,255,255,0.7)',
letterSpacing: '0.04em',
background: items[idx].bg,
border: `1px solid ${items[idx].border}`,
willChange: 'transform',
}"
>
<div style="display:flex;flex-direction:column;align-items:center;gap:0.35rem">
<span style="font-size:1.5rem">{{ items[idx].emoji }}</span>
{{ items[idx].label }}
</div>
</div>
</div>
</div>
</div>
</template><script>
import { onMount, tick } from "svelte";
const items = [
{
id: 1,
label: "Figma",
emoji: "\u{1F3A8}",
cat: "design",
bg: "rgba(168,85,247,0.2)",
border: "rgba(168,85,247,0.4)",
},
{
id: 2,
label: "React",
emoji: "\u269B\uFE0F",
cat: "dev",
bg: "rgba(59,130,246,0.2)",
border: "rgba(59,130,246,0.4)",
},
{
id: 3,
label: "D3.js",
emoji: "\u{1F4CA}",
cat: "data",
bg: "rgba(16,185,129,0.2)",
border: "rgba(16,185,129,0.4)",
},
{
id: 4,
label: "Sketch",
emoji: "\u{1F48E}",
cat: "design",
bg: "rgba(236,72,153,0.2)",
border: "rgba(236,72,153,0.4)",
},
{
id: 5,
label: "Node",
emoji: "\u{1F7E2}",
cat: "dev",
bg: "rgba(34,197,94,0.2)",
border: "rgba(34,197,94,0.4)",
},
{
id: 6,
label: "SQL",
emoji: "\u{1F5C4}\uFE0F",
cat: "data",
bg: "rgba(245,158,11,0.2)",
border: "rgba(245,158,11,0.4)",
},
{
id: 7,
label: "Color",
emoji: "\u{1F308}",
cat: "design",
bg: "rgba(239,68,68,0.2)",
border: "rgba(239,68,68,0.4)",
},
{
id: 8,
label: "TS",
emoji: "\u{1F4D8}",
cat: "dev",
bg: "rgba(14,165,233,0.2)",
border: "rgba(14,165,233,0.4)",
},
{
id: 9,
label: "Charts",
emoji: "\u{1F4C8}",
cat: "data",
bg: "rgba(168,85,247,0.2)",
border: "rgba(168,85,247,0.4)",
},
{
id: 10,
label: "Proto",
emoji: "\u{1F5BC}\uFE0F",
cat: "design",
bg: "rgba(109,40,217,0.2)",
border: "rgba(109,40,217,0.4)",
},
{
id: 11,
label: "Rust",
emoji: "\u{1F980}",
cat: "dev",
bg: "rgba(239,68,68,0.2)",
border: "rgba(239,68,68,0.4)",
},
{
id: 12,
label: "ML",
emoji: "\u{1F916}",
cat: "data",
bg: "rgba(59,130,246,0.2)",
border: "rgba(59,130,246,0.4)",
},
];
const filters = ["all", "design", "dev", "data"];
let order = items.map((_, i) => i);
let filter = "all";
let gridEl;
let savedRects = {};
function captureRects() {
if (!gridEl) return;
const rects = {};
gridEl.querySelectorAll("[data-id]").forEach((el) => {
rects[Number(el.dataset.id)] = el.getBoundingClientRect();
});
savedRects = rects;
}
async function animateAfterUpdate() {
await tick();
if (!gridEl) return;
const els = gridEl.querySelectorAll("[data-id]");
els.forEach((el) => {
const id = Number(el.dataset.id);
const first = savedRects[id];
const last = el.getBoundingClientRect();
if (first) {
const dx = first.left - last.left;
const dy = first.top - last.top;
if (dx === 0 && dy === 0) return;
el.style.transform = `translate(${dx}px, ${dy}px)`;
el.style.transition = "none";
requestAnimationFrame(() => {
requestAnimationFrame(() => {
el.style.transform = "";
el.style.transition = "transform 0.4s cubic-bezier(0.22, 1, 0.36, 1)";
el.addEventListener(
"transitionend",
() => {
el.style.transition = "";
},
{ once: true }
);
});
});
} else {
el.style.opacity = "0";
el.style.transform = "scale(0.8)";
requestAnimationFrame(() => {
requestAnimationFrame(() => {
el.style.transition = "opacity 0.35s, transform 0.35s cubic-bezier(0.22,1,0.36,1)";
el.style.opacity = "1";
el.style.transform = "scale(1)";
el.addEventListener(
"transitionend",
() => {
el.style.transition = "";
},
{ once: true }
);
});
});
}
});
}
function shuffle() {
captureRects();
const arr = [...order];
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]];
}
order = arr;
animateAfterUpdate();
}
function changeFilter(f) {
captureRects();
filter = f;
animateAfterUpdate();
}
$: visible = order.filter((idx) => filter === "all" || items[idx].cat === filter);
</script>
<div style="min-height:100vh;background:#0a0a0a;display:grid;place-items:center;padding:2rem;font-family:system-ui,-apple-system,sans-serif;color:#e4e4e7">
<div style="width:min(560px,100%);display:flex;flex-direction:column;gap:1.25rem">
<div>
<h2 style="font-size:1.25rem;font-weight:700;color:#f4f4f5">Layout Animation</h2>
<p style="font-size:0.8rem;color:#52525b;margin-top:0.25rem">FLIP technique for smooth layout transitions</p>
</div>
<div style="display:flex;gap:0.5rem;flex-wrap:wrap">
{#each filters as f}
<button
style="padding:0.45rem 0.9rem;font-size:0.75rem;font-weight:600;border:1px solid {filter === f ? '#7c3aed' : 'rgba(255,255,255,0.1)'};border-radius:0.5rem;cursor:pointer;background:{filter === f ? '#6d28d9' : 'rgba(255,255,255,0.05)'};color:{filter === f ? '#f4f4f5' : '#a1a1aa'};text-transform:capitalize"
on:click={() => changeFilter(f)}
>{f}</button>
{/each}
<button
style="padding:0.45rem 0.9rem;font-size:0.75rem;font-weight:600;border:1px solid rgba(255,255,255,0.1);border-radius:0.5rem;cursor:pointer;background:rgba(255,255,255,0.05);color:#a1a1aa"
on:click={shuffle}
>Shuffle</button>
</div>
<div bind:this={gridEl} style="display:grid;grid-template-columns:repeat(auto-fill,minmax(100px,1fr));gap:0.75rem">
{#each visible as idx (items[idx].id)}
<div
data-id={items[idx].id}
style="aspect-ratio:1;border-radius:0.75rem;display:grid;place-items:center;font-size:0.75rem;font-weight:700;color:rgba(255,255,255,0.7);letter-spacing:0.04em;background:{items[idx].bg};border:1px solid {items[idx].border};will-change:transform"
>
<div style="display:flex;flex-direction:column;align-items:center;gap:0.35rem">
<span style="font-size:1.5rem">{items[idx].emoji}</span>
{items[idx].label}
</div>
</div>
{/each}
</div>
</div>
</div>Layout Animation
Smoothly animate elements between layout positions using the FLIP technique (First, Last, Invert, Play). When items are shuffled, filtered, or resized, they glide to their new positions instead of jumping.
How it works
- First — capture every element’s bounding rect before the DOM change
- Last — perform the DOM change (reorder, filter, resize), then capture the new rects
- Invert — apply a transform that moves each element from its new position back to the old one
- Play — remove the transform with a CSS transition so elements animate to their final position
Use cases
- Photo / product grid filtering
- Shuffling card decks
- Kanban column reordering
- Dashboard widget rearrangement