Flow

Effects / WebGL shader

Halftone dots

Images and gradients made of dots that grow and shrink with brightness, like print.

Prompt to build it

Build an animated halftone pattern in deep magenta on cream. In a fragment shader, rotate a grid 30°, compute a radial pulsing brightness field, and draw a dot per cell with radius from brightness. 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>Halftone dots | Flow effect</title> <!-- Flow effect: Halftone dots (https://flow.donta.io/effects/halftone-dots) 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; void main() { float s = 11.0 * u_dpr; float a = 0.5236; mat2 R = mat2(cos(a), -sin(a), sin(a), cos(a)); vec2 p = R * gl_FragCoord.xy; vec2 cellCenter = (floor(p / s) + 0.5) * s; vec2 c = inverse(R) * cellCenter; vec2 uv = (c - 0.5 * u_res) / min(u_res.x, u_res.y); float b = 0.5 + 0.5 * sin(length(uv) * 14.0 - u_time * 2.0); b *= smoothstep(0.75, 0.1, length(uv)); float radius = 0.5 * s * sqrt(b) * 0.95; float dist = length(p - cellCenter); float dotMask = 1.0 - smoothstep(radius - 1.0, radius + 1.0, dist); vec3 paper = vec3(0.96, 0.93, 0.88); vec3 ink = vec3(0.56, 0.08, 0.3); o = vec4(mix(paper, ink, dotMask), 1.0); }`; flowShader(document.querySelector(".fx-stage canvas"), FRAG, { fallback: "#f5ede0" }); </script> </body> </html>

How it works

Divide the screen into a rotated grid; in each cell draw a circle whose radius follows the brightness of an underlying animated pattern.

Guardrails

  • Use larger dots on small screens so the effect still reads.
  • 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.