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