← Generative art

Cymatics Plate

Sand is scattered on a metal plate that hums at one note. The grains are shaken hard wherever the plate moves and left alone along the lines that stay still, so they gather into a Chladni figure. Every few seconds the note changes and the sand rushes into a new pattern. Tap or click to change the note yourself.

Tap to change the note

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-cymatics-plate to a box with a height and a dark background. The plate is a square in the middle.

Options

Add any of these attributes to the same element.

data-intervalTime on each note before moving to the next. Default 5000 ms; 0 stays on one note.
data-grainsHow many grains of sand. Default scaled to the plate’s size, 1500 to 9000.
data-colorSand colour. Default #fde68a.
data-platePlate colour. Default #2a1a4a.
data-speedSpeed multiplier. Default 1.
data-label="false"Hide the note’s two numbers in the corner.

Each note is one of the square plate’s vibration modes, two whole numbers n and m that say how many times it ripples each way. It’s paused when off screen, and with reduced motion it shows the settled pattern.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Cymatics Plate: grains of sand on a vibrating square plate. Sand shakes
    // hard wherever the plate moves and keeps still along the lines that stay
    // put, so it gathers into a Chladni figure. Every few seconds the note
    // changes, the sand scatters and finds a new pattern. Tap or click to
    // change the note yourself.
    // Usage: <div data-cymatics-plate style="height:360px;background:#120a24"></div>
    // Optional: data-grains (number of sand grains, default scaled to the
    // plate's area), data-interval (ms between notes, default 5000; 0 never
    // changes by itself), data-color (sand colour, default #fde68a),
    // data-plate (plate colour, default #2a1a4a), data-speed (speed
    // multiplier, default 1), data-label="false" (hide the note's numbers).
    var DEFAULTS = { interval: 5000, color: '#fde68a', plate: '#2a1a4a', speed: 1 };

    // Pairs of (n, m) and the sign between the two terms: each one is a mode
    // of the plate with its own pattern.
    var MODES = [[3, 5, -1], [2, 5, 1], [1, 4, -1], [3, 4, -1], [2, 7, -1], [1, 3, 1],
      [4, 5, 1], [2, 3, 1], [3, 6, 1], [1, 5, -1], [4, 7, -1], [2, 4, -1]];

    var css =
      '[data-cymatics-plate]{position:relative;isolation:isolate;overflow:hidden}' +
      '.cym-canvas{position:absolute;inset:0;width:100%;height:100%;z-index:-1}' +
      '.cym-label{position:absolute;right:12px;top:10px;font:500 12px/1 ui-monospace,Menlo,Consolas,monospace;' +
      'color:rgba(255,255,255,.62);pointer-events:none;letter-spacing:.04em}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }

    function build(el) {
      var d = el.dataset;
      var canvas = document.createElement('canvas');
      canvas.className = 'cym-canvas';
      canvas.setAttribute('aria-hidden', 'true');
      el.insertBefore(canvas, el.firstChild);
      var ctx = canvas.getContext('2d');
      var interval = d.interval !== undefined ? +d.interval : DEFAULTS.interval;
      var color = d.color || DEFAULTS.color;
      var plateColor = d.plate || DEFAULTS.plate;
      var speed = +d.speed || DEFAULTS.speed;
      var label = null;
      if (d.label !== 'false' && !el.closest('[aria-hidden="true"]')) {
        label = document.createElement('span');
        label.className = 'cym-label';
        label.setAttribute('aria-hidden', 'true');
        el.appendChild(label);
      }
      var w = 0, h = 0, dpr = 1, S = 0, ox = 0, oy = 0;
      var gx = new Float32Array(0), gy = new Float32Array(0), count = 0;
      var mode = 0, since = 0, shake = 0;
      var PI = Math.PI;

      function resize() {
        var nw = el.clientWidth, nh = el.clientHeight;
        if (nw === w && nh === h) return false;
        w = nw; h = nh;
        dpr = Math.min(2, window.devicePixelRatio || 1);
        canvas.width = Math.round(w * dpr);
        canvas.height = Math.round(h * dpr);
        S = Math.min(w, h) * 0.86;
        ox = (w - S) / 2; oy = (h - S) / 2;
        var n = +d.grains || Math.round(Math.max(1500, Math.min(9000, S * S / 16)));
        if (n !== count) {
          count = n;
          gx = new Float32Array(n); gy = new Float32Array(n);
          for (var i = 0; i < n; i++) { gx[i] = Math.random(); gy[i] = Math.random(); }
        }
        return true;
      }

      function setLabel() {
        if (label) label.textContent = 'n ' + MODES[mode][0] + '  m ' + MODES[mode][1];
      }

      // One step for every grain: shake it in proportion to how much the
      // plate moves there, and let it slide a little downhill towards the
      // nearest still line.
      function step(k) {
        var md = MODES[mode], n = md[0] * PI, m = md[1] * PI, s = md[2];
        var jit = 0.012 * k, pull = 0.0003 * k, fuzz = 0.0016 * k;
        for (var i = 0; i < count; i++) {
          var u = gx[i], v = gy[i];
          var cnu = Math.cos(n * u), cmv = Math.cos(m * v), cmu = Math.cos(m * u), cnv = Math.cos(n * v);
          var f = cnu * cmv + s * cmu * cnv;
          var fu = -n * Math.sin(n * u) * cmv - s * m * Math.sin(m * u) * cnv;
          var fv = -m * cnu * Math.sin(m * v) - s * n * cmu * Math.sin(n * v);
          var a = Math.abs(f) + shake;
          u += (Math.random() - 0.5) * (a * jit + fuzz) - f * fu * pull;
          v += (Math.random() - 0.5) * (a * jit + fuzz) - f * fv * pull;
          // Bounce off the plate's rim.
          if (u < 0) u = -u; else if (u > 1) u = 2 - u;
          if (v < 0) v = -v; else if (v > 1) v = 2 - v;
          gx[i] = u; gy[i] = v;
        }
      }

      function draw() {
        ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
        ctx.clearRect(0, 0, w, h);
        // The plate, with a soft sheen and a bolt in the middle.
        var r = Math.max(4, S * 0.025);
        ctx.save();
        ctx.shadowColor = 'rgba(0,0,0,.45)';
        ctx.shadowBlur = 24;
        ctx.shadowOffsetY = 8;
        ctx.fillStyle = plateColor;
        ctx.beginPath();
        if (ctx.roundRect) ctx.roundRect(ox, oy, S, S, r); else ctx.rect(ox, oy, S, S);
        ctx.fill();
        ctx.restore();
        var sheen = ctx.createLinearGradient(ox, oy, ox + S, oy + S);
        sheen.addColorStop(0, 'rgba(255,255,255,.10)');
        sheen.addColorStop(0.5, 'rgba(255,255,255,0)');
        sheen.addColorStop(1, 'rgba(0,0,0,.18)');
        ctx.fillStyle = sheen;
        ctx.fillRect(ox, oy, S, S);
        ctx.fillStyle = 'rgba(0,0,0,.35)';
        ctx.beginPath();
        ctx.arc(ox + S / 2, oy + S / 2, Math.max(2.5, S * 0.012), 0, PI * 2);
        ctx.fill();
        // The sand.
        ctx.fillStyle = color;
        var g = S > 260 ? 1.4 : 1.15;
        for (var i = 0; i < count; i++) ctx.fillRect(ox + gx[i] * S - g / 2, oy + gy[i] * S - g / 2, g, g);
      }

      function next() {
        mode = (mode + 1) % MODES.length;
        since = 0;
        shake = 0.5;
        setLabel();
      }

      resize();
      setLabel();
      if (reduced()) {
        for (var j = 0; j < 500; j++) step(1);
        draw();
        if (window.ResizeObserver) new ResizeObserver(function () { if (resize()) { for (var q = 0; q < 500; q++) step(1); draw(); } }).observe(el);
        return;
      }
      if (window.ResizeObserver) new ResizeObserver(function () { if (resize()) draw(); }).observe(el);
      el.addEventListener('pointerdown', next);

      var last = 0;
      var timer = anime.createTimer({
        loop: true,
        duration: 1000,
        autoplay: false,
        onUpdate: function () {
          var now = performance.now();
          var dt = last ? Math.min(50, now - last) : 16;
          last = now;
          since += dt;
          shake = Math.max(0, shake - dt / 900);
          if (interval > 0 && since > interval) next();
          var k = Math.min(1.5, dt / 16 * speed);
          step(k);
          step(k);
          draw();
        }
      });
      new IntersectionObserver(function (entries) {
        if (entries[0].isIntersecting) { last = 0; timer.play(); } else timer.pause();
      }).observe(el);
    }

    function init() {
      document.querySelectorAll('[data-cymatics-plate]').forEach(function (el) {
        if (el._cym) return;
        el._cym = true;
        build(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-cymatics-plate style="height: 360px; background: #120a24"></div>