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 column modes" for my site using the Enrichr Library element at https://enrichr.dev/elements/scroll-column-modes.html.
Read the machine-readable spec and source at https://enrichr.dev/elements/scroll-column-modes.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
- mode: drift
- strength: 1
- ease: 0.12
- cols: 3
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-column-modes.json.
Code
Mount it on an element that contains the markup. It returns an object with update(options) and destroy().
HTML
<div class="ef-scm">
<div class="ef-scm__wrap">
<div class="ef-scm__view" tabindex="0" role="region" aria-label="Column grid, scroll to explore">
<div class="ef-scm__space"><div class="ef-scm__stage"><div class="ef-scm__cols"></div></div></div>
</div>
</div>
</div>CSS
/* Enrichr Library · https://enrichr.dev */
/* Adapted from https://github.com/codrops/OnScrollColumnsRows (MIT) — Copyright (c) 2009 - 2026 [Codrops](https://tympanus.net/codrops) */
.ef-scm { width: 100%; }
.ef-scm__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-scm__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-scm__view::-webkit-scrollbar { display: none; }
.ef-scm__view:focus-visible { box-shadow: 0 0 0 2px var(--ef-acc, #e6ff55); }
.ef-scm__space { position: relative; }
.ef-scm__stage { position: sticky; top: 0; height: 26rem; overflow: hidden; }
.ef-scm__cols { display: grid; grid-template-columns: repeat(var(--n, 3), minmax(0, 1fr)); gap: var(--g); align-items: start; }
.ef-scm__col { display: flex; flex-direction: column; gap: var(--g); will-change: transform; }
.ef-scm__tile { position: relative; perspective: 1500px; }
.ef-scm__clip { position: relative; overflow: hidden; border-radius: 0.7rem; color: #fbf8f1; will-change: transform; }
.ef-scm__art { position: absolute; left: 0; right: 0; top: -14%; bottom: -14%; will-change: transform; }
.ef-scm__art svg { position: absolute; left: 50%; top: 50%; width: 62%; height: 62%; transform: translate(-50%, -50%); color: var(--ef-pc, #fbf8f1); opacity: 0.9; }
.ef-scm__clip::after { content: ""; position: absolute; inset: 0; background: linear-gradient(180deg, transparent 52%, rgba(0, 0, 0, 0.55)); }
.ef-scm__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-scm__col, .ef-scm__clip, .ef-scm__art { will-change: auto; } }JavaScript
// Enrichr Library · https://enrichr.dev
// Adapted from https://github.com/codrops/OnScrollColumnsRows (MIT) — Copyright (c) 2009 - 2026 [Codrops](https://tympanus.net/codrops)
// Scroll column modes: a grid of columns inside its own scroll container (no GSAP, no smooth-scroll library). Native scrolling sets a
// target, a rAF loop eases toward it with a lerp, and each column and tile is placed from that progress. Three modes:
// drift (columns move at different speeds and the artwork inside each tile slides), lean (side columns tilt and slide outward, the
// middle column counter-moves), fold (tiles fold up out of a 3D tilt, sliding in from the sides while the artwork zooms out).
// The loop sleeps when settled. Reduced motion: no easing and no tile effects; columns just track the scroll position.
// mount(root, { background, accent, mode (drift|lean|fold), strength (0.3-1.6), ease (0.04-0.4), cols (3-5) })
function mount(root, o) {
var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
var wrap = root.querySelector(".ef-scm__wrap");
var view = root.querySelector(".ef-scm__view");
var space = root.querySelector(".ef-scm__space");
var stage = root.querySelector(".ef-scm__stage");
var colsEl = root.querySelector(".ef-scm__cols");
var opts = {}, cols = [], tiles = [];
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", "Reed", "Kiln"];
var RATIOS = [[1.3, 0.9, 1.5, 1.1, 1.4], [0.95, 1.45, 1.05, 1.35, 0.9], [1.5, 1.1, 0.85, 1.4, 1.2], [1.0, 1.3, 1.15, 0.9, 1.45], [1.25, 0.95, 1.4, 1.05, 1.3]];
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 outPow2(u) { return 1 - (1 - u) * (1 - u); }
function build() {
colsEl.textContent = "";
colsEl.style.setProperty("--n", opts.cols);
cols = []; tiles = [];
for (var c = 0; c < opts.cols; c++) {
var col = document.createElement("div"); col.className = "ef-scm__col";
for (var k = 0; k < 5; k++) {
var n = c * 5 + k;
var tile = document.createElement("div"); tile.className = "ef-scm__tile";
tile.style.aspectRatio = "1 / " + RATIOS[c % 5][k];
var clip = document.createElement("div"); clip.className = "ef-scm__clip"; clip.style.position = "absolute"; clip.style.inset = "0";
var bg = document.createElement("div"); bg.className = "ef-scm__art"; bg.style.background = art(n, opts.accent);
if (n % 3 === 0) bg.appendChild(piece(n / 3));
var l = document.createElement("span"); l.className = "ef-scm__t"; l.textContent = NAMES[n % NAMES.length];
clip.appendChild(bg); clip.appendChild(l); tile.appendChild(clip);
col.appendChild(tile);
tiles.push({ el: tile, clip: clip, art: bg, col: c, top: 0, h: 0 });
}
colsEl.appendChild(col); cols.push({ el: col, h: 0, range: 0 });
}
measure();
}
function measure() {
if (dead) return;
viewH = view.clientHeight;
stage.style.height = viewH + "px";
space.style.height = Math.round(viewH * 3.2) + "px";
cols.forEach(function (c) { c.h = c.el.offsetHeight; c.range = Math.max(0, c.h - viewH); });
tiles.forEach(function (t) { t.top = t.el.offsetTop; t.h = t.el.offsetHeight; });
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 n = cols.length, mid = (n - 1) / 2, bell = 4 * p * (1 - p), S = opts.strength, mode = opts.mode;
var ys = [];
for (var i = 0; i < n; i++) {
var y = -p * cols[i].range;
if (!reduce) {
var dir = mid ? (i - mid) / mid : 0;
if (mode === "drift") y += -dir * 0.1 * cols[i].h * S * bell;
else if (mode === "lean" && n % 2 === 1 && i === mid) y += 0.1 * cols[i].h * S * bell;
}
ys[i] = y;
cols[i].el.style.transform = "translate3d(0," + y.toFixed(2) + "px,0)";
}
for (var j = 0; j < tiles.length; j++) {
var t = tiles[j], cs = "", as = "", cl = t.clip.style, ar = t.art.style;
if (!reduce) {
var tt = clamp((viewH - (t.top + ys[t.col])) / (viewH + t.h), 0, 1);
if (mode === "drift") {
as = "translate3d(0," + ((1 - 2 * tt) * 30 * S).toFixed(2) + "px,0)";
cl.transformOrigin = ""; cl.opacity = "";
} else if (mode === "lean") {
var side = t.col < mid ? -1 : t.col > mid ? 1 : 0;
if (side) {
cl.transformOrigin = side < 0 ? "0% 100%" : "100% 100%";
cs = "translate3d(" + (side * 10 * S * tt).toFixed(2) + "%,0,0) rotate(" + (side * 6 * S * tt).toFixed(2) + "deg)";
} else cl.transformOrigin = "";
cl.opacity = "";
} else {
var u = outPow2(clamp(tt * 1.7, 0, 1)), r = 1 - u, m = t.col + 1;
var sx = t.col === 0 ? -1 : t.col === n - 1 ? 1 : (t.col - mid) / (mid || 1);
cl.transformOrigin = "50% 100%";
cs = "translate3d(" + (sx * 200 * S * r).toFixed(2) + "%," + (30 * r * S).toFixed(2) + "%," + (30 * m * r * S).toFixed(2) + "px) rotateX(" + (-25 * m * r * S).toFixed(2) + "deg)";
cl.opacity = (0.35 + 0.65 * u).toFixed(3);
as = "scale(" + (1 + 2 * r * S).toFixed(3) + ")";
}
} else { cl.transformOrigin = ""; cl.opacity = ""; }
cl.transform = cs; ar.transform = as;
}
}
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", mode: "drift", strength: 1, ease: 0.12, cols: 3 }, opts, next || {});
opts.cols = Math.max(3, Math.min(5, Math.round(opts.cols)));
opts.ease = clamp(+opts.ease, 0.04, 0.4);
opts.strength = clamp(+opts.strength, 0.3, 1.6);
if (["drift", "lean", "fold"].indexOf(opts.mode) < 0) opts.mode = "drift";
wrap.style.background = opts.background;
wrap.style.setProperty("--ef-acc", opts.accent);
if (prev.cols !== opts.cols || prev.accent !== opts.accent) build();
else 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","mode":"drift","strength":1,"ease":0.12,"cols":3});How it works
This is a vanilla JS and CSS port of the idea behind Codrops' On-Scroll Column and Row Animations, with no GSAP, ScrollTrigger or Lenis. The grid sits in a sticky stage inside its own scroll container. Native scroll sets a target, a requestAnimationFrame loop eases toward it with a lerp, and the resulting progress moves every column. Each tile also gets its own 0 to 1 progress from where it sits in the viewport. Drift offsets columns against each other and slides the artwork inside each tile; lean rotates and slides the side columns outward from their bottom corners while the middle column counter-moves; fold starts each tile tilted back in 3D, offset sideways and zoomed in, then settles it flat. The loop stops when the scroll settles.
Accessibility and performance
The scroll area is a focusable region, so arrow keys, Page Up and Page Down scroll it and a focus ring shows. With reduced motion there is no easing and no tile effects; the columns simply track the scroll position. Tiles are decorative, so if you put real content in them keep the text readable at the start and end poses.
Only transforms and opacity change, written to the columns and tiles that exist (about 15 to 25 elements). Work happens only while the scroll is moving or easing, then the loop sleeps. The scroll listener is passive and a ResizeObserver re-measures on resize. Avoid very large images inside the tiles on low-end devices.
| Section | Gallery |
|---|---|
| Techniques | Scroll-driven, Parallax and depth, CSS 3D |
| Stack | Vanilla JS, CSS |
| Works with | React, Next.js, Vanilla |
| Dependencies | None |
| Status | tested |
| Inspired by | On-Scroll Column & Row 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.
More elements
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, so the page does not need to scroll.
How do I use my own images?
Set a background image on the tile art layer. It is oversized vertically so the parallax slide never shows an edge.
Can I add another mode?
Yes. Add a branch in place() that writes a transform for the column and tile from the column index, tile progress and strength.