Op-art Pulse
A black-and-white checkerboard swells into a bulge that breathes in and out, with fine ripples running through it, so the whole pattern seems to throb and shimmer, after Victor Vasarely and Bridget Riley. Move the pointer, or drag a finger, to push the bulge about.
Move or drag to push the bulge
How to use it
- Paste the snippet just before
</head>. - Add
data-op-art-pulseto a box with a height.
Options
Add any of these attributes to the same element.
data-pattern | checker or waves (swaying stripes). Default checker. |
data-cell | Size of one square or stripe in px. Default 22. |
data-colors | The two colours. Default #111111,#f5f5f0. |
data-speed | Speed multiplier. Default 1. |
data-pointer="false" | Ignore the pointer. |
High-contrast patterns like this can be uncomfortable for some people, so with reduced motion it shows a still frame. It’s also paused when off screen.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Op-art Pulse: a black-and-white checkerboard swells into a bulge that
// breathes in and out, with fine ripples running through it, so the whole
// pattern seems to throb and shimmer, after Victor Vasarely and Bridget
// Riley. The "waves" pattern is a field of wavy stripes instead. The
// pointer (or a finger) drags the bulge about; otherwise it drifts.
// Usage: <div data-op-art-pulse style="height:360px"></div>
// Optional: data-pattern ("checker" or "waves", default checker),
// data-cell (size of one square or stripe in px, default 22),
// data-colors (the two colours, default "#111111,#f5f5f0"), data-speed
// (speed multiplier, default 1), data-pointer="false" (ignore the
// pointer).
var DEFAULTS = { pattern: 'checker', cell: 22, colors: '#111111,#f5f5f0', speed: 1 };
var css =
'[data-op-art-pulse]{position:relative;isolation:isolate;overflow:hidden}' +
'.opa-canvas{position:absolute;inset:0;width:100%;height:100%;z-index:-1}';
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 = 'opa-canvas';
canvas.setAttribute('aria-hidden', 'true');
el.insertBefore(canvas, el.firstChild);
var ctx = canvas.getContext('2d');
var waves = d.pattern === 'waves';
var cell = +d.cell || DEFAULTS.cell;
var colors = (d.colors || DEFAULTS.colors).split(',').map(function (c) { return c.trim(); });
if (colors.length < 2) colors.push('#f5f5f0');
var speed = +d.speed || DEFAULTS.speed;
var usePointer = d.pointer !== 'false';
var w = 0, h = 0, dpr = 1, t = 0, pointer = null, cx = 0, cy = 0;
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);
cx = w / 2; cy = h / 2;
return true;
}
// Where a point of the flat pattern ends up on screen: pushed out from
// the bulge's centre (more near the middle), plus a ripple.
function warp(x, y, R, amp, out) {
var dx = x - cx, dy = y - cy, dist = Math.sqrt(dx * dx + dy * dy);
var k = 1;
if (dist < R) {
var f = 1 - (dist / R) * (dist / R);
k = 1 + amp * f * f;
}
var ripple = Math.sin(dist / cell * 1.6 - t * 5) * cell * 0.18 * Math.max(0, 1 - dist / (R * 1.6));
var nd = dist * k + ripple;
if (dist < 1e-6) { out[0] = x; out[1] = y; return; }
out[0] = cx + dx / dist * nd;
out[1] = cy + dy / dist * nd;
}
function draw() {
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.fillStyle = colors[1];
ctx.fillRect(0, 0, w, h);
ctx.fillStyle = colors[0];
var R = Math.min(w, h) * 0.62 + Math.max(w, h) * 0.08;
var amp = 0.5 + 0.34 * Math.sin(t * 1.7);
var cols = Math.ceil(w / cell) + 4, rows = Math.ceil(h / cell) + 4;
// The flat pattern stays put, centred on the box; only the bulge moves.
var x0 = w / 2 - Math.ceil(w / 2 / cell + 1) * cell, y0 = h / 2 - Math.ceil(h / 2 / cell + 1) * cell;
var p = [0, 0];
// Warp every grid corner once.
var gx = [], gy = [];
for (var j = 0; j <= rows; j++) {
for (var i = 0; i <= cols; i++) {
warp(x0 + i * cell, y0 + j * cell, R, amp, p);
gx.push(p[0]);
gy.push(p[1]);
}
}
var stride = cols + 1;
ctx.beginPath();
if (!waves) {
// Every other square of the checkerboard, as a warped quad.
for (var b = 0; b < rows; b++) {
for (var a = b & 1; a < cols; a += 2) {
var n = b * stride + a;
ctx.moveTo(gx[n], gy[n]);
ctx.lineTo(gx[n + 1], gy[n + 1]);
ctx.lineTo(gx[n + stride + 1], gy[n + stride + 1]);
ctx.lineTo(gx[n + stride], gy[n + stride]);
ctx.closePath();
}
}
} else {
// Horizontal stripes that sway up and down, every other one filled.
for (var r = 0; r < rows; r += 2) {
for (var c = 0; c < cols; c++) {
var m = r * stride + c;
var s1 = Math.sin(c * 0.45 + t * 2.2) * cell * 0.35, s2 = Math.sin((c + 1) * 0.45 + t * 2.2) * cell * 0.35;
ctx.moveTo(gx[m], gy[m] + s1);
ctx.lineTo(gx[m + 1], gy[m + 1] + s2);
ctx.lineTo(gx[m + stride + 1], gy[m + stride + 1] + s2);
ctx.lineTo(gx[m + stride], gy[m + stride] + s1);
ctx.closePath();
}
}
}
ctx.fill();
}
function tick(dt) {
t += dt / 1000 * speed;
// The bulge drifts on a slow loop, or eases towards the pointer.
var tx = pointer ? pointer.x : w / 2 + Math.sin(t * 0.37) * w * 0.22;
var ty = pointer ? pointer.y : h / 2 + Math.sin(t * 0.53 + 0.8) * h * 0.16;
cx += (tx - cx) * 0.08;
cy += (ty - cy) * 0.08;
}
resize();
t = 0.9;
draw();
if (reduced()) {
if (window.ResizeObserver) new ResizeObserver(function () { if (resize()) draw(); }).observe(el);
return;
}
if (window.ResizeObserver) new ResizeObserver(function () { if (resize()) draw(); }).observe(el);
if (usePointer) {
var at = function (e) {
var b = el.getBoundingClientRect();
pointer = { x: e.clientX - b.left, y: e.clientY - b.top };
};
el.addEventListener('pointermove', function (e) { if (e.pointerType === 'mouse' || e.buttons) at(e); });
el.addEventListener('pointerdown', at);
el.addEventListener('pointerleave', function () { pointer = null; });
el.addEventListener('pointerup', function (e) { if (e.pointerType !== 'mouse') pointer = null; });
el.addEventListener('pointercancel', function () { pointer = null; });
}
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;
tick(dt);
draw();
}
});
// Pause while off screen to save battery.
new IntersectionObserver(function (entries) {
if (entries[0].isIntersecting) { last = 0; timer.play(); } else timer.pause();
}).observe(el);
}
function init() {
document.querySelectorAll('[data-op-art-pulse]').forEach(function (el) {
if (el._opa) return;
el._opa = true;
build(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-op-art-pulse style="height: 360px"></div>
<!-- stripes: -->
<div data-op-art-pulse data-pattern="waves" style="height: 360px"></div>