Topographic Contours
The contour lines of a map flow and shift slowly behind a section, as if the hills and valleys underneath were gently rising and falling. Every fifth line is heavier, like the index contours on a real map.
Find your way
How to use it
- Paste the snippet just before
</head>. - Add
data-topographic-contoursto a section, hero or card.
Options
Add any of these attributes to the same element.
data-color | Line colour. Default sky blue #7dd3fc. |
data-lines | How many contour levels. Default 16. |
data-scale | Size of the hills. Default 1. |
data-speed | Speed multiplier. Default 1. |
It’s drawn on a transparent canvas behind your content and paused when off screen. With reduced motion the map holds still. On a light background, pick a darker line colour, e.g. data-color="#92400e".
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Topographic Contours: the contour lines of a map, flowing and shifting
// slowly as the hills and valleys beneath them rise and fall. Every fifth
// line is drawn heavier, like the index contours on a real map. Drawn on
// a transparent canvas behind your content, and paused when off screen.
// Usage: <section data-topographic-contours style="background:#0b1220">…</section>
// Optional: data-color (line colour, default sky blue #7dd3fc),
// data-lines (how many levels, default 16), data-scale (size of the hills,
// default 1), data-speed (default 1).
var DEFAULTS = { color: '#7dd3fc', lines: 16, scale: 1, speed: 1 };
var MAX_CELLS = 9000;
var css =
'[data-topographic-contours]{position:relative;isolation:isolate;overflow:hidden}' +
'.tpc-canvas{position:absolute;inset:0;width:100%;height:100%;z-index:-1;pointer-events:none}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
// Smooth 3D gradient noise (the third dimension is time).
var perm = new Uint8Array(512);
(function () {
var p = [], i;
for (i = 0; i < 256; i++) p[i] = i;
for (i = 255; i > 0; i--) { var j = (Math.random() * (i + 1)) | 0, s = p[i]; p[i] = p[j]; p[j] = s; }
for (i = 0; i < 512; i++) perm[i] = p[i & 255];
})();
function fade(t) { return t * t * t * (t * (t * 6 - 15) + 10); }
function grad(hash, x, y, z) {
var h = hash & 15, u = h < 8 ? x : y, v = h < 4 ? y : (h === 12 || h === 14 ? x : z);
return ((h & 1) ? -u : u) + ((h & 2) ? -v : v);
}
function noise(x, y, z) {
var X = Math.floor(x) & 255, Y = Math.floor(y) & 255, Z = Math.floor(z) & 255;
x -= Math.floor(x); y -= Math.floor(y); z -= Math.floor(z);
var u = fade(x), v = fade(y), w = fade(z);
var A = perm[X] + Y, AA = perm[A] + Z, AB = perm[A + 1] + Z;
var B = perm[X + 1] + Y, BA = perm[B] + Z, BB = perm[B + 1] + Z;
function lerp(t, a, b) { return a + t * (b - a); }
return lerp(w,
lerp(v, lerp(u, grad(perm[AA], x, y, z), grad(perm[BA], x - 1, y, z)),
lerp(u, grad(perm[AB], x, y - 1, z), grad(perm[BB], x - 1, y - 1, z))),
lerp(v, lerp(u, grad(perm[AA + 1], x, y, z - 1), grad(perm[BA + 1], x - 1, y, z - 1)),
lerp(u, grad(perm[AB + 1], x, y - 1, z - 1), grad(perm[BB + 1], x - 1, y - 1, z - 1))));
}
// Which cell edges a contour crosses, for each of the 16 corner patterns
// (marching squares). Edges: 0 top, 1 right, 2 bottom, 3 left.
var SEGS = [[], [3, 2], [2, 1], [3, 1], [0, 1], [0, 1, 3, 2], [0, 2], [0, 3], [0, 3], [0, 2], [0, 3, 2, 1], [0, 1], [3, 1], [2, 1], [3, 2], []];
function build(el) {
var d = el.dataset;
var color = d.color || DEFAULTS.color;
var lines = Math.max(4, +d.lines || DEFAULTS.lines);
var scale = +d.scale || DEFAULTS.scale;
var speed = d.speed !== undefined ? +d.speed : DEFAULTS.speed;
var canvas = document.createElement('canvas');
canvas.className = 'tpc-canvas';
canvas.setAttribute('aria-hidden', 'true');
el.insertBefore(canvas, el.firstChild);
var ctx = canvas.getContext('2d');
var w = 0, h = 0, dpr = 1, t = 0, g = 8, cols = 0, rows = 0, field = null;
var LO = -0.62, HI = 0.62, gap = (HI - LO) / lines;
function resize() {
dpr = Math.min(2, window.devicePixelRatio || 1);
w = el.clientWidth;
h = el.clientHeight;
canvas.width = w * dpr;
canvas.height = h * dpr;
g = Math.max(6, Math.sqrt((w * h) / MAX_CELLS));
cols = Math.ceil(w / g) + 1;
rows = Math.ceil(h / g) + 1;
field = new Float32Array((cols + 1) * (rows + 1));
draw();
}
function sample() {
var k = 1 / (260 * scale), z = t * 0.06;
for (var j = 0; j <= rows; j++) {
for (var i = 0; i <= cols; i++) {
var x = i * g * k + t * 0.02, y = j * g * k;
// Two layers of noise: broad hills with smaller bumps on them.
field[j * (cols + 1) + i] = noise(x, y, z) * 0.85 + noise(x * 2.3 + 40, y * 2.3, z * 1.6) * 0.3;
}
}
}
function draw() {
sample();
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, w, h);
ctx.strokeStyle = color;
ctx.lineJoin = 'round';
// Minor lines in one path and index lines in another, so it's just
// two strokes however many segments there are.
var minor = new Path2D(), major = new Path2D();
var W = cols + 1;
for (var j = 0; j < rows; j++) {
for (var i = 0; i < cols; i++) {
var a = field[j * W + i], b = field[j * W + i + 1];
var c = field[(j + 1) * W + i + 1], dd = field[(j + 1) * W + i];
var lo = Math.min(a, b, c, dd), hi = Math.max(a, b, c, dd);
var first = Math.ceil((lo - LO) / gap), last = Math.floor((hi - LO) / gap);
var x0 = i * g, y0 = j * g;
for (var n = first; n <= last; n++) {
var lv = LO + n * gap;
var idx = (a > lv ? 8 : 0) | (b > lv ? 4 : 0) | (c > lv ? 2 : 0) | (dd > lv ? 1 : 0);
var segs = SEGS[idx];
var path = n % 5 === 0 ? major : minor;
for (var s = 0; s < segs.length; s += 2) {
var p = edge(segs[s], lv, x0, y0, a, b, c, dd), q = edge(segs[s + 1], lv, x0, y0, a, b, c, dd);
path.moveTo(p[0], p[1]);
path.lineTo(q[0], q[1]);
}
}
}
}
ctx.globalAlpha = 0.32;
ctx.lineWidth = 1;
ctx.stroke(minor);
ctx.globalAlpha = 0.75;
ctx.lineWidth = 1.6;
ctx.stroke(major);
ctx.globalAlpha = 1;
}
// Where the level crosses one edge of a cell.
function edge(e, lv, x0, y0, a, b, c, d) {
if (e === 0) return [x0 + g * (lv - a) / (b - a), y0];
if (e === 1) return [x0 + g, y0 + g * (lv - b) / (c - b)];
if (e === 2) return [x0 + g * (lv - d) / (c - d), y0 + g];
return [x0, y0 + g * (lv - a) / (d - a)];
}
t = 10;
resize();
if (window.ResizeObserver) new ResizeObserver(resize).observe(el);
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
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) : 16;
last = now;
t += (dt / 1000) * speed;
draw();
}
});
new IntersectionObserver(function (entries) {
if (entries[0].isIntersecting) { last = 0; timer.play(); } else timer.pause();
}).observe(el);
}
function init() {
document.querySelectorAll('[data-topographic-contours]').forEach(function (el) {
if (el._tpc) return;
el._tpc = true;
build(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><section data-topographic-contours style="background: #0b1220; color: #fff">
<h1>Find your way</h1>
</section>