Rain on Glass
The section becomes a rainy window at night. Droplets land on the pane and gather; when two touch they merge, and once one is heavy enough it runs down the glass in fits and starts, swallowing droplets on its way and leaving a trail of tiny ones. Each drop works like a lens, showing the blurred city lights behind it sharp and upside down.
Stay in tonight
How to use it
- Paste the snippet just before
</head>. - Add
data-rain-on-glassto a section.
Options
Add any of these attributes to the same element.
data-rain | How hard it rains, as a multiplier. Default 1. |
data-size | Drop size, as a multiplier. Default 1. |
data-scene="false" | No city lights: just the drops, over your own background. |
It’s drawn on a canvas behind your content. Still droplets are kept on their own layer and only redrawn when they change, so it stays light, and it pauses when off screen. With reduced motion it shows a still, rain-speckled pane.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Rain on Glass: the section becomes a rainy window at night. Droplets
// land on the pane and gather; when two touch they merge, and once one is
// heavy enough it runs down the glass in fits and starts, swallowing the
// droplets in its way and leaving a trail of tiny ones behind. Each drop
// works like a little lens, showing the blurred city lights behind it
// sharp and upside down. Drawn on a canvas behind your content, and
// paused when off screen.
// Usage: <section data-rain-on-glass>…</section>
// Optional: data-rain (how hard it rains, as a multiplier, default 1),
// data-size (drop size, as a multiplier, default 1), data-scene="false"
// (no city lights: just the drops, over your own background).
var DEFAULTS = { rain: 1, size: 1 };
var LIGHTS = ['#f59e0b', '#fb7185', '#fde68a', '#f43f5e', '#38bdf8', '#a78bfa', '#facc15', '#fdba74'];
var css =
'[data-rain-on-glass]{position:relative;isolation:isolate;overflow:hidden}' +
'.rog-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);
// One shaded droplet, drawn once and stamped at every size: a dark rim
// underneath, a bright glint on top and a faint caustic along the bottom.
var sprite = document.createElement('canvas');
sprite.width = sprite.height = 64;
(function (c) {
var g = c.createRadialGradient(32, 27, 4, 32, 30, 32);
g.addColorStop(0, 'rgba(255,255,255,.06)');
g.addColorStop(0.7, 'rgba(255,255,255,.02)');
g.addColorStop(0.88, 'rgba(10,10,30,.28)');
g.addColorStop(1, 'rgba(10,10,30,.5)');
c.fillStyle = g;
c.beginPath(); c.arc(32, 32, 31, 0, Math.PI * 2); c.fill();
c.strokeStyle = 'rgba(255,255,255,.35)';
c.lineWidth = 2.5;
c.beginPath(); c.arc(32, 32, 27, Math.PI * 0.2, Math.PI * 0.8); c.stroke();
var s = c.createRadialGradient(24, 19, 0, 24, 19, 9);
s.addColorStop(0, 'rgba(255,255,255,.9)');
s.addColorStop(1, 'rgba(255,255,255,0)');
c.fillStyle = s;
c.beginPath(); c.arc(24, 19, 9, 0, Math.PI * 2); c.fill();
})(sprite.getContext('2d'));
function build(el) {
var d = el.dataset;
var rainK = d.rain !== undefined ? +d.rain : DEFAULTS.rain;
var sizeK = +d.size || DEFAULTS.size;
var scene = d.scene !== 'false';
var R = anime.utils.random;
var canvas = document.createElement('canvas');
canvas.className = 'rog-canvas';
canvas.setAttribute('aria-hidden', 'true');
el.insertBefore(canvas, el.firstChild);
var ctx = canvas.getContext('2d');
var blurred = document.createElement('canvas'), sharp = document.createElement('canvas');
var layer = document.createElement('canvas'), lctx = layer.getContext('2d');
var w = 0, h = 0, dpr = 1, unit = 1, drops = [], runners = [], dirty = true, maxDrops = 0;
function rand(a, b) { return a + Math.random() * (b - a); }
// The view through the glass: a night street of out-of-focus lights,
// painted twice, soft for the pane and crisp for inside the drops.
function paintScene() {
var b = blurred.getContext('2d'), s = sharp.getContext('2d');
blurred.width = sharp.width = w;
blurred.height = sharp.height = h;
[b, s].forEach(function (c) {
var g = c.createLinearGradient(0, 0, 0, h);
g.addColorStop(0, '#0b1026');
g.addColorStop(0.55, '#1e1538');
g.addColorStop(1, '#3a1a3a');
c.fillStyle = g;
c.fillRect(0, 0, w, h);
});
var n = Math.round((w * h) / 5000) + 12;
for (var i = 0; i < n; i++) {
var x = rand(0, w), y = h * (0.25 + 0.75 * Math.pow(Math.random(), 0.7));
var r = rand(10, 34) * unit, col = LIGHTS[i % LIGHTS.length];
var g = b.createRadialGradient(x, y, 0, x, y, r * 1.6);
g.addColorStop(0, col);
g.addColorStop(0.55, col);
g.addColorStop(1, 'rgba(0,0,0,0)');
b.globalAlpha = rand(0.18, 0.45);
b.fillStyle = g;
b.beginPath(); b.arc(x, y, r * 1.6, 0, Math.PI * 2); b.fill();
s.globalAlpha = 0.95;
s.fillStyle = col;
s.beginPath(); s.arc(x, y, r * 0.3, 0, Math.PI * 2); s.fill();
}
}
function drawDrop(c, x, y, r, sy) {
if (scene && r > 1.4) {
// A drop is a tiny lens: it shows a wide patch behind it, flipped.
var s = r * 5;
c.save();
c.beginPath();
c.ellipse(x, y, r, r * sy, 0, 0, Math.PI * 2);
c.clip();
c.translate(x, y);
c.scale(1, -1);
c.drawImage(sharp, x - s, y - s, 2 * s, 2 * s, -r, -r * sy, 2 * r, 2 * r * sy);
c.restore();
}
c.drawImage(sprite, x - r, y - r * sy, 2 * r, 2 * r * sy);
}
function resize() {
dpr = Math.min(2, window.devicePixelRatio || 1);
w = Math.max(1, el.clientWidth);
h = Math.max(1, el.clientHeight);
unit = Math.max(0.7, Math.min(1.2, Math.min(w, h) / 380)) * sizeK;
canvas.width = layer.width = w * dpr;
canvas.height = layer.height = h * dpr;
maxDrops = Math.round((w * h) / 900);
drops = [];
runners = [];
if (scene) paintScene();
// Start with the glass already wet.
for (var i = 0; i < maxDrops * 0.8; i++) land(rand(0, w), rand(0, h), small());
dirty = true;
draw();
}
function small() { return (0.9 + Math.pow(Math.random(), 3) * 4.6) * unit; }
// Add a droplet, merging it with any it touches.
function land(x, y, r) {
for (var i = drops.length - 1; i >= 0; i--) {
var o = drops[i], dx = o.x - x, dy = o.y - y;
if (dx * dx + dy * dy < (o.r + r) * (o.r + r) * 0.8) {
var a = o.r * o.r, b = r * r;
x = (o.x * a + x * b) / (a + b);
y = (o.y * a + y * b) / (a + b);
r = Math.sqrt(a + b);
drops.splice(i, 1);
}
}
if (r > 6.2 * unit && runners.length < 12) runners.push({ x: x, y: y, r: r, v: 0, vx: 0, stick: rand(0, 0.4), trail: 0 });
else drops.push({ x: x, y: y, r: r });
dirty = true;
}
function update(dt) {
var spawn = rainK * (w * h) / 6000 * dt;
while (spawn > 0) {
if (Math.random() < spawn) {
if (drops.length >= maxDrops) drops.splice(Math.floor(rand(0, drops.length)), 1);
land(rand(0, w), rand(0, h), small());
}
spawn -= 1;
}
for (var i = runners.length - 1; i >= 0; i--) {
var q = runners[i];
// Stick and slip: drops pause on the glass, then lurch on.
q.stick -= dt;
if (q.stick > 0) q.v *= Math.pow(0.02, dt);
else {
q.v = Math.min(q.v + 260 * unit * dt, q.r * 22);
if (Math.random() < dt * 0.9) q.stick = rand(0.08, 0.6);
}
q.vx += (rand(-1, 1) * 40 - q.vx * 2) * dt;
var step = q.v * dt;
q.y += step;
q.x += q.vx * dt * (q.v > 4 ? 1 : 0);
// Leave a trail of tiny droplets behind, losing a little water.
q.trail += step;
if (q.trail > rand(10, 22) * unit) {
q.trail = 0;
var tr = q.r * rand(0.18, 0.32);
drops.push({ x: q.x + rand(-1, 1) * q.r * 0.2, y: q.y - q.r * 1.3, r: tr });
q.r = Math.sqrt(Math.max(1, q.r * q.r - tr * tr * 0.6));
dirty = true;
}
// Swallow any droplets it runs into.
for (var j = drops.length - 1; j >= 0; j--) {
var o = drops[j], dx = o.x - q.x, dy = o.y - q.y;
if (dy > -q.r * 0.8 && dx * dx + dy * dy < (q.r + o.r * 0.6) * (q.r + o.r * 0.6)) {
q.r = Math.min(14 * unit, Math.sqrt(q.r * q.r + o.r * o.r));
drops.splice(j, 1);
dirty = true;
}
}
if (q.y - q.r * 2 > h) runners.splice(i, 1);
else if (q.r < 3.8 * unit) { runners.splice(i, 1); drops.push({ x: q.x, y: q.y, r: q.r }); dirty = true; }
}
}
function draw() {
if (dirty) {
// Still droplets only change now and then, so they're kept on their own layer.
lctx.setTransform(dpr, 0, 0, dpr, 0, 0);
lctx.clearRect(0, 0, w, h);
for (var i = 0; i < drops.length; i++) drawDrop(lctx, drops[i].x, drops[i].y, drops[i].r, 1);
dirty = false;
}
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
if (scene) ctx.drawImage(blurred, 0, 0, w, h);
ctx.drawImage(layer, 0, 0, w, h);
for (var k = 0; k < runners.length; k++) {
var q = runners[k];
drawDrop(ctx, q.x, q.y, q.r, 1 + Math.min(0.35, q.v / (q.r * 60)));
}
}
resize();
var lastW = w, lastH = h;
if (window.ResizeObserver) {
new ResizeObserver(function () {
if (el.clientWidth === lastW && el.clientHeight === lastH) return;
lastW = el.clientWidth; lastH = el.clientHeight;
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) / 1000;
last = now;
update(dt);
draw();
}
});
new IntersectionObserver(function (entries) {
if (entries[0].isIntersecting) { last = 0; timer.play(); } else timer.pause();
}).observe(el);
}
function init() {
document.querySelectorAll('[data-rain-on-glass]').forEach(function (el) {
if (el._rog) return;
el._rog = true;
build(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><section data-rain-on-glass style="color: #fff">
<h1>Stay in tonight</h1>
</section>