Dot Field

· 3 min · Quick read · Joost van der Laan

A grid of dots bends away from a movable point and springs back into neat rows.

Focus the Lab, use the arrow keys to move its virtual pointer, and press Escape to clear it.

A small interactive toy in the lusion-inspired direction: a grid of ink dots on the lavender canvas that bend away from your cursor on spring physics, blushing electric blue as they move. Move your pointer (or finger) across the field.

No libraries, no WebGL — one canvas, ~100 lines of vanilla JavaScript, and the colors come straight from the design-token CSS variables, so the field follows the light/dark theme toggle. The site’s persisted motion control follows the operating system until overridden; in reduced or off the grid renders statically.

FIELD 01 · SPRINGS & INK

Teardown

CONSTANTS
  var GAP = 26, R = 2, PUSH = 90, SPRING = 0.06, DAMP = 0.86;

All physics tuning on one line. GAP is the grid spacing, PUSH is the cursor radius, SPRING pulls dots home, DAMP bleeds energy.

STEP — SPRING PHYSICS
    step: function (s) {
      var px = s.pointer.x, py = s.pointer.y;
      for (var k = 0; k < dots.length; k++) {
        var d = dots[k];
        var dx = d.x - px, dy = d.y - py;
        var dist = Math.sqrt(dx * dx + dy * dy);
        if (dist < PUSH && dist > 0.001) {
          var f = (1 - dist / PUSH) * 6;
          d.vx += (dx / dist) * f;
          d.vy += (dy / dist) * f;
        }
        d.vx = (d.vx + (d.hx - d.x) * SPRING) * DAMP;
        d.vy = (d.vy + (d.hy - d.y) * SPRING) * DAMP;
        d.x += d.vx;
        d.y += d.vy;
      }
    },

Each tick pushes dots away from the pointer, then springs them back to their home position. The damping factor keeps them from ringing forever.

GEOMETRY — ONE SOURCE
  function dotGeometry(d, tokens) {
    var stretch = Math.min(
      Math.sqrt(
        (d.x - d.hx) * (d.x - d.hx) + (d.y - d.hy) * (d.y - d.hy)
      ) / 24,
      1
    );
    return {
      x: d.x,
      y: d.y,
      radius: R + stretch * 1.5,
      color: stretch > 0.05 ? tokens.primary : tokens.ink,
      alpha: 0.35 + stretch * 0.65,
    };
  }

dotGeometry() computes the circle consumed by both Canvas and the plotter export. Past 5% stretch the dot turns primary blue; opacity rises with distance from home.

PLOTTER EXPORT
  function exportSVG(s) {
    var circles = [];
    for (var i = 0; i < dots.length; i++) {
      var shape = dotGeometry(dots[i], s.tokens);
      circles.push(
        '<circle cx="' + number(shape.x) +
        '" cy="' + number(shape.y) +
        '" r="' + number(shape.radius) +
        '" fill="none" stroke="' + attribute(shape.color) +
        '" stroke-width="0.7"/>'
      );
    }
    return '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ' +
      number(s.w) + ' ' + number(s.h) +
      '" width="' + number(s.w) + '" height="' + number(s.h) +
      '" role="img" aria-label="Dot Field plotter paths">' +
      '<g vector-effect="non-scaling-stroke">' + circles.join('') +
      '</g></svg>';
  }

The same geometry becomes path-native SVG circles with finite, rounded coordinates and no raster, font, filter, script, or external reference.

CANVAS DRAW
    draw: function (s) {
      s.ctx.clearRect(0, 0, s.w, s.h);
      for (var k = 0; k < dots.length; k++) {
        var shape = dotGeometry(dots[k], s.tokens);
        s.ctx.fillStyle = shape.color;
        s.ctx.globalAlpha = shape.alpha;
        s.ctx.beginPath();
        s.ctx.arc(shape.x, shape.y, shape.radius, 0, 6.2832);
        s.ctx.fill();
      }
      s.ctx.globalAlpha = 1;
    },
    exportSVG: exportSVG,

Canvas consumes dotGeometry() directly. The blue circle is the single accent event: colour earned by motion.

Harness lifecycle

The plate code above plugs into labCanvas(), which provides the canvas context, design-token colours, pointer tracking, a fixed-timestep loop, and a reduced-motion gate. The three hooks — setup, step, draw — are the only contract a plate needs to implement. The controls below the canvas copy the exact seed/frame/quality tuple, save a high-resolution PNG, or export the same circles as plotter-safe SVG paths.

FONT-AWARE INIT
  // Init — render one frame synchronously so dots appear on first paint
  constructionStep(function () {
    build(initialFrame);
    if (!reduced) {
      scheduleFrame();
    }
  });

  function announceFontFailure() {
    var controls = document.getElementById(canvasId + '-lab-controls');
    if (!controls || typeof controls.querySelector !== 'function') return;
    var status = controls.querySelector('[data-lab-status]');
    if (status) {
      status.textContent =
        'Required fonts failed to load; this frame cannot be shared or exported.';
    }
  }

  function failFonts(error) {
    if (!alive) return;
    fontFailure = error instanceof Error
      ? error
      : new Error('Required fonts failed to load');
    fontsSettled = false;
    cancelFrame();
    cancelReplay();
    syncReadinessDataset();
    announceFontFailure();
  }

  constructionStep(function () {
    if (!declaredFonts.length) return;
    if (!document.fonts || !document.fonts.ready ||
        typeof document.fonts.load !== 'function') {
      failFonts(new Error('Required font loading is unavailable'));
    } else {
      function finishFonts() {
        // FontFace promises and the site's `fonts-loaded` class settle in nearby
        // microtasks. Cross one paint boundary so setup measures the final stack.
        requestAnimationFrame(function () {
          if (!alive) return;
          fontFailure = null;
          fontsSettled = true;
          readTokens();
          build(currentTargetFrame());
        });
      }
      var fontLoads = Promise.all(declaredFonts.map(function (font) {
        // Wait for every attempt so one rejected face cannot make another
        // still-loading face appear canonical.
        return document.fonts.load(font).then(function (faces) {
          return { font: font, faces: faces };
        }, function (error) {
          return { font: font, error: error };
        });
      }));
      fontLoads.then(function (results) {
        var failed = results.filter(function (result) {
          return result.error || !result.faces || result.faces.length === 0;
        });
        if (failed.length) {
          throw new Error('Required fonts failed to load: ' +
            failed.map(function (result) { return result.font; }).join(', '));
        }
        return document.fonts.ready;
      }).then(finishFonts, failFonts);
    }
  });

The requested state starts immediately, but canonical readiness waits for every declared setup font and crosses a paint boundary before rebuilding. A font-measured plate therefore cannot publish fallback-font geometry.

THEME AND FONT OBSERVER
  // Theme toggle: re-read tokens and redraw
  var fontClassPresent =
    document.documentElement.classList.contains('fonts-loaded');
  var mo = null;
  constructionStep(function () {
    mo = new MutationObserver(function () {
      if (!alive || tokenFailure) return;
      var nextFontClass =
        document.documentElement.classList.contains('fonts-loaded');
      if (nextFontClass !== fontClassPresent) {
        fontClassPresent = nextFontClass;
        readTokens();
        if (declaredFonts.length) {
          build(currentTargetFrame());
        } else {
          drawCurrent();
        }
        return;
      }
      readTokens();
      drawCurrent();
    });
    mo.observe(document.documentElement, {
      attributes: true,
      attributeFilter: ['class'],
    });
  });

A MutationObserver distinguishes font activation from theme changes. Fonts rebuild the canonical model; a theme toggle only re-reads tokens and redraws without advancing simulation or PRNG.