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
- Paste the snippet just before
</head>. - Add
data-cymatics-plateto 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-interval | Time on each note before moving to the next. Default 5000 ms; 0 stays on one note. |
data-grains | How many grains of sand. Default scaled to the plate’s size, 1500 to 9000. |
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 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.
<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><div data-cymatics-plate style="height: 360px; background: #120a24"></div>