Copied!
Hero

Scroll camera landscape

A procedural low-poly mountain range that the camera flies through as you scroll, with a sun that moves from dawn to dusk.

← Library

Element · Vanilla JS + WebGL 2Updated November 5, 2026

HeroElementVanilla JS + WebGL 2

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 "Scroll camera landscape" for my site using the Enrichr Library element at https://enrichr.dev/elements/scroll-camera-landscape.html.

Read the machine-readable spec and source at https://enrichr.dev/elements/scroll-camera-landscape.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:
- height: 38
- travel: 700
- time: 0.35
- shift: 0.5
- manual: 0
- colorLow: #3a6b50
- colorHigh: #e8efe0
- sky: #8fc7ff

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/scroll-camera-landscape.json.

Code

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

HTML

<div class="ef-land"><canvas class="ef-land__c" role="img" aria-label="A low-poly mountain landscape the camera flies through as the page scrolls"></canvas></div>

CSS

.ef-land { position: absolute; inset: 0; overflow: hidden; background: linear-gradient(180deg, var(--ef-sky, #8fc7ff), var(--ef-low, #1f3b2d)); }
.ef-land__c { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }

JavaScript

// Scroll camera landscape: a procedural low-poly mountain range with a camera that flies down a valley as the page scrolls,
// and a sun that moves from dawn to dusk. No models or textures: the terrain is a height function evaluated on the GPU, the
// same function runs in JavaScript to keep the camera above the ground. Needs WebGL 2; without it the CSS gradient stays.
// Put it in a positioned parent with a height; the element fills it.
// mount(root, { height (terrain), travel (distance flown), time (0 dawn - 0.5 noon - 1 dusk), shift (time change over the scroll),
//               manual (0 = follow scroll, 1-100 = fixed %), colorLow, colorHigh, sky })
function mount(root, o) {
  var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
  var wrap = root.querySelector(".ef-land");
  var canvas = root.querySelector(".ef-land__c");
  var gl = canvas.getContext("webgl2", { antialias: true, alpha: false, powerPreference: "low-power" });
  var opts = {}, raf = 0, visible = true, io = null, prog, u = {}, vao, count = 0;
  var N = 110, CELL = 3.2;

  var VS = ["#version 300 es", "in vec2 aXZ;uniform mat4 uVP;uniform vec2 uSnap;uniform float uAmp;out vec3 vW;",
    "float r(float a,float b){return 1.-abs(sin(a)*cos(b));}",
    "float hf(vec2 p){float x=p.x,z=p.y;float h=.55*r(x*.020+.6*sin(z*.011),z*.017)+.30*r(x*.047+1.3,z*.051+.4)+.15*r(x*.11,z*.10+2.);",
    "float c=smoothstep(0.,34.,abs(x-14.*sin(z*.012)));return uAmp*h*mix(.2,1.,c);}",
    "void main(){vec2 w=aXZ+uSnap;vW=vec3(w.x,hf(w),w.y);gl_Position=uVP*vec4(vW,1.);}"].join("\n");
  var FS = ["#version 300 es", "precision highp float;in vec3 vW;out vec4 o;",
    "uniform vec3 uLo,uHi,uSky,uSun,uCam;uniform float uAmp,uLight,uFog;",
    "void main(){vec3 n=normalize(cross(dFdx(vW),dFdy(vW)));if(n.y<0.)n=-n;float l=max(dot(n,normalize(uSun)),0.);",
    "float t=clamp(vW.y/uAmp,0.,1.);vec3 c=mix(uLo,uHi,smoothstep(.15,.85,t))*(.4+.9*l*uLight);",
    "float d=distance(vW,uCam);float f=1.-exp(-pow(d/uFog,2.));o=vec4(mix(c,uSky,clamp(f,0.,1.)),1.);}"].join("\n");

  // the same height function as the vertex shader, so the camera stays above the ground
  function rr(a, b) { return 1 - Math.abs(Math.sin(a) * Math.cos(b)); }
  function sm(e0, e1, x) { var t = Math.min(1, Math.max(0, (x - e0) / (e1 - e0))); return t * t * (3 - 2 * t); }
  function pathX(z) { return 14 * Math.sin(z * 0.012); }
  function hf(x, z, amp) {
    var h = 0.55 * rr(x * 0.020 + 0.6 * Math.sin(z * 0.011), z * 0.017) + 0.30 * rr(x * 0.047 + 1.3, z * 0.051 + 0.4) + 0.15 * rr(x * 0.11, z * 0.10 + 2.0);
    return amp * h * (0.2 + 0.8 * sm(0, 34, Math.abs(x - pathX(z))));
  }
  function persp(fovy, asp, n, f) { var t = 1 / Math.tan(fovy / 2), nf = 1 / (n - f); return new Float32Array([t / asp, 0, 0, 0, 0, t, 0, 0, 0, 0, (f + n) * nf, -1, 0, 0, 2 * f * n * nf, 0]); }
  function look(e, c) {
    var zx = e[0] - c[0], zy = e[1] - c[1], zz = e[2] - c[2], l = Math.hypot(zx, zy, zz);
    zx /= l; zy /= l; zz /= l;
    var xx = zz, xz = -zx; l = Math.hypot(xx, xz); xx /= l; xz /= l;
    var yx = zy * xz, yy = zz * xx - zx * xz, yz = -zy * xx;
    return new Float32Array([xx, yx, zx, 0, 0, yy, zy, 0, xz, yz, zz, 0, -(xx * e[0] + xz * e[2]), -(yx * e[0] + yy * e[1] + yz * e[2]), -(zx * e[0] + zy * e[1] + zz * e[2]), 1]);
  }
  function mul(a, b) { var out = new Float32Array(16); for (var i = 0; i < 4; i++) for (var j = 0; j < 4; j++) { var s = 0; for (var k = 0; k < 4; k++) s += a[k * 4 + j] * b[i * 4 + k]; out[i * 4 + j] = s; } return out; }
  function rgb(hex) { var m = /^#?([0-9a-f]{6})$/i.exec(hex || ""); var v = parseInt(m ? m[1] : "808080", 16); return [(v >> 16) / 255, ((v >> 8) & 255) / 255, (v & 255) / 255]; }

  function sh(type, src) { var s = gl.createShader(type); gl.shaderSource(s, src); gl.compileShader(s); return gl.getShaderParameter(s, gl.COMPILE_STATUS) ? s : null; }
  function setup() {
    var v = sh(gl.VERTEX_SHADER, VS), f = sh(gl.FRAGMENT_SHADER, FS);
    if (!v || !f) { prog = null; return false; }
    prog = gl.createProgram(); gl.attachShader(prog, v); gl.attachShader(prog, f); gl.linkProgram(prog);
    if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) { prog = null; return false; }
    gl.useProgram(prog);
    var verts = new Float32Array((N + 1) * (N + 1) * 2), k = 0;
    for (var j = 0; j <= N; j++) for (var i = 0; i <= N; i++) { verts[k++] = (i - N / 2) * CELL; verts[k++] = (j - N / 2) * CELL; }
    var idx = new Uint32Array(N * N * 6); k = 0;
    for (var y = 0; y < N; y++) for (var x = 0; x < N; x++) { var a = y * (N + 1) + x, b = a + 1, c = a + N + 1, d = c + 1; idx[k++] = a; idx[k++] = c; idx[k++] = b; idx[k++] = b; idx[k++] = c; idx[k++] = d; }
    count = idx.length;
    vao = gl.createVertexArray(); gl.bindVertexArray(vao);
    var vb = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, vb); gl.bufferData(gl.ARRAY_BUFFER, verts, gl.STATIC_DRAW);
    var loc = gl.getAttribLocation(prog, "aXZ"); gl.enableVertexAttribArray(loc); gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0);
    var ib = gl.createBuffer(); gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, ib); gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, idx, gl.STATIC_DRAW);
    ["uVP", "uSnap", "uAmp", "uLo", "uHi", "uSky", "uSun", "uCam", "uLight", "uFog"].forEach(function (n) { u[n] = gl.getUniformLocation(prog, n); });
    gl.enable(gl.DEPTH_TEST);
    return true;
  }
  function progress() {
    if (reduce) return 0;
    if (opts.manual > 0) return opts.manual / 100;
    var r = wrap.getBoundingClientRect(), vh = window.innerHeight || 800;
    return Math.min(1, Math.max(0, (vh - r.top) / (vh + r.height)));
  }
  function render() {
    raf = 0;
    if (!visible || !prog) return;
    var dpr = Math.min(window.devicePixelRatio || 1, 1.5);
    var w = Math.max(1, Math.round(wrap.clientWidth * dpr)), h = Math.max(1, Math.round(wrap.clientHeight * dpr));
    if (canvas.width !== w || canvas.height !== h) { canvas.width = w; canvas.height = h; }
    gl.viewport(0, 0, w, h);
    var p = progress(), amp = opts.height;
    var z = -opts.travel * p, x = pathX(z);
    var ey = hf(x, z, amp) + 9, lz = z - 45, lx = pathX(lz), ly = hf(lx, lz, amp) + 9;
    var VP = mul(persp(1.0, w / h, 0.5, 500), look([x, ey, z], [lx, ly, lz]));
    var t = Math.min(1, Math.max(0, opts.time + opts.shift * p)), e = Math.sin(t * Math.PI), k = sm(0.05, 0.6, e);
    var day = rgb(opts.sky), dusk = [0.95, 0.5, 0.35], sky = [0, 1, 2].map(function (i) { return dusk[i] * 0.55 * (1 - k) + day[i] * k; });
    gl.clearColor(sky[0], sky[1], sky[2], 1);
    gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
    gl.uniformMatrix4fv(u.uVP, false, VP);
    gl.uniform2f(u.uSnap, Math.round(x / CELL) * CELL, Math.round(z / CELL) * CELL);
    gl.uniform1f(u.uAmp, amp);
    gl.uniform3fv(u.uLo, rgb(opts.colorLow)); gl.uniform3fv(u.uHi, rgb(opts.colorHigh)); gl.uniform3fv(u.uSky, sky);
    gl.uniform3f(u.uSun, Math.cos(t * Math.PI) * 0.8, e * 0.9 + 0.1, -0.5);
    gl.uniform3f(u.uCam, x, ey, z);
    gl.uniform1f(u.uLight, 0.3 + 0.7 * e); gl.uniform1f(u.uFog, 260);
    gl.bindVertexArray(vao);
    gl.drawElements(gl.TRIANGLES, count, gl.UNSIGNED_INT, 0);
  }
  function schedule() { if (!raf) raf = requestAnimationFrame(render); }
  function update(next) {
    opts = Object.assign({ height: 38, travel: 700, time: 0.35, shift: 0.5, manual: 0, colorLow: "#3a6b50", colorHigh: "#e8efe0", sky: "#8fc7ff" }, opts, next || {});
    wrap.style.setProperty("--ef-sky", opts.sky);
    wrap.style.setProperty("--ef-low", opts.colorLow);
    schedule();
  }
  var ok = gl && setup();
  update(o);
  if (!ok) { canvas.style.display = "none"; return { update: update, destroy: function () {} }; }
  if ("IntersectionObserver" in window) { io = new IntersectionObserver(function (e) { visible = e[0].isIntersecting; if (visible) schedule(); }); io.observe(wrap); }
  window.addEventListener("resize", schedule);
  if (!reduce) window.addEventListener("scroll", schedule, { passive: true });
  return { update: update, destroy: function () { cancelAnimationFrame(raf); if (io) io.disconnect(); window.removeEventListener("scroll", schedule); window.removeEventListener("resize", schedule); } };
}

// Usage
var fx = mount(document.querySelector("#my-root"), {"height":38,"travel":700,"time":0.35,"shift":0.5,"manual":0,"colorLow":"#3a6b50","colorHigh":"#e8efe0","sky":"#8fc7ff"});

How it works

The terrain is a flat grid of triangles whose heights are computed in the vertex shader from a layered, ridged sine function of the world position, so there are no models or textures to load. The grid is recentred on the camera each frame in whole cells, which makes the landscape endless without popping. The camera follows a winding path down a carved valley, with scroll progress as the distance along it. The same height function runs in JavaScript to keep the camera above the ground. Flat shading comes from screen-space derivatives, and fog fades the distance into the sky colour. The sun angle moves with the time of day, changing light and sky.

Accessibility and performance

The canvas is a labelled image and decorative. With reduced motion the camera stays at its start and the scene does not respond to scroll. If WebGL 2 is unavailable the container keeps a plain gradient in the sky and valley colours.

About 72,000 indices drawn once per update; it only redraws when you scroll, resize or change a control, and not at all while off screen. The pixel ratio is capped at 1.5. Test on a mid-range phone and reduce the grid size in the source if needed.

SectionHero
TechniquesWebGL, Scroll-driven
StackVanilla JS, WebGL 2
Works withReact, Next.js, Vanilla
DependenciesNone
Statustested

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.

Word read highlightWords in a paragraph light up one by one as it scrolls through the viewport. Shader gradient backgroundA slow, flowing noise gradient drawn with one WebGL fragment shader, with a CSS fallback. Scroll velocity skewText that leans and splits into two colour fringes in proportion to how fast the page scrolls.

FAQ

Is it a real 3D model?

No. It is generated by a height function, so it ships with no model files and loads instantly. A different look means changing that function.

Does it need three.js?

No. It uses WebGL 2 directly in about 150 lines, which keeps it small. If you already use three.js you can port the same height function to a shader material.

Why does it not animate on its own?

It is driven by scroll, so it only redraws when the page moves. That saves battery and ties the motion directly to the visitor.