VibePen

Moiré

Dustin Adkison · 2 hours ago · v1

Open standalone ↗

Code written by a VibePen user, not by VibePen. It runs sandboxed on an isolated origin with no network access. Nobody at VibePen has reviewed it — treat it like any page from a stranger, and never enter a password or payment details.

Two grids of concentric rings, slowly sliding past each other. Nothing here is drawn — every band and rosette you see is interference between the two layers. Pure canvas, roughly forty lines of actual drawing code.

Source

3.2 KB
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Moiré</title>
<style>
  html, body { margin: 0; height: 100%; overflow: hidden; background: #08080c; }
  canvas { display: block; width: 100%; height: 100%; }
</style>
</head>
<body>
<canvas id="c"></canvas>
<script>
(() => {
  "use strict";

  var cv = document.getElementById("c");
  var ctx = cv.getContext("2d", { alpha: false });

  var SPACING = 13;   // ring pitch, px
  var RINGS = 190;    // rings per layer
  var t = 0;

  var w = 0, h = 0, dpr = 1;

  function resize() {
    dpr = Math.min(window.devicePixelRatio || 1, 1.5);
    var nw = Math.max(1, Math.floor(cv.clientWidth * dpr));
    var nh = Math.max(1, Math.floor(cv.clientHeight * dpr));
    if (nw === w && nh === h) return;
    w = nw; h = nh;
    cv.width = w; cv.height = h;
  }

  function rings(cx, cy, pitch, stroke) {
    ctx.strokeStyle = stroke;
    ctx.lineWidth = dpr;
    ctx.beginPath();
    for (var i = 1; i <= RINGS; i++) {
      var r = i * pitch;
      // cheap cull — skip rings entirely outside the canvas
      if (r - pitch > Math.hypot(Math.max(cx, w - cx), Math.max(cy, h - cy))) break;
      ctx.moveTo(cx + r, cy);
      ctx.arc(cx, cy, r, 0, Math.PI * 2);
    }
    ctx.stroke();
  }

  function frame() {
    resize();

    ctx.fillStyle = "#08080c";
    ctx.fillRect(0, 0, w, h);

    var pitch = SPACING * dpr;
    var mx = w / 2, my = h / 2;

    // the two centres orbit each other on slightly different periods,
    // so the interference pattern never settles into a loop
    var ax = mx + Math.cos(t * 0.21) * w * 0.16;
    var ay = my + Math.sin(t * 0.17) * h * 0.16;
    var bx = mx + Math.cos(t * 0.13 + 2.1) * w * 0.16;
    var by = my + Math.sin(t * 0.29 + 0.7) * h * 0.16;

    ctx.globalCompositeOperation = "lighter";
    rings(ax, ay, pitch, "rgba(90, 200, 255, 0.55)");
    rings(bx, by, pitch * 1.035, "rgba(255, 110, 160, 0.55)");
    ctx.globalCompositeOperation = "source-over";

    // vignette — pulls the eye to the middle where the rosettes form
    var g = ctx.createRadialGradient(mx, my, Math.min(w, h) * 0.15, mx, my, Math.max(w, h) * 0.72);
    g.addColorStop(0, "rgba(8, 8, 12, 0)");
    g.addColorStop(1, "rgba(8, 8, 12, 0.9)");
    ctx.fillStyle = g;
    ctx.fillRect(0, 0, w, h);

    t += 0.016;
  }

  var raf = 0, running = false;
  function tick() { frame(); raf = requestAnimationFrame(tick); }
  function play() { if (!running) { running = true; raf = requestAnimationFrame(tick); } }
  function stop() { running = false; cancelAnimationFrame(raf); }

  resize();

  var reduced = typeof window.matchMedia === "function" &&
    window.matchMedia("(prefers-reduced-motion: reduce)").matches;

  if (reduced) {
    t = 1.4;
    frame();
  } else {
    if (typeof IntersectionObserver === "function") {
      new IntersectionObserver(function (e) {
        e[0].isIntersecting ? play() : stop();
      }, { threshold: 0 }).observe(cv);
    }
    document.addEventListener("visibilitychange", function () {
      document.hidden ? stop() : play();
    });
    play();
  }

  window.addEventListener("resize", resize);
})();
</script>
</body>
</html>

Back to the gallery