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.
| Section | Gallery |
|---|---|
| Techniques | Scroll-driven, Parallax and depth |
| Stack | Vanilla JS, CSS |
| Works with | React, Next.js, Vanilla |
| Dependencies | None |
| Status | tested |
| Inspired by | Smooth 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.
More elements
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.