01 / blog / how-toNovember 20268 min read

Generative SVG line art that draws itself on scroll

Pen plotters draw with a real pen, one stroke after another, and the drawings they are known for are made by code: flow fields, harmonographs, contour maps. All of them are just lines, which makes them ideal for SVG, and for drawing in as the reader scrolls. This post builds two of them in plain JavaScript and times the drawing so it finishes as the reader gets to it, not before and not after.

  • How-To
  • Generative art
  • SVG
  • scrollDraw
Harmonograph
1 line, 7,142 points
Flow field
about 390 strokes
Dependencies
svg-scroll-draw only
Seeded
same numbers everywhere
Fig. 1 — the harmonograph from this post, seed 34: one unbroken line, drawn as you scroll, done when its centre reaches the middle of the screen
01First

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:

random.js10 lines
// 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.

02One line

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.

harmonograph.js23 lines
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.

03Scroll

Draw it as the reader scrolls

Put the line in an SVG and hand it to svg-scroll-draw:

index.html3 lines
<svg id="art" viewBox="0 0 1000 1000" fill="none" stroke="currentColor" stroke-width="1"></svg>

<script type="module" src="./draw-one.js"></script>
draw-one.js11 lines
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.

04Hundreds of lines

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:

flow-field.js71 lines
// 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.

Fig. 2 — the flow field above: about 390 strokes, plotted top to bottom as you scroll, the last one landing as the drawing reaches the middle of the screen
05Timing

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:

draw-many.js26 lines
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) });
index.html3 lines
<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.

06Weight

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.

→Parts list

Keep reading

More from the blog