Lava Lamp
Glowing blobs of wax rise from a warm pool at the bottom of a section, stretch as they go, merge with each other and split apart again, then rest at the top before sinking back down. Groovy.
Far out
How to use it
- Paste the snippet just before
</head>. - Add
data-lava-lampto a section with a dark background.
Options
Add any of these attributes to the same element.
data-colors | Two colours for the wax, top then bottom. Default pink #f472b6 and orange #fb923c. |
data-count | Number of blobs. Default about two for each box height of width, 4 to 14. |
data-size | Blob size multiplier. Default 1. |
data-speed | Speed multiplier. Default 1. |
It’s drawn on a small transparent canvas behind your content and paused 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 () {
// Lava Lamp: glowing blobs of wax rise from a warm pool at the bottom,
// stretch, merge and split, cool at the top and sink back down. Drawn on
// a transparent canvas behind your content, and paused when off screen.
// Usage: <section data-lava-lamp style="background:#2e1065">…</section>
// Optional: data-colors (two colours, top then bottom, default pink
// #f472b6 and orange #fb923c), data-count (number of blobs, default
// about 2 per box height of width, 4 to 14), data-speed (default 1),
// data-size (blob size multiplier, default 1).
var DEFAULTS = { colors: ['#f472b6', '#fb923c'], speed: 1, size: 1 };
var MAX_PIXELS = 90000;
var css =
'[data-lava-lamp]{position:relative;isolation:isolate;overflow:hidden}' +
'.lva-canvas{position:absolute;inset:0;width:100%;height:100%;z-index:-1;pointer-events:none}';
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;
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);
}
function build(el) {
var d = el.dataset;
var cols = (d.colors ? d.colors.split(',') : DEFAULTS.colors).map(function (c) { return rgb(c.trim()); });
var top = cols[0], bottom = cols[1] || cols[0];
var speed = d.speed !== undefined ? +d.speed : DEFAULTS.speed;
var size = +d.size || DEFAULTS.size;
var canvas = document.createElement('canvas');
canvas.className = 'lva-canvas';
canvas.setAttribute('aria-hidden', 'true');
el.insertBefore(canvas, el.firstChild);
var ctx = canvas.getContext('2d');
var R = anime.utils.random;
var w = 0, h = 0, step = 2, img = null, t = 0;
var blobs = [], tint = [];
function blob(i, n) {
return {
home: (i + 0.5) / n + R(-0.25, 0.25, 2) / n, // across, as a fraction
r: R(0.75, 1.25, 2),
f: R(0.1, 0.2, 3), ph: R(0, 6.28, 2), // rise-and-fall rhythm
wf: R(0.2, 0.5, 2), wph: R(0, 6.28, 2), // side-to-side wobble
x: 0, y: 0
};
}
function resize() {
var cw = Math.max(1, el.clientWidth), ch = Math.max(1, el.clientHeight);
step = Math.max(1.5, Math.sqrt((cw * ch) / MAX_PIXELS));
w = Math.ceil(cw / step);
h = Math.ceil(ch / step);
canvas.width = w;
canvas.height = h;
img = ctx.createImageData(w, h);
var n = +d.count || Math.round(Math.min(14, Math.max(4, (cw / ch) * 1.8 + 1.5)));
if (blobs.length !== n) {
blobs = [];
for (var i = 0; i < n; i++) blobs.push(blob(i, n));
}
// The wax colour fades from the bottom colour to the top colour.
tint = [];
for (var y = 0; y < h; y++) {
var k = y / Math.max(1, h - 1);
tint.push([top[0] + (bottom[0] - top[0]) * k, top[1] + (bottom[1] - top[1]) * k, top[2] + (bottom[2] - top[2]) * k]);
}
draw();
}
function place() {
var base = Math.min(w, h * 1.4) * 0.115 * size;
for (var i = 0; i < blobs.length; i++) {
var b = blobs[i];
// Mostly lingering near the pool or the top, travelling in between.
var s = Math.sin(t * b.f + b.ph);
s = s * (1.5 - 0.5 * s * s);
b.y = h * (0.52 - 0.56 * s);
b.x = w * b.home + Math.sin(t * b.wf + b.wph) * w * 0.04;
// Blobs stretch taller while travelling, rounder when they rest.
b.rr = base * b.r;
b.sy = 1 + 0.35 * (1 - s * s);
}
}
function draw() {
place();
var data = img.data, n = blobs.length;
var pool = h * 0.075 * size, cap = h * 0.035 * size;
for (var y = 0; y < h; y++) {
// The warm pool at the bottom and a thin cool layer at the top.
var db = h + pool * 0.35 - y, dt = y + cap * 0.6;
var edge = (pool * pool) / (db * db) + (cap * cap) / (dt * dt);
var c = tint[y];
for (var x = 0; x < w; x++) {
var f = edge;
for (var k = 0; k < n; k++) {
var b = blobs[k], dx = x - b.x, dy = (y - b.y) / b.sy;
f += (b.rr * b.rr) / (dx * dx + dy * dy + 1);
}
var o = (y * w + x) * 4;
if (f < 0.95) { data[o + 3] = 0; continue; }
// A crisp but smooth edge, and the wax a little paler inside.
var a = f > 1.05 ? 1 : (f - 0.95) / 0.1;
var glow = (1 - 1 / f) * 0.32;
data[o] = c[0] + (255 - c[0]) * glow;
data[o + 1] = c[1] + (255 - c[1]) * glow;
data[o + 2] = c[2] + (255 - c[2]) * glow;
data[o + 3] = a * 255;
}
}
ctx.putImageData(img, 0, 0);
}
t = 20;
resize();
if (window.ResizeObserver) new ResizeObserver(resize).observe(el);
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) 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-lava-lamp]').forEach(function (el) {
if (el._lva) return;
el._lva = true;
build(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><section data-lava-lamp style="background: linear-gradient(#2e1065, #1e1b4b); color: #fff">
<h1>Far out</h1>
</section>