Live preview needs JavaScript. The code below works without any dependencies.
Prompt for your AI assistant
Paste this into Claude, Cursor or ChatGPT. The settings update as you move the controls above.
Build "FLIP filter grid" for my site using the Enrichr Library element at https://enrichr.dev/elements/flip-filter-grid.html.
Read the machine-readable spec and source at https://enrichr.dev/elements/flip-filter-grid.json (markup, CSS, JavaScript, parameters).
Requirements: vanilla JavaScript and CSS, no new dependencies; keep the behaviour of the source unchanged; respect prefers-reduced-motion; work on touch screens; keep it accessible.
Settings:
- accent: #e6ff55
- columns: 4
- count: 9
- duration: 500
- stagger: 30
Verify: reduced motion shows no movement, it works at 375px wide, there are no console errors, and scrolling stays smooth.The same spec as JSON, for tools that read it directly: /elements/flip-filter-grid.json.
Code
Mount it on an element that contains the markup. It returns an object with update(options) and destroy().
HTML
<div class="ef-ffg">
<div class="ef-ffg__wrap">
<div class="ef-ffg__bar" role="group" aria-label="Filter by category"></div>
<ul class="ef-ffg__grid"></ul>
<p class="ef-ffg__status" role="status" aria-live="polite"></p>
</div>
</div>CSS
/* Enrichr Library · https://enrichr.dev */
.ef-ffg { width: 100%; }
.ef-ffg__wrap { --ef-cols: 4; --ef-gap: 0.8rem; box-sizing: border-box; width: 100%; min-height: 20rem; padding: 1.1rem; border-radius: 1rem; overflow: hidden; color: #fbf8f1; font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
.ef-ffg__bar { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-bottom: 1rem; }
.ef-ffg__btn { padding: 0.45rem 0.95rem; border-radius: 999px; border: 1.5px solid rgba(251, 248, 241, 0.3); background: transparent; color: inherit; font: inherit; font-size: 0.82rem; font-weight: 700; letter-spacing: 0.02em; cursor: pointer; transition: background 200ms, color 200ms, border-color 200ms; }
.ef-ffg__btn:hover { border-color: var(--ef-acc, #e6ff55); }
.ef-ffg__btn[aria-pressed="true"] { background: var(--ef-acc, #e6ff55); border-color: var(--ef-acc, #e6ff55); color: #0c1e18; }
.ef-ffg__btn:focus-visible { outline: 2px solid var(--ef-acc, #e6ff55); outline-offset: 2px; }
.ef-ffg__grid { position: relative; display: grid; grid-template-columns: repeat(var(--ef-cols), minmax(0, 1fr)); gap: var(--ef-gap); margin: 0; padding: 0; list-style: none; }
.ef-ffg__tile { position: relative; aspect-ratio: 1; border-radius: 0.7rem; overflow: hidden; will-change: transform, opacity; }
.ef-ffg__tile[hidden] { display: none; }
.ef-ffg__tile.is-leaving { position: absolute; margin: 0; z-index: 0; pointer-events: none; }
.ef-ffg__art { position: absolute; inset: 0; }
.ef-ffg__art::after { content: ""; position: absolute; inset: 0; background: linear-gradient(180deg, transparent 50%, rgba(0, 0, 0, 0.55)); }
.ef-ffg__t { position: absolute; left: 0.65rem; bottom: 0.5rem; font-size: 0.85rem; font-weight: 800; letter-spacing: -0.01em; text-shadow: 0 2px 8px rgba(0, 0, 0, 0.4); }
.ef-ffg__c { position: absolute; top: 0.55rem; left: 0.65rem; font-size: 0.62rem; font-weight: 700; letter-spacing: 0.14em; text-transform: uppercase; opacity: 0.85; }
.ef-ffg__status { margin: 0.9rem 0 0; font-size: 0.72rem; letter-spacing: 0.08em; text-transform: uppercase; opacity: 0.6; }JavaScript
// Enrichr Library · https://enrichr.dev
// FLIP filter grid: category buttons filter a tile grid. Before the layout changes we record every tile's rect (First), apply the filter
// so the grid reflows (Last), then animate each surviving tile from its old position with a transform (Invert, Play). Leaving tiles are
// pinned in place and fade out, entering tiles fade and scale in. Reduced motion keeps the fades and drops the movement.
// mount(root, { background, accent, columns (2-6), count (6-12), duration (ms), stagger (ms) })
function mount(root, o) {
var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
var wrap = root.querySelector(".ef-ffg__wrap");
var bar = root.querySelector(".ef-ffg__bar");
var grid = root.querySelector(".ef-ffg__grid");
var status = root.querySelector(".ef-ffg__status");
var opts = {}, offs = [], tiles = [], anims = [], active = "All", destroyed = false;
var CATS = ["Print", "Motion", "Web"];
var MAP = [0, 1, 2, 1, 0, 2, 2, 0, 1, 1, 2, 0];
var NAMES = ["Tidal", "Ember", "Moss", "Dusk", "Arch", "Grain", "Haze", "Loop", "Fold", "Pulse", "Drift", "Prism"];
// Enrichr puzzle-piece artwork, inline so the code stays portable. piece(i, accent) returns an aria-hidden SVG string, a different piece per card.
var PIECES = [["1513.7 1453.9 322.3 322.3", "M1821 1761.2h-292.3v-111.6c0-8 6.5-14.5 14.5-14.5 4.2 0 8.2 1.9 11 5.1 7.8 8.6 19.7 13.6 32.7 11.9 16.8-2.3 30.1-16 32-32.7 2.5-22.6-15.1-41.6-37.1-41.6-11 0-20.9 4.7-27.7 12.3-2.8 3.1-6.7 4.9-10.9 4.9-8 0-14.5-6.4-14.5-14.4v-111.7h292.3v111.7c0 8-6.4 14.4-14.5 14.4-4.2 0-8.1-1.8-10.9-5-7.9-8.6-19.7-13.6-32.7-11.9-16.8 2.2-30.1 15.9-32 32.7-2.5 22.6 15.1 41.6 37.1 41.6 11 0 20.8-4.8 27.7-12.3 2.8-3.1 6.7-5 10.8-5 8.1 0 14.5 6.5 14.5 14.5z"],
["1423.36 860.8 503.07 322.3", "M1821 875.8v111.6c0 8 6.5 14.5 14.5 14.5 4.2 0 8.1-1.9 10.9-5 6.8-7.5 16.7-12.3 27.7-12.3 22 0 39.6 19 37.1 41.6-1.9 16.7-15.2 30.5-32 32.7-13 1.7-24.8-3.3-32.7-11.9-2.8-3.2-6.8-5-11-5-8 0-14.5 6.4-14.5 14.4v111.7h-111.6c-8 0-14.5-6.5-14.5-14.5 0-4.2 1.9-8.2 5-11 8.7-7.9 13.7-19.7 12-32.7-2.3-16.8-16-30.1-32.8-32-22.5-2.5-41.5 15.1-41.5 37.1 0 11 4.7 20.9 12.3 27.7 3.1 2.8 4.9 6.7 4.9 10.8v0.1c0 8-6.4 14.5-14.5 14.5h-111.6v-111.7c0-8-6.4-14.4-14.5-14.4-4.1 0-8 1.8-10.8 4.9-6.9 7.6-16.7 12.3-27.7 12.3-22 0-39.6-19-37.1-41.6 1.9-16.7 15.2-30.4 32-32.7 13-1.7 24.8 3.3 32.7 11.9 2.8 3.2 6.7 5.1 10.9 5.1 8.1 0 14.5-6.5 14.5-14.5v-111.6z"],
["1513.7 166.86 322.3 503.06", "M1528.7 564.5v-111.6c0-8 6.5-14.5 14.5-14.5 4.2 0 8.2 1.9 11 5 7.8 8.7 19.7 13.7 32.7 11.9 16.8-2.2 30.1-15.9 32-32.7 2.5-22.5-15.1-41.5-37.1-41.5-11 0-20.9 4.7-27.7 12.3-2.8 3.1-6.7 4.9-10.9 4.9-8 0-14.5-6.5-14.5-14.5v-111.6h111.6c8.1 0 14.5-6.5 14.5-14.5 0-4.1-1.8-8.1-4.9-10.9-7.6-6.8-12.3-16.6-12.3-27.6 0-22 19-39.6 41.5-37.1 16.8 1.8 30.5 15.2 32.8 32 1.7 12.9-3.3 24.8-12 32.7-3.1 2.8-5 6.7-5 10.9 0 8 6.5 14.5 14.5 14.5h111.6v111.6c0 8-6.4 14.5-14.5 14.5-4.2 0-8.1-1.9-10.9-5-7.9-8.7-19.7-13.6-32.7-11.9-16.8 2.2-30.1 15.9-32 32.7-2.5 22.5 15.1 41.6 37.1 41.6 11 0 20.8-4.8 27.7-12.3 2.8-3.1 6.7-5 10.8-5 8.1 0 14.5 6.5 14.5 14.5v111.6h-111.6c-8 0-14.5 6.5-14.5 14.5 0 4.2 1.9 8.1 5 10.9 7.5 6.8 12.3 16.7 12.3 27.7 0 22-19 39.5-41.6 37.1-16.8-1.9-30.5-15.3-32.7-32-1.7-13 3.3-24.9 11.9-32.7 3.1-2.9 5-6.8 5-11 0-8-6.4-14.5-14.5-14.5z"]];
var PLACE = [[52, -16, 80, 14], [-24, 34, 96, -22], [46, 26, 74, 32], [-8, -20, 84, -12], [58, 38, 92, 24], [4, 18, 78, -32]]; // left %, top %, width %, rotate deg
var PCOL = [["#2f7d5b", 0.95], ["#0c1e18", 0.8], ["ACC", 0.9], ["#c6f26b", 0.85], ["#0a6b46", 0.95], ["#fbf8f1", 0.85]];
function piece(i, accent, sx, ox) {
var p = PIECES[i % 3], q = PLACE[i % 6], c = PCOL[i % 6], k = sx || 1, o = ox || 0;
return '<span class="ef-ffg__pc" aria-hidden="true" style="position:absolute;left:' + (o + q[0] * k).toFixed(1) + '%;top:' + q[1] + '%;width:' + (q[2] * k).toFixed(1) + '%;transform:rotate(' + q[3] + 'deg);color:' + (c[0] === "ACC" ? accent : c[0]) + ';opacity:' + c[1] + ';pointer-events:none"><svg viewBox="' + p[0] + '" aria-hidden="true" focusable="false" style="display:block;width:100%;height:auto"><path fill="currentColor" d="' + p[1] + '"/></svg></span>';
}
function art(i, a) {
var c = "#fbf8f1", d = "#0c1e18", p = i % 6;
if (p === 0) return "radial-gradient(circle at 70% 30%, " + a + " 0 18%, transparent 19%), linear-gradient(160deg, #005236, " + d + ")";
if (p === 1) return "conic-gradient(from 210deg at 35% 65%, #005236, " + a + ", " + c + ", #005236)";
if (p === 2) return "repeating-linear-gradient(120deg, #005236 0 12px, " + d + " 12px 24px)";
if (p === 3) return "radial-gradient(circle at 30% 35%, " + a + " 0 20%, transparent 21%), radial-gradient(circle at 62% 62%, " + c + " 0 20%, transparent 21%), linear-gradient(200deg, #0a6b46, " + d + ")";
if (p === 4) return "radial-gradient(ellipse 36% 26% at 50% 44%, " + c + " 0 99%, transparent 100%), linear-gradient(190deg, #005236, " + a + ")";
return "radial-gradient(circle, " + a + " 0 2.5px, transparent 3px) 0 0 / 16px 16px, linear-gradient(145deg, #005236, " + d + ")";
}
function clear() { offs.forEach(function (f) { f(); }); offs = []; }
function settle() {
// end any running animation and apply its final state
anims.forEach(function (a) { a.cancel(); });
anims = [];
tiles.forEach(function (t) {
if (t.classList.contains("is-leaving")) {
t.classList.remove("is-leaving");
t.style.left = t.style.top = t.style.width = t.style.height = "";
t.hidden = true;
}
});
}
function play(el, frames, delay, done) {
var a = el.animate(frames, { duration: opts.duration, delay: delay, easing: "cubic-bezier(.4,0,.2,1)", fill: "backwards" });
anims.push(a);
a.onfinish = function () { anims = anims.filter(function (x) { return x !== a; }); if (done) done(); };
a.oncancel = function () { anims = anims.filter(function (x) { return x !== a; }); };
}
function filter(cat, instant) {
if (destroyed) return;
settle();
active = cat;
var first = new Map(), gr = grid.getBoundingClientRect();
tiles.forEach(function (t) { if (!t.hidden) first.set(t, t.getBoundingClientRect()); });
var leaving = [], entering = [], staying = [];
tiles.forEach(function (t) {
var match = cat === "All" || t.dataset.cat === cat, was = first.has(t);
if (was && !match) leaving.push(t); else if (!was && match) entering.push(t); else if (was && match) staying.push(t);
});
var shown = staying.length + entering.length;
status.textContent = "Showing " + shown + " of " + tiles.length + (cat === "All" ? "" : " in " + cat);
// pin leaving tiles so they drop out of the flow but stay visible where they were
leaving.forEach(function (t) {
var r = first.get(t);
t.classList.add("is-leaving");
t.style.left = (r.left - gr.left) + "px"; t.style.top = (r.top - gr.top) + "px";
t.style.width = r.width + "px"; t.style.height = r.height + "px";
});
entering.forEach(function (t) { t.hidden = false; });
if (instant) { leaving.forEach(function (t) { t.classList.remove("is-leaving"); t.hidden = true; t.style.left = t.style.top = t.style.width = t.style.height = ""; }); }
else {
leaving.forEach(function (t, i) {
play(t, reduce ? [{ opacity: 1 }, { opacity: 0 }] : [{ opacity: 1, transform: "scale(1)" }, { opacity: 0, transform: "scale(0.85)" }], 0, function () {
if (t.classList.contains("is-leaving")) { t.classList.remove("is-leaving"); t.style.left = t.style.top = t.style.width = t.style.height = ""; t.hidden = true; }
});
});
staying.forEach(function (t, i) {
var f = first.get(t), l = t.getBoundingClientRect();
var dx = f.left - l.left, dy = f.top - l.top, sx = f.width / l.width, sy = f.height / l.height;
if (reduce || (!dx && !dy && sx === 1 && sy === 1)) return;
play(t, [{ transform: "translate(" + dx + "px," + dy + "px) scale(" + sx + "," + sy + ")" }, { transform: "none" }], i * opts.stagger);
});
entering.forEach(function (t, i) {
play(t, reduce ? [{ opacity: 0 }, { opacity: 1 }] : [{ opacity: 0, transform: "scale(0.85)" }, { opacity: 1, transform: "none" }], (staying.length + i) * opts.stagger * 0.5 + opts.duration * 0.25);
});
}
Array.prototype.forEach.call(bar.children, function (b) { b.setAttribute("aria-pressed", String(b.dataset.cat === cat)); });
}
function build() {
clear(); settle();
bar.textContent = ""; grid.textContent = ""; tiles = []; active = "All";
["All"].concat(CATS).forEach(function (c) {
var b = document.createElement("button");
b.type = "button"; b.className = "ef-ffg__btn"; b.textContent = c; b.dataset.cat = c;
b.setAttribute("aria-pressed", String(c === "All"));
var on = function () { filter(c); };
b.addEventListener("click", on);
offs.push(function () { b.removeEventListener("click", on); });
bar.appendChild(b);
});
for (var i = 0; i < opts.count; i++) {
var li = document.createElement("li");
li.className = "ef-ffg__tile"; li.dataset.cat = CATS[MAP[i % MAP.length]];
var bg = document.createElement("span"); bg.className = "ef-ffg__art"; bg.style.background = art(i, opts.accent);
bg.insertAdjacentHTML("beforeend", piece(i, opts.accent));
var c = document.createElement("span"); c.className = "ef-ffg__c"; c.textContent = li.dataset.cat;
var t = document.createElement("span"); t.className = "ef-ffg__t"; t.textContent = NAMES[i % NAMES.length];
li.appendChild(bg); li.appendChild(c); li.appendChild(t);
grid.appendChild(li); tiles.push(li);
}
status.textContent = "Showing " + tiles.length + " of " + tiles.length;
}
function update(next) {
var prev = opts;
opts = Object.assign({ background: "#0c1e18", accent: "#e6ff55", columns: 4, count: 9, duration: 500, stagger: 30 }, opts, next || {});
opts.count = Math.max(6, Math.min(12, Math.round(opts.count)));
opts.columns = Math.max(2, Math.min(6, Math.round(opts.columns)));
wrap.style.background = opts.background;
wrap.style.setProperty("--ef-acc", opts.accent);
wrap.style.setProperty("--ef-cols", String(opts.columns));
if (prev.count !== opts.count || prev.accent !== opts.accent) build();
}
update(o);
return { update: update, destroy: function () { destroyed = true; clear(); anims.forEach(function (a) { a.cancel(); }); anims = []; } };
}
// Usage
var fx = mount(document.querySelector("#my-root"), {"accent":"#e6ff55","columns":4,"count":9,"duration":500,"stagger":30});How it works
Before each filter change the script records the rect of every visible tile (First). It then updates the layout: tiles that no longer match are pinned with absolute positioning at their old rect so they can fade out without holding space, and tiles that now match are unhidden. The grid reflows on its own (Last). For every tile that stays, the script computes the difference between old and new rect and plays a Web Animations API transform from that offset back to none (Invert, Play), with a small stagger. Entering tiles fade and scale in, leaving tiles fade and scale out, then are hidden. A new filter during an animation cancels it and settles the state first.
Accessibility and performance
Filters are buttons in a labelled group with aria-pressed, so state is exposed and everything works from the keyboard. A polite status line announces how many tiles are showing. Reduced motion keeps the fades and drops the movement and scaling. Tiles are passive content here; make them links or buttons if yours are interactive.
Layout is read once before and once after the change, then only transforms and opacity animate, so there is no per-frame layout. Animations use the Web Animations API and are cancelled on destroy. Keep tile counts reasonable, because every visible tile is measured per filter.
| Section | Gallery |
|---|---|
| Techniques | Stagger, Pointer interaction |
| Stack | Vanilla JS, CSS |
| Works with | React, Next.js, Vanilla |
| Dependencies | None |
| Status | tested |
| Inspired by | Smooth Flexbox Filtering with Flip by GSAP (Codrops) on Codrops. Original code, written from scratch. |
Find this technique on a real site
Want to see this technique in the wild? Check any site with the free tools, then build your own version from this page.
Check which animation library a site uses · Extract a site's design tokens · Browse site breakdowns
Licence and provenance
Free to use in personal and client projects during the preview. Final licence terms will be published here before any paid tier starts; ask before reselling the code as a collection.
This is original code. The technique is a common one that we studied by measuring award-winning sites with Enrichr; no third-party code, shaders, fonts or images are used.
More elements
FAQ
Why pin the leaving tiles instead of animating them in place?
A leaving tile still occupies a grid cell, so the remaining tiles could not reflow to their new positions. Pinning it at its old rect lets it fade out while the grid already has its final layout.
Can I filter by more than one category?
Yes. Store several categories per tile and change the match test in filter(). The FLIP logic only cares about which tiles are visible before and after.
Does it need GSAP?
No. The measure, invert and play steps use getBoundingClientRect and element.animate, which are built into the browser.