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 "Loop scroll squeeze sections" for my site using the Enrichr Library element at https://enrichr.dev/elements/loop-scroll-squeeze.html.
Read the machine-readable spec and source at https://enrichr.dev/elements/loop-scroll-squeeze.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
- tiles: 4
- smoothing: 0.12
- mode: y
- drift: 0.35
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/loop-scroll-squeeze.json.
Code
Mount it on an element that contains the markup. It returns an object with update(options) and destroy().
HTML
<div class="ef-lsq">
<svg class="ef-lsq__defs" width="0" height="0" aria-hidden="true" focusable="false">
<defs>
<symbol id="ef-lsq-p1" viewBox="1513.7 1453.9 322.3 322.3"><path fill="currentColor" 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"/></symbol>
<symbol id="ef-lsq-p4" viewBox="1423.36 860.8 503.07 322.3"><path fill="currentColor" 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"/></symbol>
<symbol id="ef-lsq-p8" viewBox="1513.7 166.86 322.3 503.06"><path fill="currentColor" 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"/></symbol>
</defs>
</svg>
<div class="ef-lsq__view" tabindex="0" role="group" aria-roledescription="looping scroller" aria-label="Looping sections. Scroll, drag or use the arrow keys."></div>
<div class="ef-lsq__hint" aria-hidden="true"><span></span>Scroll</div>
<div class="ef-lsq__live" aria-live="polite"></div>
</div>CSS
/* Enrichr Library · https://enrichr.dev */
/* Adapted from https://github.com/codrops/LoopScrolling (MIT) — Copyright (c) 2009 - 2022 [Codrops](https://tympanus.net/codrops) */
.ef-lsq { position: relative; width: 100%; height: 100%; min-height: 18rem; overflow: hidden; background: #0c1e18; font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
.ef-lsq__defs { position: absolute; width: 0; height: 0; overflow: hidden; }
.ef-lsq__view { position: absolute; inset: 0; overflow: hidden; touch-action: none; cursor: grab; outline: none; overscroll-behavior: contain; }
.ef-lsq__view.is-drag { cursor: grabbing; }
.ef-lsq__view:focus-visible { box-shadow: inset 0 0 0 3px var(--ef-acc, #e6ff55); }
.ef-lsq__tile { position: absolute; left: 0; top: 0; width: 100%; height: var(--ef-h, 100%); overflow: hidden; will-change: transform; backface-visibility: hidden; display: block; }
.ef-lsq__tile.is-off { visibility: hidden; }
.ef-lsq__piece { position: absolute; width: 62%; height: 62%; right: -6%; top: 50%; margin-top: -31%; opacity: 0.95; pointer-events: none; }
.ef-lsq__piece svg { display: block; width: 100%; height: 100%; }
.ef-lsq__txt { position: absolute; left: 7%; bottom: 10%; right: 7%; pointer-events: none; }
.ef-lsq__n { display: block; font-size: 0.78rem; letter-spacing: 0.14em; text-transform: uppercase; opacity: 0.8; margin-bottom: 0.4rem; }
.ef-lsq__t { display: block; font-size: clamp(2rem, 11cqi, 4.6rem); line-height: 0.92; font-weight: 800; letter-spacing: -0.03em; text-transform: uppercase; }
.ef-lsq__a { display: inline-block; margin-top: 0.7rem; font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.08em; border-bottom: 1px solid currentColor; padding-bottom: 1px; }
.ef-lsq { container-type: inline-size; }
.ef-lsq__hint { position: absolute; left: 50%; bottom: 0.7rem; transform: translateX(-50%); display: flex; align-items: center; gap: 0.5rem; padding: 0.3rem 0.8rem; border-radius: 99px; background: rgba(12, 30, 24, 0.78); color: #fbf8f1; font-size: 0.68rem; letter-spacing: 0.16em; text-transform: uppercase; pointer-events: none; transition: opacity 400ms; }
.ef-lsq__hint span { width: 6px; height: 6px; border-radius: 50%; background: var(--ef-acc, #e6ff55); }
.ef-lsq__hint.is-gone { opacity: 0; }
.ef-lsq__live { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
@media (prefers-reduced-motion: reduce) {
.ef-lsq__tile { will-change: auto; }
.ef-lsq__hint { transition: none; }
}JavaScript
// Enrichr Library · https://enrichr.dev
// Adapted from https://github.com/codrops/LoopScrolling (MIT) — Copyright (c) 2009 - 2022 [Codrops](https://tympanus.net/codrops)
// Loop scroll squeeze: full-height sections that loop forever. The section leaving the top is squashed against it while the next one grows
// from the bottom, so the two always fill the frame exactly. Own scroller (wheel, drag, keys) with a lerp, no libraries; the wheel is only
// captured while the pointer is over the element. Reduced motion: no squash, no smoothing, no drift.
// mount(root, { accent, tiles (3-6), smoothing (0.04-0.4), mode ("y" | "both"), drift (0-1) })
function mount(root, o) {
var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
var view = root.querySelector(".ef-lsq__view");
var hint = root.querySelector(".ef-lsq__hint");
var live = root.querySelector(".ef-lsq__live");
var opts = {}, tiles = [], H = 300, pos = 0, target = 0, raf = 0, last = 0, hover = false, focus = false, drag = false;
var lastY = 0, vel = 0, lastIdx = -1, offs = [], idleAt = 0, destroyed = false, ro = null;
var DATA = [["Stack", "Opening frame"], ["Field", "Open ground"], ["Loop", "Round again"], ["Ember", "Slow burn"], ["Moss", "Quiet growth"], ["Grain", "Fine detail"]];
var PIECES = ["p1", "p4", "p8", "p4", "p1", "p8"];
function look(i, a) {
var k = i % 6;
if (k === 0) return ["linear-gradient(160deg," + a + "," + "#c6f26b)", "#0c1e18", "#005236"];
if (k === 1) return ["linear-gradient(165deg,#005236,#0c1e18)", "#fbf8f1", a];
if (k === 2) return ["radial-gradient(circle at 78% 22%,#fff 0,transparent 55%),#fbf8f1", "#0c1e18", "#0a6b46"];
if (k === 3) return ["linear-gradient(200deg,#0a6b46,#005236)", "#fbf8f1", "#c6f26b"];
if (k === 4) return ["linear-gradient(150deg,#0c1e18,#0a6b46)", "#fbf8f1", a];
return ["repeating-linear-gradient(120deg,#2f7d5b 0 14px,#0a6b46 14px 28px)", "#fbf8f1", "#fbf8f1"];
}
function clearTiles() { view.textContent = ""; tiles = []; }
function build() {
clearTiles();
for (var i = 0; i < opts.tiles; i++) {
var d = DATA[i % DATA.length], l = look(i, opts.accent);
var el = document.createElement("div");
el.className = "ef-lsq__tile";
el.style.background = l[0]; el.style.color = l[1];
var pc = document.createElement("span"); pc.className = "ef-lsq__piece"; pc.style.color = l[2];
pc.innerHTML = '<svg viewBox="0 0 10 10" preserveAspectRatio="xMidYMid meet" aria-hidden="true"><use href="#ef-lsq-' + PIECES[i % 6] + '"></use></svg>';
var tx = document.createElement("span"); tx.className = "ef-lsq__txt";
var n = document.createElement("span"); n.className = "ef-lsq__n"; n.textContent = String(i + 1).padStart(2, "0") + " / " + String(opts.tiles).padStart(2, "0");
var t = document.createElement("span"); t.className = "ef-lsq__t"; t.textContent = d[0];
var a = document.createElement("span"); a.className = "ef-lsq__a"; a.textContent = d[1];
tx.appendChild(n); tx.appendChild(t); tx.appendChild(a);
el.appendChild(pc); el.appendChild(tx);
view.appendChild(el);
tiles.push({ el: el, name: d[0], txt: tx });
}
render();
}
function measure() {
var h = view.clientHeight;
if (h > 40 && h !== H) { var ratio = H ? pos / H : 0; H = h; pos = target = ratio * H; }
else if (h > 40) H = h;
view.style.setProperty("--ef-h", H + "px");
render();
}
function mod(a, n) { return ((a % n) + n) % n; }
function render() {
var n = tiles.length; if (!n) return;
var period = n * H, p = mod(pos, period), idx = 0, best = 1e9;
for (var i = 0; i < n; i++) {
var t = mod(i * H - p + H, period) - H;
var tl = tiles[i], s = 1, off = false, org = "50% 0%";
if (Math.abs(t) < best) { best = Math.abs(t); idx = i; }
if (t >= H - 0.5) off = true;
else if (!reduce) { s = t > 0 ? 1 - t / H : 1 + t / H; if (t <= 0) org = "50% 100%"; if (s < 0.002) s = 0.002; }
tl.el.classList.toggle("is-off", off);
if (off) continue;
tl.el.style.transformOrigin = org;
tl.el.style.transform = "translate3d(0," + t.toFixed(2) + "px,0) " + (opts.mode === "both" && !reduce ? "scale(" + s.toFixed(4) + ")" : "scaleY(" + s.toFixed(4) + ")");
tl.txt.style.opacity = reduce ? 1 : Math.max(0, Math.min(1, s * 1.6 - 0.2)).toFixed(3);
}
if (idx !== lastIdx) { lastIdx = idx; live.textContent = "Section " + (idx + 1) + " of " + n + ": " + tiles[idx].name; }
}
function kick() { if (!raf && !destroyed) { last = 0; raf = requestAnimationFrame(tick); } }
function tick(now) {
raf = 0;
var dt = last ? Math.min(48, now - last) : 16.7; last = now;
var moving = false;
if (!reduce && opts.drift > 0 && !hover && !focus && !drag && now > idleAt) { target += opts.drift * 0.7 * (dt / 16.7); moving = true; }
var diff = target - pos;
if (reduce) { pos = target; }
else if (Math.abs(diff) > 0.05) { pos += diff * (1 - Math.pow(1 - opts.smoothing, dt / 16.7)); moving = true; }
else pos = target;
if (Math.abs(pos) > 1e7) { var sh = Math.floor(pos / (tiles.length * H)) * tiles.length * H; pos -= sh; target -= sh; }
render();
if (moving || (!reduce && opts.drift > 0 && !hover && !focus && !drag)) raf = requestAnimationFrame(tick);
}
function nudge(d) { target += d; idleAt = performance.now() + 1500; hint.classList.add("is-gone"); kick(); }
function onWheel(e) {
var d = e.deltaY * (e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? H : 1);
if (!d && e.deltaX) d = e.deltaX;
e.preventDefault();
nudge(d * 0.9);
}
function onDown(e) { if (e.button > 0) return; drag = true; lastY = e.clientY; vel = 0; view.classList.add("is-drag"); try { view.setPointerCapture(e.pointerId); } catch (x) {} }
function onMove(e) { if (!drag) return; var d = lastY - e.clientY; lastY = e.clientY; vel = d; nudge(d * 1.1); }
function onUp(e) { if (!drag) return; drag = false; view.classList.remove("is-drag"); nudge(vel * 8); try { view.releasePointerCapture(e.pointerId); } catch (x) {} }
function onKey(e) {
var k = e.key, dir = 0;
if (k === "ArrowDown" || k === "PageDown" || (k === " " && !e.shiftKey)) dir = 1;
else if (k === "ArrowUp" || k === "PageUp" || (k === " " && e.shiftKey)) dir = -1;
else return;
e.preventDefault();
nudge((Math.round(target / H) + dir) * H - target);
}
function onEnter() { hover = true; }
function onLeave() { hover = false; kick(); }
function onFocus() { focus = true; }
function onBlur() { focus = false; kick(); }
view.addEventListener("wheel", onWheel, { passive: false });
view.addEventListener("pointerdown", onDown);
view.addEventListener("pointermove", onMove);
view.addEventListener("pointerup", onUp);
view.addEventListener("pointercancel", onUp);
view.addEventListener("keydown", onKey);
view.addEventListener("pointerenter", onEnter);
view.addEventListener("pointerleave", onLeave);
view.addEventListener("focus", onFocus);
view.addEventListener("blur", onBlur);
if (window.ResizeObserver) { ro = new ResizeObserver(measure); ro.observe(view); }
else { window.addEventListener("resize", measure); offs.push(function () { window.removeEventListener("resize", measure); }); }
function update(next) {
var prev = opts;
opts = Object.assign({ accent: "#e6ff55", tiles: 4, smoothing: 0.12, mode: "y", drift: 0.35 }, opts, next || {});
opts.tiles = Math.max(3, Math.min(6, Math.round(Number(opts.tiles))));
opts.smoothing = Math.max(0.04, Math.min(0.4, Number(opts.smoothing)));
opts.drift = Math.max(0, Math.min(1, Number(opts.drift)));
root.style.setProperty("--ef-acc", opts.accent);
if (prev.tiles !== opts.tiles || prev.accent !== opts.accent) { var r = H ? pos / H : 0; measure(); build(); pos = target = r * H; render(); }
else render();
kick();
}
update(o);
measure();
return {
update: update,
destroy: function () {
destroyed = true;
if (raf) cancelAnimationFrame(raf); raf = 0;
view.removeEventListener("wheel", onWheel);
view.removeEventListener("pointerdown", onDown);
view.removeEventListener("pointermove", onMove);
view.removeEventListener("pointerup", onUp);
view.removeEventListener("pointercancel", onUp);
view.removeEventListener("keydown", onKey);
view.removeEventListener("pointerenter", onEnter);
view.removeEventListener("pointerleave", onLeave);
view.removeEventListener("focus", onFocus);
view.removeEventListener("blur", onBlur);
if (ro) ro.disconnect();
offs.forEach(function (f) { f(); }); offs = [];
}
};
}
// Usage
var fx = mount(document.querySelector("#my-root"), {"accent":"#e6ff55","tiles":4,"smoothing":0.12,"mode":"y","drift":0.35});How it works
A vanilla port, no GSAP, ScrollTrigger or smooth-scroll library. The element owns its own scroll position and moves it with wheel, drag and key input, then eases the displayed position toward the target with a frame-rate independent lerp. Each section's offset is wrapped modulo the total height, so there are no clones and the loop is seamless. A section below the fold is scaled up from the bottom edge and one above is scaled down toward the top edge, so the two always fill the frame exactly. Wheel events are only captured while the pointer is over the element.
Accessibility and performance
The scroller is focusable and responds to arrow keys, Page Up, Page Down and Space, stepping one section at a time, with a polite live region announcing the current section. Reduced motion removes the squash, the smoothing and the idle drift, leaving plain sliding sections. Drift also pauses on hover and focus. Text is squashed with its section, so keep copy short and check contrast over your own artwork.
Only transforms and opacity change per frame, on at most two visible sections; off-screen sections are hidden. The animation loop stops when the position settles and no drift is set. Avoid heavy filters or huge images inside sections.
| Section | Full page |
|---|---|
| Techniques | Scroll-driven, Stagger |
| Stack | Vanilla JS, CSS |
| Works with | React, Next.js, Vanilla |
| Dependencies | None |
| Status | tested |
| On CodePen | Open the live pen on our CodePen account; fork it to experiment. |
| Inspired by | Loop Scrolling 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
Does it need a smooth-scroll library?
No. The element keeps its own target position and eases toward it, so it works in any container without taking over the page scroll.
Why does the wheel not scroll the page over it?
Wheel is captured only while the pointer is over the element so the loop can run endlessly. Move the pointer off it and the page scrolls normally.
Can I use photos instead of gradients?
Yes. Set a background image on a section; the squeeze works on any content.