Copied!
Gallery

FLIP filter grid

A tile grid with category filter buttons: filtering animates the remaining tiles to their new positions with a hand-rolled FLIP while others fade out and in.

← Library

Element · Vanilla JS + CSSUpdated November 6, 2026

CursorElementVanilla JS + CSS

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.

SectionGallery
TechniquesStagger, Pointer interaction
StackVanilla JS, CSS
Works withReact, Next.js, Vanilla
DependenciesNone
Statustested
Inspired bySmooth 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.

Distributed letters hoverA big headline whose letters scatter evenly across the stage on hover or button press, then spring back into the word. Repetition image hover trailGradient image tiles that spawn a staggered trail of repeated copies chasing the pointer, each with a little more lag, then fading out on leave. Make way gridA grid of gradient tiles where the hovered tile grows and every neighbour is pushed aside by a spring, strongest for the closest tiles, so the grid never overlaps.

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.