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 "Background shift with blend modes" for my site using the Enrichr Library element at https://enrichr.dev/elements/background-shift-blend.html.
Read the machine-readable spec and source at https://enrichr.dev/elements/background-shift-blend.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
- background: #0c1e18
- shift: 0.8
- count: 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/background-shift-blend.json.
Code
Mount it on an element that contains the markup. It returns an object with update(options) and destroy().
HTML
<div class="ef-bsb">
<div class="ef-bsb__wrap">
<div class="ef-bsb__bg" aria-hidden="true"></div>
<div class="ef-bsb__scroll" tabindex="0" role="region" aria-label="Scrolling sections"></div>
<div class="ef-bsb__dots" aria-hidden="true"></div>
</div>
</div>CSS
/* Enrichr Library · https://enrichr.dev */
.ef-bsb { width: 100%; }
.ef-bsb__wrap { --p: 0; --ef-acc: #e6ff55; --ef-bg: #0c1e18; --ef-shift: 0.8; position: relative; isolation: isolate; box-sizing: border-box; width: 100%; height: 20rem; overflow: hidden; border-radius: 1rem; font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; background: var(--ef-bg); }
.ef-bsb__bg { position: absolute; inset: -30%; z-index: 0; pointer-events: none; background: radial-gradient(circle at 30% 30%, var(--ef-acc) 0 22%, transparent 55%), radial-gradient(circle at 75% 70%, #005236 0 30%, transparent 62%), linear-gradient(135deg, #005236, var(--ef-bg)); transform: translate3d(calc(var(--p) * var(--ef-shift) * -28%), calc(var(--p) * var(--ef-shift) * -22%), 0) rotate(calc(var(--p) * var(--ef-shift) * 40deg)) scale(calc(1 + var(--p) * var(--ef-shift) * 0.3)); filter: hue-rotate(calc(var(--p) * var(--ef-shift) * 70deg)); transition: transform 350ms ease-out, filter 350ms ease-out; will-change: transform; }
.ef-bsb__scroll { position: relative; z-index: 1; height: 100%; overflow-y: auto; overscroll-behavior: contain; scroll-snap-type: y mandatory; scrollbar-width: none; outline: none; cursor: grab; touch-action: pan-y; }
.ef-bsb__scroll::-webkit-scrollbar { display: none; }
.ef-bsb__scroll.is-drag { cursor: grabbing; scroll-snap-type: none; }
.ef-bsb__scroll:focus-visible { box-shadow: inset 0 0 0 2px var(--ef-acc); border-radius: inherit; }
.ef-bsb__sec { box-sizing: border-box; height: 100%; padding: 1.2rem 1.5rem; display: flex; flex-direction: column; justify-content: center; scroll-snap-align: start; user-select: none; }
.ef-bsb__big { margin: 0; font-size: clamp(2.6rem, 11vw, 5.6rem); font-weight: 900; line-height: 0.9; letter-spacing: -0.04em; text-transform: uppercase; color: #fbf8f1; mix-blend-mode: difference; }
.ef-bsb__sm { margin: 0.7rem 0 0; font-size: 0.72rem; font-weight: 700; letter-spacing: 0.16em; text-transform: uppercase; color: #fbf8f1; mix-blend-mode: difference; }
.ef-bsb__dots { position: absolute; z-index: 2; right: 0.8rem; top: 50%; transform: translateY(-50%); display: flex; flex-direction: column; gap: 0.45rem; pointer-events: none; }
.ef-bsb__dot { width: 0.4rem; height: 0.4rem; border-radius: 50%; background: #fbf8f1; opacity: 0.35; mix-blend-mode: difference; transition: opacity 200ms, transform 200ms; }
.ef-bsb__dot.is-on { opacity: 1; transform: scale(1.5); }
@media (prefers-reduced-motion: reduce) { .ef-bsb__bg, .ef-bsb__dot { transition: none; } .ef-bsb__scroll { scroll-behavior: auto; } }JavaScript
// Enrichr Library · https://enrichr.dev
// Background shift with blend modes: full-height sections inside a scroll container, with huge type set to mix-blend-mode: difference over a
// background layer that slides, rotates and hue-shifts as you scroll or drag. The element's own scroller drives it, so it works in a clipped box.
// Mouse drag scrolls too; arrow keys scroll when the region is focused. Reduced motion removes the transitions and smooth scrolling.
// mount(root, { background, accent, shift (0-1.5), count (2-6) })
function mount(root, o) {
var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
var wrap = root.querySelector(".ef-bsb__wrap");
var sc = root.querySelector(".ef-bsb__scroll");
var dots = root.querySelector(".ef-bsb__dots");
var opts = {}, offs = [], raf = 0, dotEls = [];
var DATA = [["Grow", "Section one"], ["Shift", "Section two"], ["Blend", "Section three"], ["Flow", "Section four"], ["Drift", "Section five"], ["Land", "Section six"]];
function paint() {
raf = 0;
var max = sc.scrollHeight - sc.clientHeight;
var p = max > 0 ? sc.scrollTop / max : 0;
wrap.style.setProperty("--p", p.toFixed(4));
var idx = Math.round(p * (opts.count - 1));
for (var i = 0; i < dotEls.length; i++) dotEls[i].classList.toggle("is-on", i === idx);
}
function queue() { if (!raf) raf = requestAnimationFrame(paint); }
function clear() { offs.forEach(function (f) { f(); }); offs = []; }
function bind() {
sc.addEventListener("scroll", queue, { passive: true });
offs.push(function () { sc.removeEventListener("scroll", queue); });
var down = false, sy = 0, st = 0;
function pd(e) { if (e.pointerType === "touch" || e.button) return; down = true; sy = e.clientY; st = sc.scrollTop; sc.classList.add("is-drag"); try { sc.setPointerCapture(e.pointerId); } catch (x) {} }
function pm(e) { if (down) sc.scrollTop = st - (e.clientY - sy); }
function pu() { if (!down) return; down = false; sc.classList.remove("is-drag"); var h = sc.clientHeight; sc.scrollTo({ top: Math.round(sc.scrollTop / h) * h, behavior: reduce ? "auto" : "smooth" }); }
sc.addEventListener("pointerdown", pd); sc.addEventListener("pointermove", pm); sc.addEventListener("pointerup", pu); sc.addEventListener("pointercancel", pu);
offs.push(function () { sc.removeEventListener("pointerdown", pd); sc.removeEventListener("pointermove", pm); sc.removeEventListener("pointerup", pu); sc.removeEventListener("pointercancel", pu); });
function kd(e) {
var d = e.key === "ArrowDown" || e.key === "PageDown" ? 1 : e.key === "ArrowUp" || e.key === "PageUp" ? -1 : 0;
if (!d) return;
e.preventDefault();
sc.scrollTo({ top: Math.round(sc.scrollTop / sc.clientHeight + d) * sc.clientHeight, behavior: reduce ? "auto" : "smooth" });
}
sc.addEventListener("keydown", kd);
offs.push(function () { sc.removeEventListener("keydown", kd); });
if (window.ResizeObserver) { var ro = new ResizeObserver(queue); ro.observe(sc); offs.push(function () { ro.disconnect(); }); }
}
function build() {
sc.textContent = ""; dots.textContent = ""; dotEls = [];
for (var i = 0; i < opts.count; i++) {
var s = document.createElement("section"); s.className = "ef-bsb__sec";
var h = document.createElement("h3"); h.className = "ef-bsb__big"; h.textContent = DATA[i % DATA.length][0];
var p = document.createElement("p"); p.className = "ef-bsb__sm"; p.textContent = DATA[i % DATA.length][1];
s.appendChild(h); s.appendChild(p); sc.appendChild(s);
var d = document.createElement("span"); d.className = "ef-bsb__dot"; dots.appendChild(d); dotEls.push(d);
}
queue();
}
function update(next) {
var prev = opts;
opts = Object.assign({ background: "#0c1e18", accent: "#e6ff55", shift: 0.8, count: 4 }, opts, next || {});
opts.count = Math.max(2, Math.min(6, Math.round(opts.count)));
wrap.style.setProperty("--ef-bg", opts.background);
wrap.style.setProperty("--ef-acc", opts.accent);
wrap.style.setProperty("--ef-shift", String(opts.shift));
if (prev.count !== opts.count) build();
}
update(o);
bind();
return { update: update, destroy: function () { clear(); if (raf) cancelAnimationFrame(raf); raf = 0; } };
}
// Usage
var fx = mount(document.querySelector("#my-root"), {"accent":"#e6ff55","background":"#0c1e18","shift":0.8,"count":4});How it works
The element has its own scroll container with snap points, and a gradient layer sits behind it. A scroll listener (throttled with requestAnimationFrame) turns scrollTop into a 0 to 1 progress value and writes it to a CSS variable, --p. The stylesheet maps --p to the gradient layer's translate, rotate, scale and hue-rotate, so the colour field drifts as you move through the sections. The headings use mix-blend-mode: difference inside an isolated wrapper, so the type inverts against whatever colour is behind it and stays readable as the background changes. Mouse drag moves scrollTop directly and snaps to the nearest section on release.
Accessibility and performance
The scroller is a focusable region, so arrow and page keys move between sections. A visible focus ring is drawn inside the box. Reduced motion removes the background transition and the smooth snapping, though the background still follows your own scrolling. Difference blending can drop contrast where the background is mid grey, so check your own colours.
Scroll events only write one CSS variable per frame. Only transform and filter change on a single layer. The filter is hue-rotate on one large element, so keep the layer count at one and avoid adding blur.
| Section | Background |
|---|---|
| Techniques | Scroll-driven, Parallax and depth |
| Stack | Vanilla JS, CSS |
| Works with | React, Next.js, Vanilla |
| Dependencies | None |
| Status | tested |
| Inspired by | Background Shift Animation with CSS Blend Modes 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.
More elements
FAQ
Why does the type change colour?
mix-blend-mode: difference subtracts the text colour from whatever is behind it. White text over a light background becomes dark, and over a dark one it stays light.
Why does the background layer sit outside the scroller?
So it does not scroll away. It stays pinned behind the sections and only reacts to progress. The wrapper uses isolation: isolate so blending only sees this layer.
Does it work inside a clipped container?
Yes. It listens to its own scroll container rather than the window, so overflow hidden parents and previews are fine.