Settling Snow
Snow drifts down over a section and piles up along the top edges of the cards and buttons inside it, building soft, rounded drifts. Once a ledge is full, flakes slide off and fall past. Loop it and the snow melts away and starts again.
Winter saleSnow piles up along the top of anything you mark.Shop now
How to use it
- Paste the snippet just before
</head>. - Add
data-settling-snowto a section. - Add
data-snow-ledgeto the cards, buttons or headings snow should settle on. Without any, it settles on the section’s direct children.
Options
Add any of these attributes to the same element.
data-snow-ledge | On an element inside: snow settles along its top edge. |
data-loop | Melt away once the drifts are deep, then start again. |
data-density | Flakes per 100 × 100 px. Default 3. |
data-depth | Deepest drift, in px. Default 16. |
data-speed | How quickly it builds up. Default 1. |
data-ground="false" | Don’t pile up along the bottom of the section. |
It’s drawn on a canvas over your content that clicks pass straight through, and paused when off screen. It re-measures the ledges as the page changes, so drifts follow the layout. With reduced motion you see the drifts already settled and no falling flakes.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Settling Snow: snow drifts down over a section and piles up along the top
// edges of the elements inside it (cards, buttons, headings) and along the
// bottom, building soft rounded drifts. Once a ledge is full, flakes slide
// off and fall past it. Drawn on a canvas over your content, which clicks
// pass straight through, and paused when off screen.
// Usage: <section data-settling-snow>
// <div class="card" data-snow-ledge>…</div>
// </section>
// Optional: data-snow-ledge on the elements snow should settle on (without
// any, it settles on the section's direct children), data-density (flakes
// per 100 × 100 px, default 3), data-depth (deepest drift in px, default
// 16), data-speed (how fast it builds up, default 1), data-ground="false"
// (don't pile up along the bottom), data-loop (melt away once it's deep and
// start again).
var DEFAULTS = { density: 3, depth: 16, speed: 1 };
var COL = 3; // px per column of snow
var css =
'[data-settling-snow]{position:relative;overflow:hidden}' +
'.stl-canvas{position:absolute;inset:0;width:100%;height:100%;z-index:2;pointer-events:none}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function build(el) {
var d = el.dataset;
var canvas = document.createElement('canvas');
canvas.className = 'stl-canvas';
canvas.setAttribute('aria-hidden', 'true');
el.appendChild(canvas);
var ctx = canvas.getContext('2d');
var density = d.density !== undefined ? +d.density : DEFAULTS.density;
var depth = +d.depth || DEFAULTS.depth;
var speed = d.speed !== undefined ? +d.speed : DEFAULTS.speed;
var ground = d.ground !== 'false';
var loop = d.loop !== undefined && d.loop !== 'false';
var w = 0, h = 0, dpr = 1, t = 0, lastMeasure = -1e9;
var flakes = [], ledges = [], melting = 0, fullSince = -1;
function targets() {
var list = el.querySelectorAll('[data-snow-ledge]');
if (!list.length) list = Array.prototype.filter.call(el.children, function (c) { return c !== canvas; });
return Array.prototype.slice.call(list);
}
// Each ledge keeps a height for every 3 px column along its top edge.
// Re-measuring keeps what's settled where the ledge hasn't changed size.
function measure() {
var box = el.getBoundingClientRect();
var sx = box.width ? w / box.width : 1;
var old = ledges;
ledges = targets().map(function (t, i) {
var r = t.getBoundingClientRect();
var radius = parseFloat(getComputedStyle(t).borderTopLeftRadius) || 0;
var L = { x: (r.left - box.left) * sx, y: (r.top - box.top) * sx, w: r.width * sx, radius: Math.min(radius, r.width / 3) };
var n = Math.max(2, Math.round(L.w / COL));
var prev = old[i];
L.cols = prev && prev.cols.length === n ? prev.cols : new Float32Array(n);
L.max = new Float32Array(n);
for (var c = 0; c < n; c++) {
// Shallower near the ends, so drifts round off at the corners.
var e = Math.min(c + 0.5, n - c - 0.5) * COL;
var k = Math.min(1, e / Math.max(8, depth * 1.4 + L.radius * 0.5));
L.max[c] = depth * Math.sqrt(k);
}
return L;
}).filter(function (L) { return L.w > 4; });
if (ground) {
var g = old.ground;
var n = Math.max(2, Math.round(w / COL));
var G = { x: 0, y: h, w: w, radius: 0, cols: g && g.cols.length === n ? g.cols : new Float32Array(n), max: new Float32Array(n) };
for (var c = 0; c < n; c++) G.max[c] = depth * 1.3;
ledges.push(G);
ledges.ground = G;
}
}
function spawn(f, anywhere) {
f.r = 1 + Math.random() * 2.2;
f.x = Math.random() * w;
f.y = anywhere ? Math.random() * h : -6 - Math.random() * 30;
f.vy = 0.02 + f.r * 0.012 + Math.random() * 0.012;
f.ph = Math.random() * 6.28;
f.sw = 0.2 + Math.random() * 0.5;
return f;
}
function resize() {
dpr = Math.min(2, window.devicePixelRatio || 1);
w = el.clientWidth;
h = el.clientHeight;
canvas.width = w * dpr;
canvas.height = h * dpr;
var n = Math.round(Math.min(500, Math.max(30, (w * h) / 10000 * density)));
while (flakes.length < n) flakes.push(spawn({}, true));
flakes.length = n;
measure();
lastMeasure = t;
}
// Add snow to a column, spread over its neighbours, then let steep
// slopes slump sideways so it piles up in smooth drifts.
function deposit(L, c, amount) {
for (var o = -8; o <= 8; o++) {
var j = c + o;
if (j < 0 || j >= L.cols.length) continue;
var add = amount * Math.exp(-(o * o) / 24) / 8.7;
L.cols[j] = Math.min(L.max[j], L.cols[j] + add);
}
}
function slump(L) {
var cols = L.cols;
for (var c = 0; c < cols.length - 1; c++) {
var diff = cols[c] - cols[c + 1];
if (diff > 1.6) { cols[c] -= (diff - 1.6) * 0.25; cols[c + 1] += (diff - 1.6) * 0.25; }
else if (diff < -1.6) { cols[c] += (-diff - 1.6) * 0.25; cols[c + 1] -= (-diff - 1.6) * 0.25; }
}
}
function fullness() {
var sum = 0, cap = 0;
ledges.forEach(function (L) {
if (L === ledges.ground && ledges.length > 1) return;
for (var c = 0; c < L.cols.length; c++) { sum += L.cols[c]; cap += L.max[c]; }
});
return cap ? sum / cap : 0;
}
function step(dt) {
t += dt;
if (t - lastMeasure > 600) { measure(); lastMeasure = t; }
if (loop) {
if (melting > 0) {
melting -= dt;
var k = Math.pow(0.9985, dt);
ledges.forEach(function (L) { for (var c = 0; c < L.cols.length; c++) L.cols[c] *= k; });
} else if (fullness() > 0.8) {
if (fullSince < 0) fullSince = t;
if (t - fullSince > 2200) { melting = 2600; fullSince = -1; }
}
}
for (var i = 0; i < flakes.length; i++) {
var f = flakes[i];
var py = f.y;
f.y += f.vy * dt;
f.x += Math.sin(t * 0.0012 * f.sw + f.ph) * 0.18 * f.sw * dt / 16 + 0.05 * dt / 16;
if (f.x > w + 4) f.x -= w + 8;
var landed = false;
for (var l = 0; l < ledges.length && !landed; l++) {
var L = ledges[l];
if (f.x < L.x || f.x >= L.x + L.w) continue;
var c = Math.min(L.cols.length - 1, Math.floor((f.x - L.x) / COL));
var top = L.y - L.cols[c];
if (py <= top && f.y >= top) {
// A full column lets the flake slide off and keep falling.
if (melting <= 0 && L.cols[c] < L.max[c] - 0.3) {
deposit(L, c, f.r * 10 * speed);
landed = true;
}
}
}
if (landed || f.y > h + 6) spawn(f, false);
}
ledges.forEach(slump);
}
function draw() {
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, w, h);
// Settled snow: a soft white shape along each ledge.
ctx.fillStyle = '#fbfdff';
ctx.shadowColor = 'rgba(30,50,90,.28)';
ctx.shadowBlur = 4;
ctx.shadowOffsetY = 1;
ledges.forEach(function (L) {
var cols = L.cols, any = false;
for (var c = 0; c < cols.length; c++) if (cols[c] > 0.3) { any = true; break; }
if (!any) return;
ctx.beginPath();
ctx.moveTo(L.x, L.y + 1);
for (var c2 = 0; c2 < cols.length; c2++) ctx.lineTo(L.x + (c2 + 0.5) * COL, L.y + 1 - cols[c2]);
ctx.lineTo(L.x + L.w, L.y + 1);
ctx.closePath();
ctx.fill();
});
ctx.shadowColor = 'transparent';
ctx.shadowBlur = 0;
ctx.shadowOffsetY = 0;
// Falling flakes.
ctx.fillStyle = 'rgba(255,255,255,.9)';
ctx.beginPath();
for (var i = 0; i < flakes.length; i++) {
var f = flakes[i];
ctx.moveTo(f.x + f.r, f.y);
ctx.arc(f.x, f.y, f.r, 0, 6.283);
}
ctx.fill();
}
resize();
if (window.ResizeObserver) new ResizeObserver(function () { resize(); draw(); }).observe(el);
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
// A still winter scene: drifts already settled.
ledges.forEach(function (L) { for (var c = 0; c < L.cols.length; c++) L.cols[c] = L.max[c] * 0.7; });
draw();
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(40, now - last) : 16;
last = now;
step(dt);
draw();
}
});
new IntersectionObserver(function (entries) {
if (entries[0].isIntersecting) { last = 0; timer.play(); } else timer.pause();
}).observe(el);
}
function init() {
document.querySelectorAll('[data-settling-snow]').forEach(function (el) {
if (el._stl) return;
el._stl = true;
build(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><section data-settling-snow data-loop>
<div class="card" data-snow-ledge>…</div>
<button data-snow-ledge>Shop now</button>
</section>