Wireframe Terrain
A glowing wireframe landscape rolls endlessly towards you: a flat valley between neon hills, under a striped retro sun. Hills hide the lines behind them, and the far distance fades into a haze.
How to use it
- Paste the snippet just before
</head>. - Add
data-wireframe-terrainto a box with a height and a dark background.
Options
Add any of these attributes to the same element.
data-color | Line colour. Default #e879f9. |
data-speed | How fast the land rolls by. Default 1. |
data-height | How tall the hills are. Default 1. |
data-sun="false" | No sun. |
data-bg | Colour the hills are filled with. Default the box’s background colour. |
Drawn on a canvas, paused when off screen. With reduced motion it shows a still frame.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Wireframe Terrain: a glowing wireframe landscape rolls towards you, a
// flat valley between hills, under a striped retro sun. Drawn on a canvas
// with hidden lines removed, and paused when off screen.
// Usage: <div data-wireframe-terrain style="height: 360px; background: #12052a"></div>
// Optional: data-color (line colour, default #e879f9), data-speed (default
// 1), data-height (hill height, default 1), data-sun="false" (no sun),
// data-bg (colour the hills are filled with, default the box's background).
var DEFAULTS = { color: '#e879f9', speed: 1, height: 1 };
var css =
'[data-wireframe-terrain]{position:relative;overflow:hidden}' +
'.wft-canvas{position:absolute;inset:0;width:100%;height:100%;display:block;pointer-events:none}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
// Smooth value noise: random heights on a grid, blended between.
function hash(x, y) {
var h = Math.sin(x * 127.1 + y * 311.7) * 43758.5453;
return h - Math.floor(h);
}
function noise(x, y) {
var xi = Math.floor(x), yi = Math.floor(y), xf = x - xi, yf = y - yi;
var u = xf * xf * (3 - 2 * xf), v = yf * yf * (3 - 2 * yf);
var a = hash(xi, yi), b = hash(xi + 1, yi), c = hash(xi, yi + 1), d = hash(xi + 1, yi + 1);
return a + (b - a) * u + (c - a) * v + (a - b - c + d) * u * v;
}
function build(el) {
var d = el.dataset;
var canvas = document.createElement('canvas');
canvas.className = 'wft-canvas';
canvas.setAttribute('aria-hidden', 'true');
el.insertBefore(canvas, el.firstChild);
var ctx = canvas.getContext('2d');
var color = d.color || DEFAULTS.color;
var speed = d.speed !== undefined ? +d.speed : DEFAULTS.speed;
var tall = d.height !== undefined ? +d.height : DEFAULTS.height;
var sun = d.sun !== 'false';
var bg = d.bg || getComputedStyle(el).backgroundColor;
if (!bg || bg === 'transparent' || /rgba\(.*,\s*0\)$/.test(bg)) bg = '#12052a';
var w = 0, h = 0, dpr = 1, travel = 0, t = 0;
// The world: columns from -HALF to HALF, rows from NEAR to FAR.
var HALF = 16, NEAR = 0.6, FAR = 26, CAM = 1.4;
var cols = HALF * 2 + 1;
function resize() {
dpr = Math.min(2, window.devicePixelRatio || 1);
w = el.clientWidth;
h = el.clientHeight;
canvas.width = Math.round(w * dpr);
canvas.height = Math.round(h * dpr);
}
function height(x, z) {
var ax = Math.abs(x);
// Flat down the middle, rising into hills either side.
var side = Math.max(0, Math.min(1, (ax - 1.5) / 6));
side = side * side * (3 - 2 * side);
var n = noise(x * 0.32, z * 0.32) * 0.75 + noise(x * 0.9 + 9, z * 0.9) * 0.25;
return (side * (1.2 + n * 5.5) + n * 0.18) * tall;
}
function draw() {
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, w, h);
var horizon = h * 0.46, cx = w / 2, f = Math.max(w, h * 1.4) * 0.55;
var sway = Math.sin(t * 0.25) * 0.8;
if (sun) {
var r = Math.min(w * 0.22, h * 0.26), sy = horizon - r * 0.32;
var g = ctx.createLinearGradient(0, sy - r, 0, sy + r);
g.addColorStop(0, '#fde68a');
g.addColorStop(0.55, '#fb7185');
g.addColorStop(1, '#c026d3');
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(cx, sy, r, 0, Math.PI * 2);
ctx.fill();
// The stripes across its lower half, thicker towards the bottom.
ctx.fillStyle = bg;
for (var k = 0; k < 6; k++) {
var yy = sy + r * (0.08 + k * 0.16), th = r * (0.025 + k * 0.022);
ctx.fillRect(cx - r, yy, r * 2, th);
}
}
var shift = travel % 1;
var rows = [];
for (var zi = Math.ceil(FAR); zi >= 0; zi--) {
var z = NEAR + zi - shift;
if (z < 0.25) continue;
var row = [];
for (var c = 0; c < cols; c++) {
var x = c - HALF;
var y = height(x, zi + Math.floor(travel));
row.push([cx + (x - sway) * f / z, horizon + (CAM - y) * f / z]);
}
rows.push({ z: z, pts: row });
}
ctx.lineJoin = 'round';
for (var i = 0; i < rows.length; i++) {
var far = rows[i], near = rows[i + 1];
var fade = Math.max(0, Math.min(1, 1 - (far.z - 2) / (FAR - 2)));
ctx.strokeStyle = color;
ctx.globalAlpha = 0.12 + 0.88 * fade * fade;
ctx.lineWidth = 0.6 + fade * 0.9;
if (near) {
// Fill the strip in front of this row so it hides what's behind.
ctx.fillStyle = bg;
ctx.globalAlpha = 1;
ctx.beginPath();
far.pts.forEach(function (p, j) { if (j) ctx.lineTo(p[0], p[1]); else ctx.moveTo(p[0], p[1]); });
for (var j = cols - 1; j >= 0; j--) ctx.lineTo(near.pts[j][0], near.pts[j][1]);
ctx.closePath();
ctx.fill();
ctx.globalAlpha = 0.12 + 0.88 * fade * fade;
}
ctx.beginPath();
far.pts.forEach(function (p, j) { if (j) ctx.lineTo(p[0], p[1]); else ctx.moveTo(p[0], p[1]); });
if (near) {
for (var q = 0; q < cols; q++) {
ctx.moveTo(far.pts[q][0], far.pts[q][1]);
ctx.lineTo(near.pts[q][0], near.pts[q][1]);
}
}
ctx.stroke();
}
ctx.globalAlpha = 1;
// A soft haze where the land meets the sky.
var haze = ctx.createLinearGradient(0, horizon - h * 0.12, 0, horizon + h * 0.06);
haze.addColorStop(0, 'rgba(0,0,0,0)');
haze.addColorStop(0.7, hexA(color, 0.14));
haze.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = haze;
ctx.fillRect(0, horizon - h * 0.12, w, h * 0.18);
}
function hexA(hex, a) {
var m = /^#?([0-9a-f]{6})$/i.exec(hex);
if (!m) return 'rgba(255,255,255,' + a + ')';
var n = parseInt(m[1], 16);
return 'rgba(' + (n >> 16) + ',' + (n >> 8 & 255) + ',' + (n & 255) + ',' + a + ')';
}
resize();
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
draw();
if (window.ResizeObserver) new ResizeObserver(function () { resize(); draw(); }).observe(el);
return;
}
if (window.ResizeObserver) new ResizeObserver(resize).observe(el);
var last = 0;
var timer = anime.createTimer({
loop: true,
duration: 1000,
autoplay: false,
onUpdate: function () {
var now = performance.now();
var dt = last ? Math.min(50, now - last) / 1000 : 0.016;
last = now;
t += dt;
travel += dt * 2.2 * speed;
draw();
}
});
new IntersectionObserver(function (entries) {
if (entries[0].isIntersecting) { last = 0; timer.play(); } else timer.pause();
}).observe(el);
}
function init() {
document.querySelectorAll('[data-wireframe-terrain]').forEach(function (el) {
if (el._wft) return;
el._wft = true;
build(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-wireframe-terrain style="height: 360px; background: #12052a"></div>