Copied!
Gallery

Smooth scrolly images

A vertical list of image cards in its own scroll container, eased with a lerp, where scroll velocity squashes and skews each card while its image drifts for parallax.

← 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 "Smooth scrolly images" for my site using the Enrichr Library element at https://enrichr.dev/elements/smooth-scrolly-images.html.

Read the machine-readable spec and source at https://enrichr.dev/elements/smooth-scrolly-images.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
- parallax: 0.2
- velocity: 1
- count: 5

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/smooth-scrolly-images.json.

Code

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

HTML

<div class="ef-ssi">
  <div class="ef-ssi__wrap">
    <div class="ef-ssi__view" tabindex="0" role="region" aria-label="Image list, scroll to explore">
      <div class="ef-ssi__space"><div class="ef-ssi__stage"><div class="ef-ssi__list"></div><span class="ef-ssi__bar" aria-hidden="true"></span></div></div>
    </div>
  </div>
</div>

CSS

/* Enrichr Library · https://enrichr.dev */
.ef-ssi { width: 100%; }
.ef-ssi__wrap { box-sizing: border-box; width: 100%; padding: 1rem; border-radius: 1rem; font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
.ef-ssi__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-ssi__view::-webkit-scrollbar { display: none; }
.ef-ssi__view:focus-visible { box-shadow: 0 0 0 2px var(--ef-acc, #e6ff55); }
.ef-ssi__space { position: relative; }
.ef-ssi__stage { position: sticky; top: 0; height: 26rem; overflow: hidden; }
.ef-ssi__list { position: relative; display: flex; flex-direction: column; gap: 1rem; padding: 0.2rem 0.2rem 1rem; will-change: transform; }
.ef-ssi__card { position: relative; flex: none; height: 13rem; border-radius: 0.9rem; overflow: hidden; color: #fbf8f1; transform-origin: 50% 50%; will-change: transform; box-shadow: 0 10px 18px -12px rgba(0, 0, 0, 0.6); }
.ef-ssi__art { position: absolute; left: 0; right: 0; top: 0; bottom: 0; will-change: transform; }
.ef-ssi__card::after { content: ""; position: absolute; inset: 0; background: linear-gradient(180deg, rgba(0, 0, 0, 0.1), transparent 35%, rgba(0, 0, 0, 0.62)); pointer-events: none; }
.ef-ssi__n { position: absolute; z-index: 1; top: 0.8rem; left: 1rem; font-size: 0.68rem; font-weight: 700; letter-spacing: 0.14em; opacity: 0.85; }
.ef-ssi__txt { position: absolute; z-index: 1; left: 1rem; right: 1rem; bottom: 0.9rem; }
.ef-ssi__t { display: block; font-size: 1.25rem; font-weight: 800; letter-spacing: -0.01em; line-height: 1.1; text-shadow: 0 2px 10px rgba(0, 0, 0, 0.35); }
.ef-ssi__s { display: block; margin-top: 0.25rem; font-size: 0.72rem; opacity: 0.82; letter-spacing: 0.04em; }
.ef-ssi__bar { position: absolute; right: 0.35rem; top: 0.6rem; width: 3px; height: 1.6rem; border-radius: 3px; background: var(--ef-acc, #e6ff55); opacity: 0.85; pointer-events: none; }
@media (prefers-reduced-motion: reduce) { .ef-ssi__list, .ef-ssi__card, .ef-ssi__art { will-change: auto; } }

JavaScript

// Enrichr Library · https://enrichr.dev
// Smooth scrolly images: a list of gradient image cards inside its own scroll container. Native scrolling (wheel, touch, keys, drag)
// sets a target; a rAF loop eases the list toward it with a lerp, and the speed of that easing drives a per-card scale, skew and
// image parallax. The loop sleeps when the list has settled. Reduced motion: no easing, no velocity effects, no parallax.
// mount(root, { background, accent, ease (0.04-0.4), parallax (0-0.5), velocity (0-1.5), count (3-8) })
function mount(root, o) {
  var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
  var wrap = root.querySelector(".ef-ssi__wrap");
  var view = root.querySelector(".ef-ssi__view");
  var space = root.querySelector(".ef-ssi__space");
  var stage = root.querySelector(".ef-ssi__stage");
  var list = root.querySelector(".ef-ssi__list");
  var bar = root.querySelector(".ef-ssi__bar");
  var opts = {}, cards = [], arts = [], tops = [], hs = [];
  var viewH = 0, listH = 0, cur = 0, target = 0, raf = 0, ro = null, offs = [], dead = false;
  var DATA = [["Tidal", "Field notes"], ["Ember", "Studio series"], ["Moss", "Quiet objects"], ["Dusk", "Night shift"], ["Arch", "Form study"], ["Grain", "Print run"], ["Haze", "Soft light"], ["Loop", "Motion kit"]];

  // 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-ssi__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 72% 32%, " + a + " 0 16%, transparent 17%), 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 14px, " + d + " 14px 28px)";
    if (p === 3) return "radial-gradient(circle at 30% 35%, " + a + " 0 18%, transparent 19%), radial-gradient(circle at 64% 62%, " + c + " 0 16%, transparent 17%), linear-gradient(200deg, #0a6b46, " + d + ")";
    if (p === 4) return "radial-gradient(ellipse 30% 24% 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 / 18px 18px, linear-gradient(145deg, #005236, " + d + ")";
  }
  function clamp(v, a, b) { return v < a ? a : v > b ? b : v; }
  function build() {
    list.textContent = "";
    cards = []; arts = [];
    for (var i = 0; i < opts.count; i++) {
      var c = document.createElement("div"); c.className = "ef-ssi__card";
      var bg = document.createElement("div"); bg.className = "ef-ssi__art"; bg.style.background = art(i, opts.accent);
      bg.insertAdjacentHTML("beforeend", piece(i, opts.accent));
      var n = document.createElement("span"); n.className = "ef-ssi__n"; n.textContent = String(i + 1).padStart(2, "0");
      var tx = document.createElement("div"); tx.className = "ef-ssi__txt";
      var t = document.createElement("span"); t.className = "ef-ssi__t"; t.textContent = DATA[i % DATA.length][0];
      var s = document.createElement("span"); s.className = "ef-ssi__s"; s.textContent = DATA[i % DATA.length][1];
      tx.appendChild(t); tx.appendChild(s);
      c.appendChild(bg); c.appendChild(n); c.appendChild(tx);
      list.appendChild(c); cards.push(c); arts.push(bg);
    }
    measure();
  }
  function measure() {
    if (dead) return;
    viewH = view.clientHeight;
    stage.style.height = viewH + "px";
    tops = []; hs = [];
    var maxH = 0;
    for (var i = 0; i < cards.length; i++) { tops.push(cards[i].offsetTop); hs.push(cards[i].offsetHeight); if (hs[i] > maxH) maxH = hs[i]; }
    listH = list.offsetHeight;
    space.style.height = Math.max(listH, viewH) + "px";
    var pad = reduce ? 0 : Math.ceil((viewH / 2 + maxH / 2) * opts.parallax);
    for (var j = 0; j < arts.length; j++) { arts[j].style.top = -pad + "px"; arts[j].style.bottom = -pad + "px"; }
    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;
    list.style.transform = "translate3d(0," + (-cur).toFixed(2) + "px,0)";
    var range = Math.max(1, listH - viewH);
    bar.style.transform = "translateY(" + (clamp(cur / range, 0, 1) * (viewH - 1.2 * 16 - 38)).toFixed(1) + "px)";
    var amt = clamp(Math.abs(v) / 50, 0, 1) * opts.velocity;
    var sk = clamp(v / 40, -1, 1) * 4 * opts.velocity;
    if (!reduce) {
      for (var i = 0; i < cards.length; i++) {
        var cc = tops[i] + hs[i] / 2 - cur - viewH / 2;
        if (Math.abs(cc) > viewH / 2 + hs[i]) continue;
        cards[i].style.transform = "scale(" + (1 - amt * 0.1).toFixed(4) + ") skewY(" + sk.toFixed(2) + "deg)";
        arts[i].style.transform = "translate3d(0," + (-cc * opts.parallax).toFixed(1) + "px,0)";
      }
    }
    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, parallax: 0.2, velocity: 1, count: 5 }, opts, next || {});
    opts.count = Math.max(3, Math.min(8, Math.round(opts.count)));
    opts.ease = clamp(+opts.ease, 0.04, 0.4);
    opts.parallax = clamp(+opts.parallax, 0, 0.5);
    opts.velocity = clamp(+opts.velocity, 0, 1.5);
    wrap.style.background = opts.background;
    wrap.style.setProperty("--ef-acc", opts.accent);
    if (prev.count !== opts.count || prev.accent !== opts.accent) build();
    else if (prev.parallax !== opts.parallax) 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,"parallax":0.2,"velocity":1,"count":5});

How it works

The container scrolls natively, so wheel, touch, drag and keys all work. Inside it a sticky stage holds the list, and a spacer gives the scroller its height. On every scroll event a requestAnimationFrame loop reads scrollTop as the target and moves a current value toward it by a fraction each frame (a lerp). The list is translated by that current value, so it trails the real scroll and glides to rest. The gap between current and target each frame is the velocity: it scales each card down a little, skews it, and shifts the oversized image layer inside the card against its distance from the viewport centre, which gives the parallax. When current equals target the loop stops and wakes again on the next scroll.

Accessibility and performance

The scroller is a focusable region, so arrow keys, Page Up and Down, Home and End scroll it, with a visible focus ring. Because scrolling is native, screen readers and touch work as usual. With prefers-reduced-motion the lerp is bypassed and the scale, skew and parallax are skipped, so the list simply scrolls. The card text sits over a darkening scrim for contrast.

Only transforms are written each frame, to the list and to the cards near the viewport; off-screen cards are skipped. Card positions are measured once and again on resize through a ResizeObserver, never inside the loop. The loop sleeps when the list has settled, so an idle element costs nothing.

SectionGallery
TechniquesScroll-driven, Parallax and depth
StackVanilla JS, CSS
Works withReact, Next.js, Vanilla
DependenciesNone
Statustested
Inspired bySmooth Scrolly Images by Codrops 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 not hijack the wheel event?

Native scrolling keeps touch, keyboard, drag and accessibility behaviour intact. The effect is a visual follower of the real scroll position, so nothing breaks if the script fails.

How do I make it feel heavier?

Lower the smoothing value. A smaller fraction per frame means a longer glide and a stronger velocity effect.

Can I use real photos?

Yes. Put an image in the art layer of each card. It is taller than the card on purpose so the parallax shift never shows an edge.