Copied!
Gallery

Scroll layout formation

A grid that assembles itself as you scroll, in five formations: rise, fan, 3D scatter, cascade and flip. Vanilla port, no GSAP.

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

Read the machine-readable spec and source at https://enrichr.dev/elements/scroll-layout-formation.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
- formation: rise
- spread: 0.4
- depth: 1
- ease: 0.12

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-formation.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__view" tabindex="0" role="region" aria-label="Layout formation, scroll to assemble the grid">
      <div class="ef-slf__space">
        <div class="ef-slf__stage">
          <div class="ef-slf__grid"></div>
          <div class="ef-slf__cap"><span class="ef-slf__title">Formation</span><span class="ef-slf__hint">Scroll</span></div>
        </div>
      </div>
    </div>
  </div>
</div>

CSS

/* Enrichr Library · https://enrichr.dev */
/* Adapted from https://github.com/codrops/OnScrollLayoutFormations (MIT) — Copyright (c) 2009 - 2024 [Codrops](https://tympanus.net/codrops) */
.ef-slf { width: 100%; }
.ef-slf__wrap { --g: 10px; box-sizing: border-box; width: 100%; padding: 1rem; border-radius: 1rem; font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
.ef-slf__view { position: relative; height: 26rem; max-height: 80vh; overflow-x: hidden; overflow-y: auto; border-radius: 0.8rem; scrollbar-width: none; overscroll-behavior: contain; outline: none; }
.ef-slf__view::-webkit-scrollbar { display: none; }
.ef-slf__view:focus-visible { box-shadow: 0 0 0 2px var(--ef-acc, #e6ff55); }
.ef-slf__space { position: relative; }
.ef-slf__stage { position: sticky; top: 0; height: 26rem; overflow: hidden; display: flex; flex-direction: column; gap: 0.6rem; padding: 0.2rem; box-sizing: border-box; }
.ef-slf__grid { position: relative; flex: 1 1 auto; min-height: 0; display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); grid-template-rows: repeat(3, minmax(0, 1fr)); gap: var(--g); }
.ef-slf__tile { position: relative; border-radius: 0.6rem; overflow: hidden; color: #fbf8f1; will-change: transform, opacity; }
.ef-slf__tile::after { content: ""; position: absolute; inset: 0; background: linear-gradient(180deg, transparent 50%, rgba(0, 0, 0, 0.5)); }
.ef-slf__art { position: absolute; inset: 0; }
.ef-slf__art svg { position: absolute; left: 50%; top: 50%; width: 58%; height: 58%; transform: translate(-50%, -50%); color: var(--ef-pc, #fbf8f1); opacity: 0.9; }
.ef-slf__n { position: absolute; z-index: 1; left: 0.5rem; bottom: 0.4rem; font-size: 0.68rem; font-weight: 800; letter-spacing: 0.1em; text-shadow: 0 2px 8px rgba(0, 0, 0, 0.4); }
.ef-slf__cap { flex: 0 0 auto; display: flex; align-items: baseline; justify-content: space-between; color: #fbf8f1; overflow: hidden; }
.ef-slf__title { display: block; font-size: 1.5rem; font-weight: 800; letter-spacing: -0.02em; line-height: 1.1; color: var(--ef-acc, #e6ff55); will-change: transform; }
.ef-slf__hint { font-size: 0.68rem; letter-spacing: 0.14em; text-transform: uppercase; opacity: 0.7; }
@media (prefers-reduced-motion: reduce) { .ef-slf__tile, .ef-slf__title { will-change: auto; } }

JavaScript

// Enrichr Library · https://enrichr.dev
// Adapted from https://github.com/codrops/OnScrollLayoutFormations (MIT) — Copyright (c) 2009 - 2024 [Codrops](https://tympanus.net/codrops)
// Scroll layout formation: a 4 x 3 grid inside its own scroll container that assembles itself as you scroll (no GSAP, no scroll library).
// The sticky stage stays put while the scroll position, eased by a rAF lerp, drives a staggered timeline: every tile gets its own start
// time and an easing curve, then is moved from a scattered pose into its grid slot. Five formations: rise, fan, scatter (3D),
// cascade, flip (3D). The loop sleeps when settled. Reduced motion: the grid stays fully formed and the scroll only moves the caption in.
// mount(root, { background, accent, formation (rise|fan|scatter|cascade|flip), spread (0.1-0.7), depth (0.3-1.6), ease (0.04-0.4) })
function mount(root, o) {
  var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
  var wrap = root.querySelector(".ef-slf__wrap");
  var view = root.querySelector(".ef-slf__view");
  var space = root.querySelector(".ef-slf__space");
  var stage = root.querySelector(".ef-slf__stage");
  var grid = root.querySelector(".ef-slf__grid");
  var title = root.querySelector(".ef-slf__title");
  var hint = root.querySelector(".ef-slf__hint");
  var opts = {}, tiles = [], gw = 1, gh = 1;
  var viewH = 0, cur = 0, target = 0, raf = 0, ro = null, offs = [], dead = false;
  var COLS = 4, ROWS = 3, N = 12;
  var NAMES = ["Tidal", "Ember", "Moss", "Dusk", "Arch", "Grain", "Haze", "Loop", "Drift", "Fern", "Slate", "Pulse"];
  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"]
  ];

  function art(i, a) {
    var c = "#fbf8f1", d = "#0c1e18";
    var 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 18%, transparent 19%), 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 piece(k) {
    var P = PIECES[k % PIECES.length], NS = "http://www.w3.org/2000/svg";
    var s = document.createElementNS(NS, "svg");
    s.setAttribute("viewBox", P[0]); s.setAttribute("aria-hidden", "true"); s.setAttribute("focusable", "false");
    var p = document.createElementNS(NS, "path");
    p.setAttribute("fill", "currentColor"); p.setAttribute("d", P[1]);
    s.appendChild(p);
    return s;
  }
  function clamp(v, a, b) { return v < a ? a : v > b ? b : v; }
  function rnd(i) { var s = Math.sin(i * 127.1 + 311.7) * 43758.5453; return s - Math.floor(s); }
  function outSine(u) { return Math.sin(u * Math.PI / 2); }
  function outPow3(u) { var r = 1 - u; return 1 - r * r * r; }
  function outExpo(u) { return u >= 1 ? 1 : 1 - Math.pow(2, -10 * u); }
  function build() {
    grid.textContent = "";
    tiles = [];
    for (var i = 0; i < N; i++) {
      var el = document.createElement("div"); el.className = "ef-slf__tile";
      var bg = document.createElement("div"); bg.className = "ef-slf__art"; bg.style.background = art(i, opts.accent);
      if (i % 3 === 1) bg.appendChild(piece((i - 1) / 3));
      var n = document.createElement("span"); n.className = "ef-slf__n"; n.textContent = String(i + 1).padStart(2, "0") + " " + NAMES[i];
      el.appendChild(bg); el.appendChild(n); grid.appendChild(el);
      tiles.push({ el: el, i: i, c: i % COLS, r: Math.floor(i / COLS), cx: 0, cy: 0, rank: 0 });
    }
    measure();
  }
  function ranks() {
    var f = opts.formation, vals = tiles.map(function (t) {
      if (f === "fan") return Math.abs(t.c - (COLS - 1) / 2) + Math.abs(t.r - (ROWS - 1) / 2) * 0.01;
      if (f === "scatter") return Math.hypot(t.cx / (gw / 2), t.cy / (gh / 2));
      return t.i;
    });
    var mn = Math.min.apply(null, vals), mx = Math.max.apply(null, vals);
    tiles.forEach(function (t, k) { t.rank = mx > mn ? (vals[k] - mn) / (mx - mn) : 0; });
  }
  function measure() {
    if (dead) return;
    viewH = view.clientHeight;
    stage.style.height = viewH + "px";
    space.style.height = Math.round(viewH * 3.2) + "px";
    gw = grid.clientWidth || 1; gh = grid.clientHeight || 1;
    tiles.forEach(function (t) {
      t.cx = t.el.offsetLeft + t.el.offsetWidth / 2 - gw / 2;
      t.cy = t.el.offsetTop + t.el.offsetHeight / 2 - gh / 2;
    });
    ranks();
    target = view.scrollTop;
    kick();
  }
  function kick() { if (!raf && !dead) raf = requestAnimationFrame(tick); }
  function place() {
    var max = Math.max(1, space.offsetHeight - viewH);
    var p = clamp(cur / max, 0, 1);
    var P = clamp(p / 0.8, 0, 1);
    var f = opts.formation, sp = opts.spread, D = opts.depth;
    grid.style.perspective = reduce ? "none" : f === "scatter" ? "1000px" : f === "flip" ? "2000px" : "none";
    var cu = clamp((P - 0.55) / 0.45, 0, 1);
    title.style.transform = reduce ? "none" : "translate3d(0," + ((1 - outPow3(cu)) * 120).toFixed(1) + "%,0)";
    hint.style.opacity = String(0.7 * (1 - clamp(p * 6, 0, 1)));
    for (var k = 0; k < tiles.length; k++) {
      var t = tiles[k], s = t.el.style;
      if (reduce) { s.transform = ""; s.opacity = ""; s.transformOrigin = ""; continue; }
      var u = clamp((P - t.rank * sp) / (1 - sp), 0, 1), e, r, tr, op = 1, org = "50% 50%";
      if (f === "rise") {
        e = outSine(u); r = 1 - e;
        tr = "translate3d(0," + ((gh * (1 + 0.8 * rnd(t.i))) * r).toFixed(1) + "px,0)";
      } else if (f === "fan") {
        e = outPow3(u); r = 1 - e; org = "50% 0%";
        var dc = t.c - (COLS - 1) / 2;
        tr = "translate3d(0," + (gh * r).toFixed(1) + "px,0) rotate(" + (-dc * 3 * D * r).toFixed(2) + "deg)";
      } else if (f === "scatter") {
        e = outExpo(u); r = 1 - e;
        var dx = t.cx / (gw / 2), dy = t.cy / (gh / 2), dist = Math.hypot(dx, dy) || 0.001;
        tr = "translate3d(" + (dx / dist * gw * 0.5 * D * r).toFixed(1) + "px," + (dy / dist * gh * 0.5 * D * r).toFixed(1) + "px," + (900 * dist * D * r).toFixed(1) + "px) rotateX(" + (dy * -50 * D * r).toFixed(1) + "deg) rotateY(" + (dx * 70 * D * r).toFixed(1) + "deg) scale(" + (0.7 + 0.3 * e).toFixed(3) + ")";
        op = clamp(e * 2, 0, 1);
      } else if (f === "cascade") {
        e = outPow3(u); r = 1 - e; org = "100% -450%";
        tr = "translate3d(" + (50 * r).toFixed(1) + "%,0,0) rotate(" + (-10 * D * r).toFixed(2) + "deg) skewX(" + (15 * D * r).toFixed(2) + "deg) scaleX(" + (1 + 0.05 * r).toFixed(3) + ")";
        op = e;
      } else {
        e = outExpo(u); r = 1 - e; org = "0% 50%";
        tr = "translate3d(0," + (10 * r).toFixed(1) + "%," + (-200 * r).toFixed(1) + "px) rotateY(" + (65 * D * r).toFixed(1) + "deg)";
        op = clamp(u * 4, 0, 1);
      }
      s.transformOrigin = org; s.transform = tr; s.opacity = op.toFixed(3);
    }
  }
  function tick() {
    raf = 0;
    if (dead) return;
    target = view.scrollTop;
    cur += (target - cur) * (reduce ? 1 : opts.ease);
    if (Math.abs(target - cur) < 0.05) cur = target;
    place();
    if (cur !== target) kick();
  }
  function onScroll() { kick(); }
  function update(next) {
    var prev = opts;
    opts = Object.assign({ background: "#0c1e18", accent: "#e6ff55", formation: "rise", spread: 0.4, depth: 1, ease: 0.12 }, opts, next || {});
    opts.spread = clamp(+opts.spread, 0.1, 0.7);
    opts.depth = clamp(+opts.depth, 0.3, 1.6);
    opts.ease = clamp(+opts.ease, 0.04, 0.4);
    if (["rise", "fan", "scatter", "cascade", "flip"].indexOf(opts.formation) < 0) opts.formation = "rise";
    wrap.style.background = opts.background;
    wrap.style.setProperty("--ef-acc", opts.accent);
    if (prev.accent !== opts.accent) build();
    else { ranks(); kick(); }
  }
  function destroy() {
    dead = true;
    if (raf) cancelAnimationFrame(raf);
    raf = 0;
    offs.forEach(function (f) { f(); }); offs = [];
    if (ro) { ro.disconnect(); ro = null; }
  }
  view.addEventListener("scroll", onScroll, { passive: true });
  offs.push(function () { view.removeEventListener("scroll", onScroll); });
  if (window.ResizeObserver) { ro = new ResizeObserver(function () { measure(); }); ro.observe(view); }
  else { window.addEventListener("resize", measure); offs.push(function () { window.removeEventListener("resize", measure); }); }
  update(o);
  return { update: update, destroy: destroy };
}

// Usage
var fx = mount(document.querySelector("#my-root"), {"accent":"#e6ff55","formation":"rise","spread":0.4,"depth":1,"ease":0.12});

How it works

This is a vanilla JS and CSS port of the idea behind Codrops' On-Scroll Layout Formations, with no GSAP, ScrollTrigger or Lenis. The grid is pinned with a sticky stage inside its own scroll container. Scroll position, eased by a requestAnimationFrame lerp, becomes a 0 to 1 timeline; each tile gets a start time from its rank (list order, or distance from the centre) and an easing curve, then is moved from a scattered pose into its slot. Rise slides tiles up from below, fan swings them in from a top pivot, scatter throws them out in 3D from the grid centre, cascade swings them in along a far pivot, and flip opens each one on a left hinge. The last stretch of scroll holds the finished layout while the caption rises in.

Accessibility and performance

The scroll area is a focusable region, so arrow keys and Page Up and Page Down drive it, and a focus ring shows. With reduced motion the grid stays fully formed and nothing is hidden by the scroll position. Tiles start partly transparent in some formations, so do not rely on the early frames to carry meaning.

Twelve tiles get a transform and an opacity per frame while scrolling, and nothing else. 3D formations add perspective on the grid, which is cheap at this size. The loop sleeps once the eased value reaches the scroll target, and the scroll listener is passive.

SectionGallery
TechniquesScroll-driven, Stagger, CSS 3D
StackVanilla JS, CSS
Works withReact, Next.js, Vanilla
DependenciesNone
Statustested
Inspired byOn-Scroll Layout Formations 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.

Folding layout 3DA stack of panels hinged like a concertina that folds and unfolds in CSS 3D with scroll or a slider. Staggered grid revealA grid of tiles that flip up one after another in random, reading, centre-out or edges-in order, then spin their glyphs into place. Scroll column modesA grid of columns that react to scroll in three ways: drifting parallax, side columns that lean outward, and tiles that fold up out of 3D. Vanilla port, no GSAP.

FAQ

Why is the scroll container inside the element?

So it works inside an overflow hidden box or a preview frame. It listens to its own scroll event instead of the window.

How do I change the grid size?

The grid is a 4 by 3 CSS grid and the constants COLS, ROWS and N in the script. Change them together and the stagger order is recomputed.

Can the finished layout hold longer?

Yes. The timeline completes at 80 percent of the scroll distance, so a smaller number in place() gives a longer hold, and a taller space element gives more scroll.