Copied!
Gallery

Flip bento grid

A 3 by 3 bento grid of photos where the one under the pointer grows into the big tile and the rest reflow around it, animated with FLIP.

← 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 bento grid" for my site using the Enrichr Library element at https://enrichr.dev/elements/flip-bento-grid.html.

Read the machine-readable spec and source at https://enrichr.dev/elements/flip-bento-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:
- grow: 3
- duration: 0.4
- gap: 15
- radius: 12
- tour: 2.5
- background: #0c1e18
- edge: #fbf8f1
- accent: #e6ff55

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-bento-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-fbg">
  <div class="ef-fbg__grid" role="group" aria-label="Photo grid. Hover or focus a photo to make it the large one">
    <div class="ef-fbg__item" tabindex="0" data-col="0" data-row="0"><img class="ef-fbg__img" src="/img/library/corgi-2.jpg" alt="Corgi sitting in a field of flowers" style="object-position: 50% 40%" decoding="async"></div>
    <div class="ef-fbg__item" tabindex="0" data-col="1" data-row="0"><img class="ef-fbg__img" src="/img/library/corgi-4.jpg" alt="A corgi and a terrier running down a dirt road" style="object-position: 50% 60%" decoding="async"></div>
    <div class="ef-fbg__item" tabindex="0" data-col="2" data-row="0"><img class="ef-fbg__img" src="/img/library/corgi-3.jpg" alt="Corgi standing on a rock among autumn leaves" style="object-position: 50% 45%" decoding="async"></div>
    <div class="ef-fbg__item" tabindex="0" data-col="0" data-row="1"><img class="ef-fbg__img" src="/img/library/corgi-5.jpg" alt="Corgi lying down on a white background" style="object-position: 45% 50%" decoding="async"></div>
    <div class="ef-fbg__item" tabindex="0" data-col="1" data-row="1"><img class="ef-fbg__img" src="/img/library/corgi-1.jpg" alt="Corgi puppy in front of an orange wall" style="object-position: 50% 35%" decoding="async"></div>
    <div class="ef-fbg__item" tabindex="0" data-col="2" data-row="1"><img class="ef-fbg__img" src="/img/library/corgi-6.jpg" alt="Corgi standing on a white background" style="object-position: 50% 50%" decoding="async"></div>
    <div class="ef-fbg__item" tabindex="0" data-col="0" data-row="2"><img class="ef-fbg__img" src="/img/library/corgi-3.jpg" alt="Close crop of the corgi on the rock" style="object-position: 30% 70%" decoding="async"></div>
    <div class="ef-fbg__item" tabindex="0" data-col="1" data-row="2"><img class="ef-fbg__img" src="/img/library/corgi-2.jpg" alt="Close crop of the flowers around the corgi" style="object-position: 50% 85%" decoding="async"></div>
    <div class="ef-fbg__item" tabindex="0" data-col="2" data-row="2"><img class="ef-fbg__img" src="/img/library/corgi-4.jpg" alt="Close crop of the dirt road" style="object-position: 85% 50%" decoding="async"></div>
  </div>
</div>

CSS

/* Enrichr Library · https://enrichr.dev */
.ef-fbg { --ef-fbg-gap: 15px; --ef-fbg-r: 12px; --ef-fbg-bg: #0c1e18; --ef-fbg-edge: #fbf8f1; --ef-fbg-acc: #e6ff55; position: relative; width: 100%; min-height: 26rem; box-sizing: border-box; overflow: hidden; border-radius: 1rem; background: var(--ef-fbg-bg); }
.ef-fbg .ef-fbg__grid { position: absolute; inset: 5%; margin: auto; max-width: 1000px; max-height: 800px; display: grid; gap: var(--ef-fbg-gap); grid-template-columns: 1fr 3fr 1fr; grid-template-rows: 1fr 3fr 1fr; }
.ef-fbg .ef-fbg__item { position: relative; box-sizing: border-box; min-width: 0; min-height: 0; margin: 0; overflow: hidden; cursor: pointer; border-radius: var(--ef-fbg-r); border: 1px solid color-mix(in srgb, var(--ef-fbg-edge) 50%, transparent); box-shadow: 0 4px 6px color-mix(in srgb, var(--ef-fbg-edge) 25%, transparent); background: color-mix(in srgb, var(--ef-fbg-edge) 8%, var(--ef-fbg-bg)); outline: none; -webkit-tap-highlight-color: transparent; }
.ef-fbg .ef-fbg__item:hover, .ef-fbg .ef-fbg__item:focus-visible { z-index: 10; }
.ef-fbg .ef-fbg__item:focus-visible { box-shadow: 0 0 0 3px var(--ef-fbg-acc), 0 4px 6px color-mix(in srgb, var(--ef-fbg-edge) 25%, transparent); }
.ef-fbg .ef-fbg__img { position: absolute; inset: 0; z-index: 1; display: block; width: 100%; height: 100%; max-width: none; margin: 0; object-fit: cover; user-select: none; -webkit-user-drag: none; pointer-events: none; }

JavaScript

// Enrichr Library · https://enrichr.dev
// Flip bento grid: a 3 by 3 grid of photos where the photo under the pointer takes the big middle-sized track and the
// others reflow around it. The hovered tile's column and row become the large track (3fr against 1fr by default); when
// the pointer leaves the grid the centre tile is large again. Every change is animated with FLIP: measure each tile, switch
// the grid tracks, measure again, then pin each tile absolutely at its old box and tween left, top, width and height to the
// new box over 0.4 s with a power2.out ease (1 - (1 - p)^3), the same as GSAP Flip.from with absolute: true. A new hover
// mid-move starts from where the tiles are on screen, so nothing jumps. Keyboard focus works like hover; a tap picks a tile
// on touch screens. An optional tour enlarges tiles in turn while nobody is hovering, so a preview moves on its own.
// Reduced motion switches the layout instantly and turns the tour off. The tour pauses off screen and in hidden tabs.
// mount(root, { grow (large track, x the small ones), duration (s), gap (px), radius (px), tour (s between tour steps, 0 = off),
//               background, edge (tile border and glow), accent (focus ring) })
function mount(root, o) {
  var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
  var box = root.querySelector(".ef-fbg");
  var grid = root.querySelector(".ef-fbg__grid");
  var items = Array.prototype.slice.call(root.querySelectorAll(".ef-fbg__item"));
  var TOUR = [0, 8, 2, 6, 5, 1, 3, 7, 4]; // tile indexes the tour visits: corners, then edges, then home
  var opts = {}, col = 1, row = 1, raf = 0, start = 0, from = null, to = null;
  var inside = false, focused = false, visible = true, io = null, ro = null, tourTimer = 0, tourStep = 0, destroyed = false, offs = [];

  function num(v, d) { v = Number(v); return isFinite(v) ? v : d; }
  function tracks(active) { var t = [1, 1, 1]; t[active] = opts.grow; return t.map(function (v) { return v + "fr"; }).join(" "); }
  function layout() { grid.style.gridTemplateColumns = tracks(col); grid.style.gridTemplateRows = tracks(row); }
  // each tile's box inside the grid's padding box, as it is drawn right now (mid-animation included)
  function measure() {
    var g = grid.getBoundingClientRect();
    return items.map(function (el) {
      var r = el.getBoundingClientRect();
      return { x: r.left - g.left - grid.clientLeft, y: r.top - g.top - grid.clientTop, w: r.width, h: r.height };
    });
  }
  function place(el, b) { var s = el.style; s.position = "absolute"; s.left = b.x + "px"; s.top = b.y + "px"; s.width = b.w + "px"; s.height = b.h + "px"; }
  function release() { items.forEach(function (el) { var s = el.style; s.position = s.left = s.top = s.width = s.height = ""; }); }
  function ease(p) { return 1 - Math.pow(1 - p, 3); } // GSAP power2.out
  function frame(now) {
    var p = Math.min(1, (now - start) / (opts.duration * 1000)), e = ease(p);
    items.forEach(function (el, i) {
      var a = from[i], b = to[i];
      place(el, { x: a.x + (b.x - a.x) * e, y: a.y + (b.y - a.y) * e, w: a.w + (b.w - a.w) * e, h: a.h + (b.h - a.h) * e });
    });
    if (p < 1) raf = requestAnimationFrame(frame);
    else { raf = 0; release(); }
  }
  function stop() { if (raf) { cancelAnimationFrame(raf); raf = 0; } release(); }
  // FLIP: First (where the tiles are now), Last (where the new tracks put them), Invert (pin them at First), Play
  function flip(c, r) {
    if (c === col && r === row) return;
    var first = measure();
    if (raf) { cancelAnimationFrame(raf); raf = 0; }
    release();
    col = c; row = r; layout();
    if (reduce || opts.duration <= 0 || !box.isConnected) return;
    var last = measure();
    from = first; to = last;
    items.forEach(function (el, i) { place(el, first[i]); });
    start = performance.now();
    raf = requestAnimationFrame(frame);
  }
  function pick(el) { flip(num(el.getAttribute("data-col"), 1), num(el.getAttribute("data-row"), 1)); }

  function idle() { return !inside && !focused; }
  function schedule() {
    clearTimeout(tourTimer); tourTimer = 0;
    if (reduce || destroyed || !(opts.tour > 0)) return;
    tourTimer = setTimeout(tourTick, opts.tour * 1000);
  }
  function tourTick() {
    tourTimer = 0;
    if (destroyed) return;
    if (idle() && visible && !document.hidden) { pick(items[TOUR[tourStep % TOUR.length]] || items[4]); tourStep++; }
    schedule();
  }

  function on(el, type, fn) { el.addEventListener(type, fn); offs.push(function () { el.removeEventListener(type, fn); }); }
  items.forEach(function (el) {
    on(el, "pointerenter", function () { inside = true; clearTimeout(tourTimer); pick(el); });
    on(el, "focus", function () { focused = true; clearTimeout(tourTimer); pick(el); });
  });
  // leaving the grid puts the centre tile back, like the original's mouseleave; a lifted finger is not a leave
  on(grid, "pointerleave", function (e) { if (e.pointerType === "touch") return; inside = false; flip(1, 1); schedule(); });
  on(grid, "focusout", function (e) { if (grid.contains(e.relatedTarget)) return; focused = false; if (!inside) flip(1, 1); schedule(); });

  function update(next) {
    opts = Object.assign({ grow: 3, duration: 0.4, gap: 15, radius: 12, tour: 2.5, background: "#0c1e18", edge: "#fbf8f1", accent: "#e6ff55" }, opts, next || {});
    opts.grow = Math.max(1, num(opts.grow, 3)); opts.duration = Math.max(0, num(opts.duration, 0.4));
    opts.gap = Math.max(0, num(opts.gap, 15)); opts.radius = Math.max(0, num(opts.radius, 12)); opts.tour = Math.max(0, num(opts.tour, 2.5));
    box.style.setProperty("--ef-fbg-gap", opts.gap + "px");
    box.style.setProperty("--ef-fbg-r", opts.radius + "px");
    box.style.setProperty("--ef-fbg-bg", opts.background);
    box.style.setProperty("--ef-fbg-edge", opts.edge);
    box.style.setProperty("--ef-fbg-acc", opts.accent);
    if (destroyed) return;
    stop(); layout();
    if (idle()) schedule();
  }

  update(o);
  if ("IntersectionObserver" in window) { io = new IntersectionObserver(function (e) { visible = e[0].isIntersecting; }); io.observe(box); }
  // a resize mid-move would leave tiles pinned at stale boxes; finish the move and let the grid reflow
  if ("ResizeObserver" in window) { ro = new ResizeObserver(function () { if (raf) stop(); }); ro.observe(box); }
  return {
    update: update,
    destroy: function () {
      destroyed = true; stop(); clearTimeout(tourTimer);
      offs.forEach(function (f) { f(); }); offs = [];
      if (io) io.disconnect();
      if (ro) ro.disconnect();
    }
  };
}

// Usage
var fx = mount(document.querySelector("#my-root"), {"grow":3,"duration":0.4,"gap":15,"radius":12,"tour":2.5,"background":"#0c1e18","edge":"#fbf8f1","accent":"#e6ff55"});

How it works

The photos sit in a CSS grid with three columns and three rows. The large tile's column and row get 3fr and the others 1fr, so hovering a tile only has to change two grid-template values. The move uses FLIP, the technique GSAP Flip is built on: measure every tile, switch the tracks, measure again, then pin each tile with position absolute at its old box and tween left, top, width and height to the new box over 0.4 seconds with a power2.out ease. Tweening the size rather than a scale keeps each photo cropped with object-fit cover instead of stretched. If the pointer reaches another tile mid-move, the first measurement is taken from where the tiles are on screen, so the grid never jumps. Leaving the grid puts the centre tile back. An optional auto tour enlarges tiles in turn while nobody is hovering, which is what makes the Library preview move.

Accessibility and performance

Every tile is focusable with the Tab key and focus enlarges it exactly like hover; leaving the grid with the keyboard restores the centre tile. Each photo has alt text and a visible lime focus ring. On touch screens a tap picks a tile. With prefers-reduced-motion the layout still changes but instantly, with no tween, and the auto tour is off.

Nothing runs while the grid is still. A move animates nine boxes for 0.4 seconds with one requestAnimationFrame loop, and the inline styles are removed at the end so the grid goes back to plain CSS layout. The auto tour is a single timer that skips its step off screen and in hidden tabs, and a resize mid-move finishes the move at once.

SectionGallery
TechniquesPointer interaction
StackVanilla JS, CSS
Works withReact, Next.js, Vanilla
DependenciesNone
Statustested
On CodePenOpen the live pen on our CodePen account; fork it to experiment.
Inspired byFlip Bento Hover Effect by Enrichr on CodePen. 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.

Rule sweep listA line sweeps across a menu row on hover or focus and exits through the opposite side. Depth parallax cardLayered shapes shift by different amounts as the pointer moves, drifting on touch screens. 3D ring carouselCards on a CSS 3D ring that rotates one step at a time, with buttons, arrow keys and autoplay.

FAQ

Does it need GSAP Flip?

No. FLIP is four steps you can write by hand: record where each tile is, change the layout, record where each tile ends up, then animate from the first box to the last. This element does that with getBoundingClientRect and one animation loop, using the same 0.4 second power2.out timing as the original GSAP version.

Can I use my own photos?

Yes. Change the src and alt of the nine images in the markup. Each image has an object-position so you can choose which part shows when the tile is small. Photos: Alvan Nee and fatty corgi on Unsplash.

How do I stop the automatic tour?

Set the auto tour to 0. The tour only runs while nobody is hovering or focusing a tile, and it stops for reduced motion either way.

Can the grid have more tiles?

The idea works for any grid. Add rows or columns to the markup, give each tile its data-col and data-row, and make the tracks function return as many values as you have columns and rows.