Copied!
Gallery

Alternate column scroll

A three-column image grid where the outer columns slide up and the middle column slides down as you scroll inside it, smoothed with a lerp and a touch of skew.

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

Read the machine-readable spec and source at https://enrichr.dev/elements/alternate-column-scroll.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
- ease: 0.1
- skew: 1
- gap: 12
- tiles: 4

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/alternate-column-scroll.json.

Code

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

HTML

<div class="ef-acs">
  <div class="ef-acs__wrap">
    <div class="ef-acs__view" tabindex="0" role="region" aria-label="Image grid, scroll to explore">
      <div class="ef-acs__space"><div class="ef-acs__stage"><div class="ef-acs__cols"></div></div></div>
    </div>
  </div>
</div>

CSS

/* Enrichr Library · https://enrichr.dev */
.ef-acs { width: 100%; }
.ef-acs__wrap { --g: 12px; box-sizing: border-box; width: 100%; padding: 1rem; border-radius: 1rem; font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
.ef-acs__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-acs__view::-webkit-scrollbar { display: none; }
.ef-acs__view:focus-visible { box-shadow: 0 0 0 2px var(--ef-acc, #e6ff55); }
.ef-acs__space { position: relative; }
.ef-acs__stage { position: sticky; top: 0; height: 26rem; overflow: hidden; }
.ef-acs__cols { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--g); align-items: start; }
.ef-acs__col { display: flex; flex-direction: column; gap: var(--g); will-change: transform; }
.ef-acs__tile { position: relative; border-radius: 0.7rem; overflow: hidden; color: #fbf8f1; }
.ef-acs__tile::after { content: ""; position: absolute; inset: 0; background: linear-gradient(180deg, transparent 50%, rgba(0, 0, 0, 0.55)); }
.ef-acs__t { position: absolute; z-index: 1; left: 0.6rem; bottom: 0.5rem; font-size: 0.78rem; font-weight: 800; letter-spacing: 0.02em; text-shadow: 0 2px 8px rgba(0, 0, 0, 0.4); }
@media (prefers-reduced-motion: reduce) { .ef-acs__col { will-change: auto; } }

JavaScript

// Enrichr Library · https://enrichr.dev
// Alternate column scroll: a three-column gradient grid inside its own scroll container. Native scrolling sets a target, a rAF loop
// eases toward it with a lerp, and the progress (0-1) moves the outer columns up while the middle column moves down. Scroll speed adds
// a small skew. The loop sleeps when settled. Reduced motion: no easing and no skew; columns still track the scroll position.
// mount(root, { background, accent, ease (0.04-0.4), skew (0-1.5), gap (px), tiles (3-6) })
function mount(root, o) {
  var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
  var wrap = root.querySelector(".ef-acs__wrap");
  var view = root.querySelector(".ef-acs__view");
  var space = root.querySelector(".ef-acs__space");
  var stage = root.querySelector(".ef-acs__stage");
  var colsEl = root.querySelector(".ef-acs__cols");
  var opts = {}, cols = [], ranges = [];
  var viewH = 0, cur = 0, target = 0, raf = 0, ro = null, offs = [], dead = false;
  var NAMES = ["Tidal", "Ember", "Moss", "Dusk", "Arch", "Grain", "Haze", "Loop", "Drift", "Fern", "Slate", "Pulse", "Dune", "Mist", "Rune", "Glow", "Echo", "Bloom"];
  var RATIOS = [[1.3, 0.9, 1.5, 1.1, 1.4, 0.85], [0.95, 1.45, 1.05, 1.35, 0.9, 1.25], [1.5, 1.1, 0.85, 1.4, 1.2, 1.0]];

  // 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-acs__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";
    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 clamp(v, a, b) { return v < a ? a : v > b ? b : v; }
  function build() {
    colsEl.textContent = "";
    cols = [];
    for (var c = 0; c < 3; c++) {
      var col = document.createElement("div"); col.className = "ef-acs__col";
      for (var k = 0; k < opts.tiles; k++) {
        var n = c * opts.tiles + k;
        var t = document.createElement("div"); t.className = "ef-acs__tile";
        t.style.aspectRatio = "1 / " + RATIOS[c][k % 6];
        t.style.background = art(n, opts.accent);
        t.insertAdjacentHTML("afterbegin", piece(n, opts.accent));
        var l = document.createElement("span"); l.className = "ef-acs__t"; l.textContent = NAMES[n % NAMES.length];
        t.appendChild(l); col.appendChild(t);
      }
      colsEl.appendChild(col); cols.push(col);
    }
    measure();
  }
  function measure() {
    if (dead) return;
    viewH = view.clientHeight;
    stage.style.height = viewH + "px";
    space.style.height = Math.round(viewH * 3.2) + "px";
    ranges = cols.map(function (c) { return Math.max(0, c.offsetHeight - viewH); });
    target = view.scrollTop;
    kick();
  }
  function kick() { if (!raf && !dead) raf = requestAnimationFrame(tick); }
  function tick() {
    raf = 0;
    if (dead) return;
    target = view.scrollTop;
    var prev = cur;
    cur += (target - cur) * (reduce ? 1 : opts.ease);
    if (Math.abs(target - cur) < 0.05) cur = target;
    var v = reduce ? 0 : cur - prev;
    var max = Math.max(1, space.offsetHeight - viewH);
    var p = clamp(cur / max, 0, 1);
    var sk = clamp(v / 40, -1, 1) * 5 * opts.skew;
    for (var i = 0; i < cols.length; i++) {
      var y = i % 2 ? -ranges[i] + p * ranges[i] : -p * ranges[i];
      cols[i].style.transform = "translate3d(0," + y.toFixed(2) + "px,0)" + (reduce ? "" : " skewY(" + (i % 2 ? sk : -sk).toFixed(2) + "deg)");
    }
    if (cur !== target || Math.abs(v) > 0.01) kick();
  }
  function onScroll() { kick(); }
  function update(next) {
    var prev = opts;
    opts = Object.assign({ background: "#0c1e18", accent: "#e6ff55", ease: 0.1, skew: 1, gap: 12, tiles: 4 }, opts, next || {});
    opts.tiles = Math.max(3, Math.min(6, Math.round(opts.tiles)));
    opts.ease = clamp(+opts.ease, 0.04, 0.4);
    opts.skew = clamp(+opts.skew, 0, 1.5);
    opts.gap = clamp(+opts.gap, 2, 32);
    wrap.style.background = opts.background;
    wrap.style.setProperty("--ef-acc", opts.accent);
    wrap.style.setProperty("--g", opts.gap + "px");
    if (prev.tiles !== opts.tiles || prev.accent !== opts.accent) build();
    else if (prev.gap !== opts.gap) measure();
  }
  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","ease":0.1,"skew":1,"gap":12,"tiles":4});

How it works

The container scrolls natively and holds a sticky stage with three tall columns. A requestAnimationFrame loop eases a current scroll value toward scrollTop, turns it into progress from 0 to 1, and maps that to each column's own travel distance (its height minus the viewport height). Columns one and three move from 0 to minus their travel; column two starts pulled up by its travel and moves back down. Because every column is driven by the same progress they stay in sync while heading in opposite directions. The speed of the easing adds a small skew that flips sign on alternate columns. The loop sleeps once everything has settled.

Accessibility and performance

The scroller is a focusable region with a visible focus ring, so arrow keys, Page Up and Down, Home and End all work, and touch scrolling is native. With prefers-reduced-motion the easing and skew are off and the columns follow the scroll position directly. Tiles carry short text labels over a darkening scrim. If the grid becomes real content, keep it in reading order, since the middle column moves independently.

Each frame writes one transform per column, three in total. Column heights are measured on build and on resize through a ResizeObserver, not in the loop. The scroll range is bounded, so at either end the page can continue to scroll normally instead of being trapped.

SectionGallery
TechniquesScroll-driven, Parallax and depth
StackVanilla JS, CSS
Works withReact, Next.js, Vanilla
DependenciesNone
Statustested
Inspired byAlternate Column Scroll Animation by Manoela Ilic 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.

Parallax pattern sectionsFull-height sections whose patterned backgrounds drift slower than the page, so each headline floats over a deeper layer. Layered parallax sceneA seeded landscape of hill ridges, a sun and a headline tucked behind the front hill, each moving at its own rate as you scroll. Background shift with blend modesFull-height sections with huge type in mix-blend-mode difference over a gradient layer that slides, rotates and hue-shifts as you scroll or drag.

FAQ

Why does the scroll stop instead of looping forever?

A bounded range lets the page scroll on once you reach the end, which is kinder than trapping the wheel. The columns reach their own ends at the same moment.

Can I add more columns?

Yes. Build more column elements and keep the odd and even rule in the loop: even indices move up, odd ones move down.

How do I use photos?

Set an image as the tile background, or put an img inside the tile. Tile height comes from its aspect ratio, so the column heights stay predictable.