Effects / WebGL shader
Topographic lines
Contour lines like a terrain map that slowly drift and reshape.
Prompt to build it
Build drifting topographic contour lines in dark ink on sand. In a fragment shader, sample layered value noise, take fract(n * levels), and draw anti-aliased lines with fwidth at each level crossing. Make it responsive, cap devicePixelRatio at 2, pause when off-screen, and show a still frame when prefers-reduced-motion is set. No external libraries.
Tested code
One self-contained HTML file with no libraries: the same code that runs above. Your AI gets it from Flow's get_effect tool.
Show the code
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Topographic lines | Flow effect</title>
<!--
Flow effect: Topographic lines (https://flow.donta.io/effects/topographic-lines)
Self-contained: no libraries. Copy the .fx-stage element, the CSS and the
script into your project and size .fx-stage however you like.
-->
<style>
:root { --display: "Bricolage Grotesque", ui-sans-serif, system-ui, sans-serif; --body: ui-sans-serif, system-ui, sans-serif; }
html, body { margin: 0; min-height: 100%; background: #0e0e10; display: grid; place-items: center; }
.fx-stage { width: min(100vw, 960px); }
/* Stage that every effect fills. Container units (cqw/cqmin) size the art. */
.fx-stage {
position: relative;
aspect-ratio: 4 / 3;
overflow: hidden;
border-radius: 16px;
container-type: size;
background: #111;
isolation: isolate;
}
.fx-fill {
position: absolute;
inset: 0;
}
.fx-fallback {
position: absolute;
inset: 0;
}
.fx-center {
display: grid;
place-items: center;
}
@media (prefers-reduced-motion: reduce) {
.fx-stage *, .fx-stage *::before, .fx-stage *::after { animation-play-state: paused !important; }
}
</style>
</head>
<body>
<div class="fx-stage"><canvas class="fx-fill" aria-hidden="true" style="width:100%;height:100%;display:block"></canvas></div>
<script>
function flowLoop(el, frame, opts) {
opts = opts || {};
if (matchMedia("(prefers-reduced-motion: reduce)").matches) {
for (let i = 0; i < (opts.stillFrames || 1); i++) frame(2 + i / 60);
return function () {};
}
let raf = 0, onScreen = true, last = 0;
const start = performance.now(), gap = opts.fps ? 1000 / opts.fps : 0;
const io = new IntersectionObserver(function (e) { onScreen = e[0].isIntersecting; });
io.observe(el);
function tick(now) {
raf = requestAnimationFrame(tick);
if (!onScreen || document.hidden || (gap && now - last < gap)) return;
last = now;
frame((now - start) / 1000);
}
raf = requestAnimationFrame(tick);
return function () { cancelAnimationFrame(raf); io.disconnect(); };
}
function flowShader(canvas, frag, o) {
o = o || {};
const gl = canvas.getContext("webgl2", { antialias: false });
function fail(msg) { console.warn("[flow]", msg); canvas.style.background = o.fallback || "#111"; }
if (!gl) return fail("WebGL2 not available");
function compile(type, src) {
const s = gl.createShader(type); gl.shaderSource(s, src); gl.compileShader(s);
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) { fail(gl.getShaderInfoLog(s)); return null; }
return s;
}
const vs = compile(gl.VERTEX_SHADER, "#version 300 es\nin vec2 p;void main(){gl_Position=vec4(p,0.0,1.0);}");
const fs = compile(gl.FRAGMENT_SHADER, frag);
if (!vs || !fs) return;
const prog = gl.createProgram();
gl.attachShader(prog, vs); gl.attachShader(prog, fs); gl.linkProgram(prog); gl.useProgram(prog);
gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
const loc = gl.getAttribLocation(prog, "p");
gl.enableVertexAttribArray(loc); gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0);
const U = function (n) { return gl.getUniformLocation(prog, n); };
const uRes = U("u_res"), uTime = U("u_time"), uMouse = U("u_mouse"), uDpr = U("u_dpr");
const dpr = Math.min(devicePixelRatio || 1, 2) * (o.scale || 1);
let mouse = [0.5, 0.5];
canvas.addEventListener("pointermove", function (e) {
const r = canvas.getBoundingClientRect();
mouse = [(e.clientX - r.left) / r.width, 1 - (e.clientY - r.top) / r.height];
});
function size() {
canvas.width = Math.max(1, Math.round(canvas.clientWidth * dpr));
canvas.height = Math.max(1, Math.round(canvas.clientHeight * dpr));
gl.viewport(0, 0, canvas.width, canvas.height);
}
size();
new ResizeObserver(size).observe(canvas);
return flowLoop(canvas, function (t) {
gl.uniform2f(uRes, canvas.width, canvas.height); gl.uniform1f(uTime, t);
gl.uniform2f(uMouse, mouse[0], mouse[1]); gl.uniform1f(uDpr, dpr);
gl.drawArrays(gl.TRIANGLES, 0, 3);
});
}
const FRAG = `#version 300 es
precision highp float;
uniform vec2 u_res;
uniform float u_time;
uniform vec2 u_mouse;
uniform float u_dpr;
out vec4 o;
float hash(vec2 p) { p = fract(p * vec2(123.34, 456.21)); p += dot(p, p + 45.32); return fract(p.x * p.y); }
float vnoise(vec2 p) {
vec2 i = floor(p), f = fract(p);
vec2 u = f * f * (3.0 - 2.0 * f);
return mix(mix(hash(i), hash(i + vec2(1, 0)), u.x), mix(hash(i + vec2(0, 1)), hash(i + vec2(1, 1)), u.x), u.y);
}
float fbm(vec2 p) {
float v = 0.0, a = 0.5;
for (int i = 0; i < 4; i++) { v += a * vnoise(p); p = p * 2.03 + 11.7; a *= 0.5; }
return v;
}
void main() {
vec2 uv = gl_FragCoord.xy / min(u_res.x, u_res.y);
float n = fbm(uv * 2.2 + vec2(u_time * 0.025, -u_time * 0.018));
float levels = 14.0;
float v = n * levels;
float f = fract(v);
float w = fwidth(v);
float major = step(4.5, mod(floor(v), 5.0));
float line = 1.0 - smoothstep(0.0, w * (1.2 + major * 1.2), min(f, 1.0 - f));
vec3 sand = vec3(0.91, 0.86, 0.76);
vec3 ink = vec3(0.17, 0.13, 0.09);
o = vec4(mix(sand, ink, line * 0.9), 1.0);
}`;
flowShader(document.querySelector(".fx-stage canvas"), FRAG, { fallback: "#e8dcc2" });
</script>
</body>
</html>
How it works
Evaluate smooth noise per pixel and draw a thin line wherever the value crosses an evenly spaced level; animate the noise slowly over time.
Guardrails
- Keep the drift slow; it should feel like weather, not water.
- Respect prefers-reduced-motion: show a still frame instead of the animation.
- Pause when off-screen or when the tab is hidden (IntersectionObserver + visibilitychange).
- Keep text off busy animated areas, or give it a solid backing that passes WCAG contrast.
- One showpiece effect per page. If everything moves, nothing stands out.