Copied!
Background

Background shift with blend modes

Full-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.

← 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 "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.

SectionBackground
TechniquesScroll-driven, Parallax and depth
StackVanilla JS, CSS
Works withReact, Next.js, Vanilla
DependenciesNone
Statustested
Inspired byBackground 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.

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. Smooth scrolly imagesA 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.

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.