Water Caustics
The rippling net of light you see on the bottom of a swimming pool on a sunny day, endlessly shifting and re-forming. Add faint tiles for the full poolside feel.
Summer starts here
How to use it
- Paste the snippet just before
</head>. - Add
data-water-causticsto a section, hero or card.
Options
Add any of these attributes to the same element.
data-colors | Two colours: the water, then the light. Default #0e7490,#ecfeff. |
data-scale | Size of the pattern. Default 1. |
data-speed | Speed multiplier. Default 1. |
data-tiles | Faint pool tiles over the light. Give a size in px, e.g. data-tiles="40". Default 48 when set. |
It fills the section on its own, so no background is needed. It’s worked out on a small canvas and scaled up smoothly, so it costs about the same on any screen, and it pauses when off screen. With reduced motion it shows one still frame.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Water Caustics: the rippling net of light you see on the bottom of a
// swimming pool, endlessly shifting. Drawn small on a canvas and scaled
// up behind your content, and paused when off screen.
// Usage: <section data-water-caustics>…</section>
// Optional: data-colors (two colours: the water, then the light, default
// "#0e7490,#ecfeff"), data-scale (size of the pattern, default 1),
// data-speed (default 1), data-tiles (draw faint pool tiles; give a size in
// px, e.g. data-tiles="40", default 48 when set).
var DEFAULTS = { colors: '#0e7490,#ecfeff', scale: 1, speed: 1, tiles: 48 };
var css =
'[data-water-caustics]{position:relative;isolation:isolate;overflow:hidden}' +
'.wcs-canvas,.wcs-tiles{position:absolute;inset:0;width:100%;height:100%;z-index:-1;pointer-events:none}' +
'.wcs-tiles{background-image:linear-gradient(rgba(255,255,255,.1) 1px,transparent 1px),linear-gradient(90deg,rgba(255,255,255,.1) 1px,transparent 1px);mix-blend-mode:overlay}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
var probe = document.createElement('canvas').getContext('2d');
function rgb(c) {
probe.fillStyle = '#000';
probe.fillStyle = c.trim();
var v = probe.fillStyle;
if (v[0] === '#') return [parseInt(v.slice(1, 3), 16), parseInt(v.slice(3, 5), 16), parseInt(v.slice(5, 7), 16)];
return v.match(/[\d.]+/g).slice(0, 3).map(Number);
}
// Keep the work per frame about the same, whatever the section's size.
var PIXELS = 15000;
function build(el) {
var d = el.dataset;
var cols = (d.colors || DEFAULTS.colors).split(',');
var deep = rgb(cols[0]), light = rgb(cols[1] || '#ffffff');
var scale = d.scale !== undefined ? +d.scale : DEFAULTS.scale;
var speed = d.speed !== undefined ? +d.speed : DEFAULTS.speed;
var canvas = document.createElement('canvas');
canvas.className = 'wcs-canvas';
canvas.setAttribute('aria-hidden', 'true');
el.insertBefore(canvas, el.firstChild);
if (d.tiles !== undefined) {
var tiles = document.createElement('div');
tiles.className = 'wcs-tiles';
tiles.setAttribute('aria-hidden', 'true');
var ts = (+d.tiles || DEFAULTS.tiles) + 'px';
tiles.style.backgroundSize = ts + ' ' + ts;
canvas.after(tiles);
}
var ctx = canvas.getContext('2d');
var cw = 0, ch = 0, cell = 4, img = null, t = 0;
var still = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
// A brightness table: the light gathers into thin, bright lines.
var lut = [];
for (var i = 0; i < 256; i++) {
var v = i / 255;
lut.push([0, 1, 2].map(function (k) { return Math.round(deep[k] + (light[k] - deep[k]) * v); }));
}
function resize() {
var w = el.clientWidth, h = el.clientHeight;
cell = Math.max(2, Math.sqrt((w * h) / PIXELS));
cw = Math.max(1, Math.round(w / cell));
ch = Math.max(1, Math.round(h / cell));
canvas.width = cw;
canvas.height = ch;
img = ctx.createImageData(cw, ch);
draw();
}
// The classic caustic trick: each point is pushed around by a few
// layers of swirling sine waves, and where the paths bunch up the light
// is bright.
function draw() {
var data = img.data, time = t * 0.5 + 23, inten = 0.005;
// The pattern repeats every few hundred px; turning it a little hides
// the repeat.
var unit = (Math.PI * 2) / (380 * scale), ca = Math.cos(0.35) * unit, sa = Math.sin(0.35) * unit;
var tt = [];
for (var n = 0; n < 5; n++) tt.push(time * (1 - 3.5 / (n + 1)));
var o = 0;
for (var y = 0; y < ch; y++) {
var yy = y * cell;
for (var x = 0; x < cw; x++) {
var xx = x * cell;
var px = xx * ca - yy * sa - 250, py = xx * sa + yy * ca - 250;
var ix = px, iy = py, c = 1;
for (n = 0; n < 5; n++) {
var s = tt[n];
var nx = px + Math.cos(s - ix) + Math.sin(s + iy);
var ny = py + Math.sin(s - iy) + Math.cos(s + ix);
ix = nx; iy = ny;
var ax = (px * inten) / Math.sin(ix + s), ay = (py * inten) / Math.cos(iy + s);
c += 1 / Math.sqrt(ax * ax + ay * ay);
}
c = 1.17 - Math.pow(c / 5, 1.4);
var v = Math.pow(Math.abs(c), 8);
var col = lut[v >= 1 ? 255 : (v * 255) | 0];
data[o] = col[0]; data[o + 1] = col[1]; data[o + 2] = col[2]; data[o + 3] = 255;
o += 4;
}
}
ctx.putImageData(img, 0, 0);
}
resize();
if (window.ResizeObserver) new ResizeObserver(resize).observe(el);
if (still) return;
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;
t += (dt / 1000) * speed;
draw();
}
});
new IntersectionObserver(function (entries) {
if (entries[0].isIntersecting) { last = 0; timer.play(); } else timer.pause();
}).observe(el);
}
function init() {
document.querySelectorAll('[data-water-caustics]').forEach(function (el) {
if (el._wcs) return;
el._wcs = true;
build(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><section data-water-caustics data-tiles style="color: #fff">
<h1>Summer starts here</h1>
</section>