Copied!
Hero

Layered parallax scene

A seeded landscape of hill ridges, a sun and a headline tucked behind the front hill, each moving at its own rate as you scroll.

← Library

Element · Vanilla JS + CSSUpdated November 6, 2026

HeroElementVanilla 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 "Layered parallax scene" for my site using the Enrichr Library element at https://enrichr.dev/elements/layered-parallax-scene.html.

Read the machine-readable spec and source at https://enrichr.dev/elements/layered-parallax-scene.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:
- title: Other worlds
- layers: 4
- depth: 60
- height: 440
- seed: 7
- sky: #143b2b
- sun: #e6ff55
- haze: #5f9a62
- land: #071e17

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/layered-parallax-scene.json.

Code

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

HTML

<div class="ef-lps">
  <div class="ef-lps__scene">
    <div class="ef-lps__sun" aria-hidden="true"></div>
    <div class="ef-lps__layers"></div>
  </div>
</div>

CSS

/* Enrichr Library · https://enrichr.dev */
.ef-lps { --ef-lps-sky: #143b2b; --ef-lps-sun: #e6ff55; --ef-lps-haze: #5f9a62; --ef-lps-land: #071e17; --ef-lps-ink: #fbf8f1; width: 100%; font-family: "Helvetica Neue", Arial, "Segoe UI", system-ui, sans-serif; }
.ef-lps__scene { position: relative; height: var(--ef-lps-h, 440px); overflow: hidden; border-radius: 1rem; background: linear-gradient(180deg, color-mix(in srgb, var(--ef-lps-sky) 80%, #000) 0%, var(--ef-lps-sky) 55%, color-mix(in srgb, var(--ef-lps-sky) 70%, var(--ef-lps-sun)) 100%); container-type: size; isolation: isolate; }
.ef-lps__sun { position: absolute; left: 62%; top: 12%; width: clamp(4rem, 34cqh, 14rem); aspect-ratio: 1; border-radius: 50%; background: var(--ef-lps-sun); box-shadow: 0 0 6rem color-mix(in srgb, var(--ef-lps-sun) 35%, transparent); will-change: transform; }
.ef-lps__layers { position: absolute; inset: 0; }
.ef-lps__layer { position: absolute; left: -2%; right: -2%; bottom: -40%; will-change: transform; }
.ef-lps__layer svg { display: block; width: 100%; height: 100%; }
.ef-lps__title { position: absolute; left: 6%; top: 24%; margin: 0; max-width: 88%; font-size: clamp(2.6rem, min(15cqi, 30cqh), 10rem); font-weight: 900; line-height: 0.82; letter-spacing: -0.06em; text-transform: uppercase; color: var(--ef-lps-ink); will-change: transform; }
@media (prefers-reduced-motion: reduce) { .ef-lps__sun, .ef-lps__layer, .ef-lps__title { transform: none !important; } }

JavaScript

// Enrichr Library · https://enrichr.dev
// Layered parallax scene: a landscape of hill ridges drawn as SVG paths from a seed, a sun and a headline tucked behind the
// front hill. As the scene passes through the viewport each layer moves at its own rate (near hills rise fastest, the sun
// sinks), which reads as depth. Scroll events from the page or any scrolling parent schedule one frame; nothing runs while
// the page is still. Layers extend below the scene so moving them never shows a gap. No images are used.
// mount(root, { title, layers (3-5), depth (0-100), height (px), seed (1-30), sky, sun, haze (far hills), land (near hills), ink })
function mount(root, o) {
  var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
  var box = root.querySelector(".ef-lps");
  var scene = root.querySelector(".ef-lps__scene");
  var sun = root.querySelector(".ef-lps__sun");
  var holder = root.querySelector(".ef-lps__layers");
  var NS = "http://www.w3.org/2000/svg";
  var opts = {}, parts = [], raf = 0, visible = true, io = null, last = -1;

  function rng(seed) { var s = seed >>> 0 || 1; return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; }; }
  // A ridge is a sum of three sine waves with seeded phases, sampled into a path that is closed along the bottom.
  function ridge(rand, rough) {
    var w = [[1 + rand() * 1.5, rand() * 6.28, 1], [3 + rand() * 3, rand() * 6.28, 0.45], [9 + rand() * 6, rand() * 6.28, 0.12 * rough]];
    var d = "M0 1000L0 ";
    for (var x = 0; x <= 1000; x += 20) {
      var t = x / 1000, y = 0;
      w.forEach(function (k) { y += Math.sin(t * k[0] * Math.PI + k[1]) * k[2]; });
      d += (x ? "L" + x + " " : "") + (150 + y * 70).toFixed(1);
    }
    return d + "L1000 1000Z";
  }
  function build() {
    holder.textContent = "";
    parts = [];
    var n = opts.layers, rand = rng(opts.seed * 7919);
    var tops = { 3: [36, 52, 68], 4: [32, 45, 58, 71], 5: [30, 41, 52, 63, 74] }[n];
    for (var i = 0; i < n; i++) {
      var t = n === 1 ? 1 : i / (n - 1);
      var layer = document.createElement("div"); layer.className = "ef-lps__layer"; layer.setAttribute("aria-hidden", "true");
      layer.style.top = tops[i] + "%";
      layer.style.zIndex = String(i + 1);
      var svg = document.createElementNS(NS, "svg"); svg.setAttribute("viewBox", "0 0 1000 1000"); svg.setAttribute("preserveAspectRatio", "none"); svg.setAttribute("focusable", "false");
      var path = document.createElementNS(NS, "path"); path.setAttribute("d", ridge(rand, 1 + t));
      path.style.fill = "color-mix(in srgb, var(--ef-lps-land) " + Math.round(t * 100) + "%, var(--ef-lps-haze))";
      svg.appendChild(path); layer.appendChild(svg);
      holder.appendChild(layer);
      parts.push({ el: layer, f: 0.25 + 0.75 * t });
      if (i === n - 2) {
        var h = document.createElement("h3"); h.className = "ef-lps__title"; h.textContent = opts.title;
        h.style.zIndex = String(i + 1);
        holder.appendChild(h);
        parts.push({ el: h, f: 0.15 + 0.75 * t });
      }
    }
    parts.push({ el: sun, f: -0.6 });
    last = -1;
  }
  function draw() {
    raf = 0;
    if (!visible || reduce) return;
    var vh = window.innerHeight || document.documentElement.clientHeight || 800;
    var r = scene.getBoundingClientRect();
    var p = Math.min(1, Math.max(0, (vh - r.top) / (vh + r.height)));
    if (Math.abs(p - last) < 0.0005) return;
    last = p;
    var range = r.height * opts.depth / 100 * 0.8;
    parts.forEach(function (q) { q.el.style.transform = "translate3d(0," + (-(p - 0.5) * range * q.f).toFixed(1) + "px,0)"; });
  }
  function kick() { if (!raf) raf = requestAnimationFrame(draw); }
  function update(next) {
    opts = Object.assign({ title: "Other worlds", layers: 4, depth: 60, height: 440, seed: 7, sky: "#143b2b", sun: "#e6ff55", haze: "#5f9a62", land: "#071e17", ink: "#fbf8f1" }, opts, next || {});
    opts.layers = Math.min(5, Math.max(3, Math.round(Number(opts.layers) || 4)));
    opts.depth = Math.min(100, Math.max(0, Number(opts.depth) || 0));
    opts.seed = Math.max(1, Math.round(Number(opts.seed) || 1));
    box.style.setProperty("--ef-lps-h", Math.max(220, Number(opts.height) || 440) + "px");
    ["sky", "sun", "haze", "land", "ink"].forEach(function (k) { box.style.setProperty("--ef-lps-" + k, opts[k]); });
    build();
    kick();
  }
  update(o);
  if (!reduce) {
    window.addEventListener("scroll", kick, { passive: true, capture: true });
    window.addEventListener("resize", kick);
    if ("IntersectionObserver" in window) { io = new IntersectionObserver(function (e) { visible = e[0].isIntersecting; if (visible) kick(); }, { rootMargin: "120px 0px" }); io.observe(scene); }
  }
  return {
    update: update,
    destroy: function () {
      if (raf) cancelAnimationFrame(raf); raf = 0;
      if (io) io.disconnect();
      window.removeEventListener("scroll", kick, { capture: true });
      window.removeEventListener("resize", kick);
    }
  };
}

// Usage
var fx = mount(document.querySelector("#my-root"), {"title":"Other worlds","layers":4,"depth":60,"height":440,"seed":7,"sky":"#143b2b","sun":"#e6ff55","haze":"#5f9a62","land":"#071e17"});

How it works

Each hill is an SVG path built from three sine waves whose phases come from a seeded random generator, so a seed always gives the same landscape. Layers are stacked from back to front and coloured from the far-hill colour to the near-hill colour for aerial perspective, and each extends below the scene. As the scene travels through the viewport, one frame per scroll works out its progress from 0 to 1 and moves every layer by its share of the depth: the nearest hills rise fastest, the headline, placed just behind the front hill, a little slower, and the sun sinks. Different speeds at different distances is what reads as depth.

Accessibility and performance

The headline is a real heading; the hills and sun are decorative and hidden from assistive technology. The default headline is a light colour on a dark sky for contrast. With prefers-reduced-motion everything stays still.

Scroll events only schedule a frame, and work stops off screen. Each frame reads one bounding box and writes a few transforms, composited without layout or paint. The ridges are drawn once.

SectionHero
TechniquesParallax and depth, Scroll-driven
StackVanilla JS, CSS
Works withReact, Next.js, Vanilla
DependenciesNone
Statustested
Inspired byGSAP Parallax Terrain by Enrichr on CodePen. 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. Word read highlightWords in a paragraph light up one by one as it scrolls through the viewport. Depth parallax cardLayered shapes shift by different amounts as the pointer moves, drifting on touch screens.

FAQ

How do I get a different landscape?

Change the seed. Every number gives a different but repeatable set of ridges.

Why do the hills extend below the scene?

So that moving them up never reveals a gap at the bottom edge.

Can the headline sit in front of the hills?

Yes. Give .ef-lps__title a z-index higher than the number of layers.