Copied!
Gallery

Scroll layout flip

A pinned row of tiles that re-flows into a grid, a feature view or a fanned stack as you scroll inside the panel, with the art cropped rather than stretched.

← Library

Element · Vanilla JS + CSSUpdated November 6, 2026

ScrollElementVanilla 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 "Scroll layout flip" for my site using the Enrichr Library element at https://enrichr.dev/elements/scroll-layout-flip.html.

Read the machine-readable spec and source at https://enrichr.dev/elements/scroll-layout-flip.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:
- layout: grid
- length: 2.5
- smooth: 0.6
- count: 7
- 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/scroll-layout-flip.json.

Code

Mount it on an element that contains the markup. It returns an object with update(options) and destroy().

HTML

<div class="ef-slf">
  <div class="ef-slf__wrap">
    <div class="ef-slf__scroller" tabindex="0" role="region" aria-label="Scroll inside this panel to rearrange the tiles from a row into a new layout">
      <div class="ef-slf__track">
        <div class="ef-slf__stage">
          <div class="ef-slf__items" aria-hidden="true"></div>
          <p class="ef-slf__cap"><b class="ef-slf__capt"></b><span class="ef-slf__caps"></span></p>
          <div class="ef-slf__bar" aria-hidden="true"><i></i></div>
          <div class="ef-slf__hint" aria-hidden="true">Scroll</div>
        </div>
      </div>
    </div>
  </div>
</div>

CSS

/* Enrichr Library · https://enrichr.dev */
/* Adapted from https://github.com/codrops/ScrollBasedLayoutAnimations (MIT) — Copyright (c) 2009 - 2022 [Codrops](https://tympanus.net/codrops) */
.ef-slf { width: 100%; height: 100%; min-height: 26rem; font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
.ef-slf__wrap { --ef-acc: #e6ff55; position: relative; box-sizing: border-box; width: 100%; height: 100%; min-height: 26rem; border-radius: 1rem; overflow: hidden; background: #0c1e18; color: #fbf8f1; }
.ef-slf__scroller { position: absolute; inset: 0; overflow-x: hidden; overflow-y: auto; overscroll-behavior: contain; scrollbar-width: thin; scrollbar-color: rgba(251, 248, 241, 0.35) transparent; outline: none; }
.ef-slf__scroller:focus-visible { box-shadow: inset 0 0 0 2px var(--ef-acc); }
.ef-slf__track { position: relative; width: 100%; height: 300%; }
.ef-slf__stage { position: sticky; top: 0; left: 0; width: 100%; height: 100%; overflow: hidden; }
.ef-slf__items { position: absolute; inset: 0; }
.ef-slf__tile { position: absolute; left: 0; top: 0; overflow: hidden; transform-origin: 0 0; will-change: transform; box-shadow: 0 10px 24px -12px rgba(0, 0, 0, 0.7); }
.ef-slf__inner { position: absolute; left: 0; top: 0; transform-origin: 0 0; }
.ef-slf__art { position: absolute; inset: 0; will-change: transform; }
.ef-slf__art svg { position: absolute; inset: 0; margin: auto; width: 58%; height: 58%; display: block; }
.ef-slf__n { position: absolute; left: 0.7rem; bottom: 0.55rem; font-size: 0.66rem; font-weight: 700; letter-spacing: 0.14em; opacity: 0.85; }
.ef-slf__cap { z-index: 20; position: absolute; left: 0.9rem; bottom: 1.5rem; margin: 0; max-width: 70%; padding: 0.6rem 0.8rem; border-radius: 0.7rem; background: rgba(12, 30, 24, 0.94); display: flex; flex-direction: column; gap: 0.15rem; opacity: 0; transform: translateY(8px); pointer-events: none; }
.ef-slf__capt { font-size: 0.95rem; font-weight: 800; letter-spacing: -0.01em; }
.ef-slf__caps { font-size: 0.72rem; opacity: 0.8; line-height: 1.35; }
.ef-slf__bar { z-index: 20; position: absolute; left: 0.9rem; right: 0.9rem; bottom: 0.7rem; height: 3px; border-radius: 3px; background: rgba(251, 248, 241, 0.2); }
.ef-slf__bar i { display: block; height: 100%; border-radius: inherit; background: var(--ef-acc); transform-origin: 0 50%; transform: scaleX(0); }
.ef-slf__hint { z-index: 20; position: absolute; top: 0.8rem; right: 0.9rem; font-size: 0.66rem; font-weight: 700; letter-spacing: 0.16em; text-transform: uppercase; color: var(--ef-acc); }
.ef-slf__hint::after { content: ""; display: inline-block; width: 0.45rem; height: 0.45rem; margin-left: 0.4rem; border-right: 2px solid currentColor; border-bottom: 2px solid currentColor; transform: rotate(45deg) translateY(-2px); animation: ef-slf-nudge 1.4s ease-in-out infinite; }
@keyframes ef-slf-nudge { 0%, 100% { translate: 0 -2px; } 50% { translate: 0 3px; } }
@media (prefers-reduced-motion: reduce) { .ef-slf__hint::after { animation: none; } .ef-slf__cap { transform: none; } }

JavaScript

// Enrichr Library · https://enrichr.dev
// Adapted from https://github.com/codrops/ScrollBasedLayoutAnimations (MIT) — Copyright (c) 2009 - 2022 [Codrops](https://tympanus.net/codrops)
// Scroll layout flip: a row of tiles pinned in a panel of its own re-flows into a grid, a feature view or a fanned stack as you scroll.
// A hand-made FLIP: every tile has a "row" box and a "final" box, and the scroll progress blends position, size and tilt between them.
// The tile art is counter-scaled so it is cropped, never stretched. No GSAP, no ScrollTrigger: the panel scrolls itself (wheel, touch,
// keys), so it works inside an overflow:hidden container. Scrolling is smoothed with a lerp; reduced motion follows the scroll 1:1.
// mount(root, { background, accent, layout ("grid"|"feature"|"stack"), length (screens of scroll), smooth (0-1), count (5-9) })
function mount(root, o) {
  var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
  var wrap = root.querySelector(".ef-slf__wrap");
  var sc = root.querySelector(".ef-slf__scroller");
  var track = root.querySelector(".ef-slf__track");
  var stage = root.querySelector(".ef-slf__stage");
  var itemsEl = root.querySelector(".ef-slf__items");
  var cap = root.querySelector(".ef-slf__cap");
  var capT = root.querySelector(".ef-slf__capt");
  var capS = root.querySelector(".ef-slf__caps");
  var bar = root.querySelector(".ef-slf__bar i");
  var hint = root.querySelector(".ef-slf__hint");
  var PIECES = [
    { vb: "1513.7 1453.9 322.3 322.3", d: "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" },
    { vb: "1423.36 860.8 503.07 322.3", d: "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" },
    { vb: "1513.7 166.86 322.3 503.06", d: "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" }
  ];
  // [background, piece colour]; dark tiles take cream or the accent, light tiles take deep green
  var ART = [
    ["linear-gradient(150deg, #005236, #0c1e18)", "A", "#fbf8f1"],
    ["linear-gradient(160deg, #c6f26b, #2f7d5b)", "#005236", "#0c1e18"],
    ["linear-gradient(145deg, #2f7d5b, #0a6b46)", "#fbf8f1", "#fbf8f1"],
    ["linear-gradient(155deg, #fbf8f1, #c6f26b)", "#0a6b46", "#0c1e18"],
    ["radial-gradient(circle at 70% 25%, #0a6b46, #0c1e18 70%)", "#c6f26b", "#fbf8f1"],
    ["linear-gradient(160deg, #e6ff55, #c6f26b)", "#005236", "#0c1e18"]
  ];
  var CAPS = {
    grid: ["A tidy grid", "The row settles into even tiles that fill the panel."],
    feature: ["A feature view", "One tile steps forward and the rest line up beside it."],
    stack: ["A fanned stack", "The row gathers into a loose pile, centre tile on top."]
  };
  var RADIUS = 9;
  var opts = {}, tiles = [], geo = [], W = 0, H = 0, target = 0, cur = 0, raf = 0, last = 0, ro = null, built = "";

  function lerp(a, b, t) { return a + (b - a) * t; }
  function clamp(v, a, b) { return Math.max(a, Math.min(b, v)); }

  // The "row" boxes: tiles in one line, the middle one biggest, a little wider than the panel
  function rowBoxes() {
    var n = opts.count, g = Math.max(5, Math.round(Math.min(W, H) * 0.018)), c = (n - 1) / 2, wt = [], sum = 0, i, out = [];
    for (i = 0; i < n; i++) { wt[i] = 0.55 + 0.9 * (1 - Math.abs(i - c) / (c || 1)); sum += wt[i]; }
    var avail = W * 1.06 - g * (n - 1), x = (W - (avail + g * (n - 1))) / 2;
    for (i = 0; i < n; i++) {
      var w = wt[i] / sum * avail, h = Math.min(H * 0.8, w * 1.4);
      out.push({ cx: x + w / 2, cy: H / 2, w: w, h: h, r: 0 });
      x += w + g;
    }
    return out;
  }
  // The "final" boxes for the chosen layout
  function finalBoxes() {
    var n = opts.count, pad = Math.round(Math.min(W, H) * 0.04) + 6, g = Math.max(6, Math.round(Math.min(W, H) * 0.02));
    var aw = W - 2 * pad, ah = H - 2 * pad, c = (n - 1) / 2, ci = Math.round(c), out = [], i;
    if (opts.layout === "feature") {
      var bw = aw * 0.6;
      out[ci] = { cx: pad + bw / 2, cy: H / 2, w: bw, h: ah, r: 0 };
      var rest = [], rows, cw = aw * 0.4 - g, k = 0;
      for (i = 0; i < n; i++) if (i !== ci) rest.push(i);
      rows = Math.ceil(rest.length / 2);
      var th = (ah - g * (rows - 1)) / rows, tw = (cw - g) / 2;
      rest.forEach(function (idx) {
        var col = k % 2, row = Math.floor(k / 2);
        out[idx] = { cx: pad + bw + g + col * (tw + g) + tw / 2, cy: pad + row * (th + g) + th / 2, w: tw, h: th, r: 0 };
        k++;
      });
    } else if (opts.layout === "stack") {
      var sw = Math.min(aw * 0.56, ah * 0.52), sh = Math.min(ah * 0.78, sw * 1.35);
      for (i = 0; i < n; i++) out.push({ cx: W / 2 + (i - c) * aw * 0.065, cy: H / 2 + (i - c) * ah * 0.012, w: sw, h: sh, r: (i - c) * 0.06 });
    } else {
      var cols = n <= 4 ? 2 : 3, nr = Math.ceil(n / cols), ch = (ah - g * (nr - 1)) / nr;
      for (i = 0; i < n; i++) {
        var r = Math.floor(i / cols), inRow = Math.min(cols, n - r * cols), cwid = (aw - g * (inRow - 1)) / inRow, col = i - r * cols;
        out.push({ cx: pad + col * (cwid + g) + cwid / 2, cy: pad + r * (ch + g) + ch / 2, w: cwid, h: ch, r: 0 });
      }
    }
    return out;
  }
  function layoutGeo() {
    W = sc.clientWidth; H = sc.clientHeight;
    if (!W || !H) return false;
    stage.style.height = H + "px";
    track.style.height = (H * (1 + opts.length)) + "px";
    var a = rowBoxes(), b = finalBoxes(), n = opts.count, c = (n - 1) / 2;
    geo = [];
    for (var i = 0; i < n; i++) {
      var mw = Math.max(a[i].w, b[i].w), mh = Math.max(a[i].h, b[i].h);
      geo.push({ a: a[i], b: b[i], mw: mw, mh: mh, d: 0.28 * Math.abs(i - c) / (c || 1) });
      var t = tiles[i];
      t.el.style.width = mw + "px"; t.el.style.height = mh + "px";
      t.inner.style.width = mw + "px"; t.inner.style.height = mh + "px";
      t.el.style.zIndex = String(opts.layout === "stack" ? n - Math.round(Math.abs(i - c)) : (i === Math.round(c) ? n : 1));
    }
    return true;
  }

  function build() {
    itemsEl.textContent = "";
    tiles = [];
    for (var i = 0; i < opts.count; i++) {
      var art = ART[i % ART.length], pc = PIECES[[0, 1, 2][i % 3]];
      var col = art[1] === "A" ? opts.accent : art[1];
      var el = document.createElement("div"); el.className = "ef-slf__tile";
      var inner = document.createElement("div"); inner.className = "ef-slf__inner";
      var a = document.createElement("div"); a.className = "ef-slf__art"; a.style.background = art[0]; a.style.color = col;
      a.innerHTML = '<svg viewBox="' + pc.vb + '" focusable="false"><path fill="currentColor" d="' + pc.d + '"/></svg>';
      var n = document.createElement("span"); n.className = "ef-slf__n"; n.style.color = art[2]; n.textContent = String(i + 1).padStart(2, "0");
      a.appendChild(n); inner.appendChild(a); el.appendChild(inner); itemsEl.appendChild(el);
      tiles.push({ el: el, inner: inner, art: a });
    }
    built = opts.count + "|" + opts.accent;
  }

  function render(p) {
    if (!geo.length) return;
    var e = p * p * (3 - 2 * p), n = geo.length;
    for (var i = 0; i < n; i++) {
      var g = geo[i], t = tiles[i];
      var lp = clamp((e - g.d) / (1 - 0.28), 0, 1);
      lp = lp * lp * (3 - 2 * lp);
      var w = lerp(g.a.w, g.b.w, lp), h = lerp(g.a.h, g.b.h, lp), cx = lerp(g.a.cx, g.b.cx, lp), cy = lerp(g.a.cy, g.b.cy, lp), r = lerp(g.a.r, g.b.r, lp);
      var sx = w / g.mw, sy = h / g.mh;
      var tx = cx - (Math.cos(r) * w / 2 - Math.sin(r) * h / 2), ty = cy - (Math.sin(r) * w / 2 + Math.cos(r) * h / 2);
      t.el.style.transform = "translate(" + tx.toFixed(2) + "px," + ty.toFixed(2) + "px) rotate(" + r.toFixed(4) + "rad) scale(" + sx.toFixed(4) + "," + sy.toFixed(4) + ")";
      t.el.style.borderRadius = (RADIUS / sx).toFixed(2) + "px / " + (RADIUS / sy).toFixed(2) + "px";
      t.inner.style.transform = "translate(" + ((w - g.mw) / (2 * sx)).toFixed(2) + "px," + ((h - g.mh) / (2 * sy)).toFixed(2) + "px) scale(" + (1 / sx).toFixed(4) + "," + (1 / sy).toFixed(4) + ")";
      t.art.style.transform = reduce ? "" : "scale(" + (1 + 0.3 * (1 - lp)).toFixed(3) + ")";
    }
    var ca = clamp((p - 0.55) / 0.3, 0, 1);
    cap.style.opacity = ca.toFixed(3);
    cap.style.transform = "translateY(" + ((1 - ca) * 8).toFixed(1) + "px)";
    bar.style.transform = "scaleX(" + p.toFixed(4) + ")";
    hint.style.opacity = (1 - clamp(p / 0.12, 0, 1)).toFixed(3);
  }

  function frame(ts) {
    raf = 0;
    var dt = Math.min(0.05, (ts - last) / 1000 || 0.016);
    last = ts;
    var k = opts.smooth > 0 ? 24 - 20 * opts.smooth : 0;
    if (reduce || k <= 0) cur = target; else cur += (target - cur) * (1 - Math.exp(-dt * k));
    if (Math.abs(target - cur) < 0.0004) cur = target;
    render(cur);
    if (cur !== target) raf = requestAnimationFrame(frame);
  }
  function kick() { if (!raf) { last = performance.now(); raf = requestAnimationFrame(frame); } }
  function onScroll() {
    var max = sc.scrollHeight - sc.clientHeight;
    target = max > 0 ? clamp(sc.scrollTop / max, 0, 1) : 0;
    kick();
  }
  function onResize() { if (layoutGeo()) { onScroll(); render(cur); } }

  function update(next) {
    var prev = opts;
    opts = Object.assign({ background: "#0c1e18", accent: "#e6ff55", layout: "grid", length: 2.5, smooth: 0.6, count: 7 }, opts, next || {});
    opts.count = Math.max(5, Math.min(9, Math.round(opts.count)));
    opts.length = clamp(Number(opts.length) || 2.5, 1, 6);
    opts.smooth = clamp(Number(opts.smooth) || 0, 0, 1);
    if (!CAPS[opts.layout]) opts.layout = "grid";
    wrap.style.background = opts.background;
    wrap.style.setProperty("--ef-acc", opts.accent);
    capT.textContent = CAPS[opts.layout][0];
    capS.textContent = CAPS[opts.layout][1];
    if (built !== opts.count + "|" + opts.accent) build();
    var ratio = sc.scrollHeight - sc.clientHeight > 0 ? sc.scrollTop / (sc.scrollHeight - sc.clientHeight) : 0;
    if (layoutGeo()) {
      var max = sc.scrollHeight - sc.clientHeight;
      if (prev.length !== undefined && prev.length !== opts.length) sc.scrollTop = ratio * max;
      onScroll();
      cur = target;
      render(cur);
    }
  }

  sc.addEventListener("scroll", onScroll, { passive: true });
  if (window.ResizeObserver) { ro = new ResizeObserver(onResize); ro.observe(sc); }
  else window.addEventListener("resize", onResize);
  update(o);
  return {
    update: update,
    destroy: function () {
      sc.removeEventListener("scroll", onScroll);
      if (ro) ro.disconnect(); else window.removeEventListener("resize", onResize);
      if (raf) cancelAnimationFrame(raf);
      raf = 0;
      itemsEl.textContent = "";
      tiles = []; geo = [];
    }
  };
}

// Usage
var fx = mount(document.querySelector("#my-root"), {"layout":"grid","length":2.5,"smooth":0.6,"count":7,"accent":"#e6ff55"});

How it works

This is a vanilla port, with no GSAP, Flip or ScrollTrigger. The element owns a scrolling panel with a tall track and a sticky stage. Every tile has two boxes, one in a loose row and one in the chosen final layout, and the script blends position, size and tilt between them as the scroll progress goes from 0 to 1, with a small per-tile delay from the centre outwards. Each tile is drawn at its larger size and scaled with a transform, while its art is counter-scaled so it crops instead of stretching. Scroll progress is smoothed with a lerp in one requestAnimationFrame loop that only runs while the value is still moving.

Accessibility and performance

The panel is a keyboard-focusable scroll region, so arrow keys, Page Up and Page Down and touch all work and the focus ring is visible. The tiles are decorative and hidden from assistive technology, and the caption states the layout in text. With reduced motion the smoothing and the art zoom are off and the layout follows the scroll position exactly, still driven by the user.

Only transforms on a handful of tiles change per frame, plus the radius on the same tiles. Geometry is measured once on resize through a ResizeObserver, not on every scroll event, and the rAF loop stops when the smoothed value reaches the target.

SectionGallery
TechniquesScroll-driven, Stagger
StackVanilla JS, CSS
Works withReact, Next.js, Vanilla
DependenciesNone
Statustested
Inspired byOn-Scroll Image Layout Animations by Codrops on github.com. 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.

Scroll layout formationA grid that assembles itself as you scroll, in five formations: rise, fan, 3D scatter, cascade and flip. Vanilla port, no GSAP. Scroll text scramble and slideUppercase text lines slide sideways between two columns as you scroll, then scramble into place the first time they enter view, with Enrichr puzzle pieces drifting behind. Split text revealA headline whose words rise out of a mask one after another when it scrolls into view.

FAQ

Why does it use its own scroll container?

So it keeps working inside an overflow:hidden box or a preview frame, and wheel and touch events stay on the element instead of the window. Scroll chaining to the page is blocked with overscroll-behavior.

Can I use my own images?

Yes. Replace the art element in each tile with an image set to cover. Because the art is counter-scaled, the picture is cropped as the tile changes shape, never squashed.

How do I add a new layout?

Add another branch to finalBoxes() that returns one centre, width, height and rotation per tile, and a caption for it. The row boxes and the blending stay the same.