Seed it, so the drawing stays put
Generative art uses randomness, but a page should not change every time it loads. A seeded generator gives the same sequence of numbers for the same seed, so the drawing is the same on every visit, and on the server if you render it there. Math.random() has no seed; this is mulberry32, small and fast enough for art:
// mulberry32: a tiny seeded generator. The same seed gives the same numbers,
// on the server and in every visitor's browser.
export function random(seed) {
return () => {
seed = (seed + 0x6d2b79f5) | 0;
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}Keep the seed in the code, and change it until you find a drawing you like.
A harmonograph: one line that spirals in
A harmonograph traces two pendulums swinging at right angles while they slow down. In code that is one formula: x and y are each the sum of two sines, both multiplied by a factor that shrinks over time.
import { random } from './random.js';
// Two damped pendulums: x and y are each the sum of two sines that die away.
export function harmonograph(seed, size = 1000) {
const rand = random(seed);
// Ratios close to whole numbers make the figure turn slowly as it shrinks.
const f = [2, 3, 3, 2].map((n) => n + rand() * 0.02);
const p = f.map(() => rand() * Math.PI);
const damp = 0.0042 + rand() * 0.0015;
const r = size * 0.23;
const points = [];
// Small steps keep the wide outer loops round. Stop when the swing is a
// fifth of what it was, or the middle fills in solid.
for (let t = 0; ; t += 0.05) {
const e = Math.exp(-damp * t);
if (e < 0.2) break;
points.push([
size / 2 + r * e * (Math.sin(f[0] * t + p[0]) + Math.sin(f[1] * t + p[1])),
size / 2 + r * e * (Math.sin(f[2] * t + p[2]) + Math.sin(f[3] * t + p[3])),
]);
}
return 'M' + points.map(([x, y]) => `${x.toFixed(1)} ${y.toFixed(1)}`).join('L');
}Two details decide whether it looks drawn or computed. Frequency ratios near whole numbers (2, 3, 3, 2, each nudged by up to 0.02) make the figure turn slowly as it shrinks, which is where the woven look comes from. And the loop stops once the swing is a fifth of its start: past that point the line keeps circling the middle until it is a solid blot. For seed 34 that is 7,142 points, about 84 KB of path data.
Draw it as the reader scrolls
Put the line in an SVG and hand it to svg-scroll-draw:
<svg id="art" viewBox="0 0 1000 1000" fill="none" stroke="currentColor" stroke-width="1"></svg>
<script type="module" src="./draw-one.js"></script>import { scrollDraw } from 'svg-scroll-draw';
import { harmonograph } from './harmonograph.js';
const svg = document.querySelector('#art');
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
path.setAttribute('d', harmonograph(34));
svg.append(path);
// One line: it draws as the drawing rises, and is done when its
// centre reaches the middle of the screen.
scrollDraw(svg, { easing: 'linear', trigger: { start: 'center 115%', end: 'center 50%' } });The trigger reads as “from when the drawing's centre is 115% of the way down the screen (its top just coming into view) to when its centre is at 50%”. Measuring both ends from the centre means the drawing finishes in the same place on a phone and on a wide screen, whatever its height. linear keeps the pen in step with the scroll: an ease-out puts most of the line down in the first half, and the drawing looks finished long before it is.
A flow field: lines that never touch
A flow field is a direction at every point of the page. Drop a pen anywhere, let it follow the directions, and you get a streamline. The plotter look comes from many streamlines that run side by side and never cross. The trick, from Jobard and Lefer's evenly-spaced streamlines, is to start each new line one spacing away from an old one, and stop it before it comes closer than half a spacing to anything drawn:
// Lines that follow a field of directions and never touch. Each new line
// starts one spacing away from an old one, and stops before it comes closer
// than half a spacing to anything already drawn.
export function flowField({ size = 1000, radius = 455, spacing = 7 } = {}) {
const c = size / 2;
// A drift from two slow waves, and a swirl round the centre. Blend them as
// directions, not angles: atan2 jumps from π to −π on the left-hand side,
// and adding angles across that jump draws a seam straight through the art.
const angle = (x, y) => {
const drift = Math.sin(x / 150) * 2 + Math.cos(y / 170) * 2;
const swirl = Math.atan2(y - c, x - c) + Math.PI / 2;
return Math.atan2(Math.sin(drift) + 0.5 * Math.sin(swirl), Math.cos(drift) + 0.5 * Math.cos(swirl));
};
// Every point drawn so far, filed by a grid cell one spacing wide.
const grid = new Map();
const cellOf = (x, y) => [Math.floor(x / spacing), Math.floor(y / spacing)];
const inside = (x, y) => Math.hypot(x - c, y - c) < radius;
const free = (x, y, d) => {
const [cx, cy] = cellOf(x, y);
for (let j = -1; j <= 1; j++) {
for (let i = -1; i <= 1; i++) {
for (const [px, py] of grid.get(`${cx + i},${cy + j}`) ?? []) {
if ((px - x) ** 2 + (py - y) ** 2 < d * d) return false;
}
}
}
return true;
};
// Walk the field both ways from a seed point, 1.6 units a step.
const trace = (sx, sy) => {
const half = (dir) => {
const out = [];
let x = sx;
let y = sy;
for (let k = 0; k < 700; k++) {
const a = angle(x, y);
x += Math.cos(a) * 1.6 * dir;
y += Math.sin(a) * 1.6 * dir;
if (!inside(x, y) || !free(x, y, spacing / 2)) break;
out.push([x, y]);
}
return out;
};
return [...half(-1).reverse(), [sx, sy], ...half(1)];
};
const lines = [];
const seeds = [[c, c]];
while (seeds.length) {
const [sx, sy] = seeds.pop();
if (!inside(sx, sy) || !free(sx, sy, spacing)) continue;
const line = trace(sx, sy);
if (line.length < 14) continue;
for (const [x, y] of line) {
const key = cellOf(x, y).join();
if (!grid.has(key)) grid.set(key, []);
grid.get(key).push([x, y]);
}
// New seeds one spacing to either side, every few steps along the line.
for (let i = 0; i < line.length; i += 6) {
const [x, y] = line[i];
const a = angle(x, y) + Math.PI / 2;
seeds.push([x + Math.cos(a) * spacing, y + Math.sin(a) * spacing]);
seeds.push([x - Math.cos(a) * spacing, y - Math.sin(a) * spacing]);
}
lines.push(line);
}
return lines;
}The grid is what keeps this fast: each step only checks the points in the nine cells around it, not every point drawn so far. With a radius of 455 and a spacing of 7 the field fills the circle with about 390strokes and 60 thousand points.
Timing hundreds of strokes
With one path, the trigger alone decides when the drawing finishes. With hundreds you want them to land one after another, top to bottom, the way the plotter would draw them, and that is what stagger does. But each path still draws across the whole trigger; the stagger only starts each one later. So the last path finishes well after the trigger ends: with a spread of one, a whole range later, by which point the drawing is up near the top of the screen.
The fix is to end the trigger early by exactly that much. Both ends are measured from the centre, so the range is a fixed share of the screen and the end can be solved for directly:
import { scrollDraw } from 'svg-scroll-draw';
import { flowField } from './flow-field.js';
// Each path draws across the whole trigger, and `stagger` starts each one a
// little later, so the last one ends `spread` ranges after trigger.end.
// Measure both ends from the element's centre and solve for the end, so the
// last stroke lands as the centre reaches `to`.
function scrollWindow(spread, from = 1.15, to = 0.5) {
const end = (to + spread * from) / (1 + spread);
const pct = (v) => `${(v * 100).toFixed(1)}%`;
return { start: `center ${pct(from)}`, end: `center ${pct(end)}` };
}
const svg = document.querySelector('#art');
// Top to bottom, as a plotter travels.
const lines = flowField().sort((a, b) => Math.min(a[0][1], a.at(-1)[1]) - Math.min(b[0][1], b.at(-1)[1]));
for (const line of lines) {
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
// Every third point is plenty at this scale, and a third of the markup.
const pts = line.filter((_, i) => i % 3 === 0 || i === line.length - 1);
path.setAttribute('d', 'M' + pts.map(([x, y]) => `${x.toFixed(1)} ${y.toFixed(1)}`).join('L'));
svg.append(path);
}
const spread = 1; // the last line starts one range after the first
scrollDraw(svg, { easing: 'linear', stagger: spread / (lines.length - 1), trigger: scrollWindow(spread) });<svg id="art" viewBox="0 0 1000 1000" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"></svg>
<script type="module" src="./draw-many.js"></script>With a spread of 1, the range runs from 115% to 82.5%, the first strokes draw as the drawing comes into view, and the last one lands as its centre reaches the middle. In our tests the field was 46% undrawn with its centre at 65% of the screen and finished by 45%, in Chromium, with no console errors.
What to keep off the page
A drawing like this is mostly numbers. Written out in full, the field's path data is about 696 KB. Two habits keep it small:
- Fewer points. At 1.6 units a step, every third point is still smoother than the eye can tell, and it brings the field to about 237 KB. One decimal place is plenty for a 1000-unit box.
- Make it in the browser. Generate the paths after the page loads, as the figures here do, instead of rendering them into the HTML. The markup stays small and the server does nothing, and the seed keeps the drawing stable from one visit to the next.
Readers who ask the system for reduced motion get the finished drawing: svg-scroll-draw shows the end state and draws nothing.
— / next
Sections drawn this way
scrollfx is a folio of React sections built on these drawings (a flow-field hero, contour maps, a hatched chart, a harmonograph call to action), each installed with the shadcn CLI and timed to finish as the reader reaches it.
Star on GitHubFound this useful? A star helps others find it.