Updated 2026-10-14. 11 effects.
An animated website background is a moving decorative layer behind a section's content. These are the richer ones: flow fields, light through fog, liquid inks and terrain. Each draws on a 2D canvas by default, a few add an optional WebGL upgrade, and every one falls back to a still frame when motion or WebGL is unavailable.
Use one of these where the background is part of the first impression: a launch page, a portfolio opening, an event site. They carry more character than a gradient, so give them one section and let the rest of the page stay quiet.
The WebGL pieces check for a working context first and switch to their canvas or CSS version if there is none, if the context is lost, or if the visitor prefers reduced motion. You get the upgrade where it runs well and a good-looking page everywhere else. For lighter effects, the CSS, gradient, particle and wave collections linked below cost less to run.
| Effect | Built with | Size (gzipped) | Running cost |
|---|---|---|---|
| Wind Map Flow Field | Canvas 2D | 3.5 KB | About one particle per 520 square pixels, drawn as short line segments in three batched strokes per frame on canvas 2D. |
| Desert Heat Haze Horizon | Canvas 2D | 3.6 KB | The landscape is painted once; each frame copies it back in about 45 thin strips with small sideways offsets. |
| Dot Grid Fabric | Canvas 2D | 3.5 KB | One spring per dot, about 1,700 at desktop size, updated each frame and filled in three batched paths. |
| Lava Lamp Wax | Canvas 2D | 3.5 KB | A coarse field of samples, one per five pixels, summed over eight blobs at 30 frames a second and scaled up. |
| Light Beams Through Fog | Canvas 2D | 3.9 KB | Six pre-drawn beam images and sixteen fog sprites, blended at reduced resolution, plus a short loop over the dust specks. |
| Pool Water Ripples | Canvas 2D | 3.8 KB | A wave grid with one cell per eight pixels updated twice a frame, then tile lines and a scaled shading layer on canvas 2D. |
| Rain on a Night Window | Canvas 2D | 4.1 KB | Three images built once at load; each frame copies them and clips about 26 small drops, which is light work on canvas 2D. |
| Ringed Planet Dust Bands | Canvas 2D | 3.8 KB | About 3,200 one-pixel grains drawn each frame plus a few gradient fills; light for canvas 2D. |
| Rising Incense Smoke | Canvas 2D | 3.6 KB | Around 900 soft sprites drawn per frame at reduced resolution, with a simple drift calculation for each. |
| Two Ink Liquid Marble | Canvas 2D | 3.3 KB | A small grid of colour samples, about one per five pixels, worked out on the main thread at 30 frames a second and scaled up. |
| Wireframe Terrain Glide | Canvas 2D | 3.6 KB | About 4,400 projected points drawn as line paths each frame on canvas 2D, with no per-pixel work. |
Thousands of hairline streaks ride a slow wind field across a dark chart, then wind into a low-pressure system on the right, the way a live forecast map looks. Faster air glows warm and calm air stays steel blue, so the speed of the wind is readable at a glance. The copy sits on the left over a dark fade. On phones the low moves into the lower part of the block, below the button, so the headline keeps a calm patch of chart behind it.
<section class="gw-root" data-gw>
<canvas class="gw-canvas" aria-hidden="true"></canvas>
<div class="gw-scrim" aria-hidden="true"></div>
<div class="gw-inner">
<div class="gw-copy">
<p class="gw-eyebrow">Fairlead marine forecasts</p>
<h2 class="gw-title">Know the wind before you leave the mooring.</h2>
<p class="gw-body">Hour by hour wind, gusts and swell for the anchorages you actually sail, checked by forecasters who live on the coast.</p>
<a class="gw-button" href="#">See this week’s forecast</a>
</div>
</div>
</section>
<style>
/* Page colours: background, text and button. The scene colours live in SETTINGS in the script below. */
.gw-root { --gw-bg: #0c1520; --gw-text: #f2f5f7; --gw-muted: #c3ced8; --gw-button: #f3d9b1; --gw-button-text: #1a1410;
position: relative; width: 100%; height: 640px; overflow: hidden; background: var(--gw-bg); background-image: linear-gradient(rgba(140,170,200,.06) 1px, transparent 1px), linear-gradient(90deg, rgba(140,170,200,.06) 1px, transparent 1px); background-size: 72px 72px; color: var(--gw-text); font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
.gw-canvas { position: absolute; inset: 0; display: block; width: 100%; height: 100%; pointer-events: none; }
.gw-scrim { position: absolute; inset: 0; pointer-events: none; background: linear-gradient(90deg, rgba(12,21,32,.92) 0%, rgba(12,21,32,.7) 38%, rgba(12,21,32,0) 70%); }
.gw-inner { position: relative; z-index: 1; box-sizing: border-box; height: 100%; max-width: 72rem; margin: 0 auto; padding: 56px 24px; display: flex; flex-direction: column; justify-content: center; align-items: flex-start; text-align: left; }
.gw-copy { box-sizing: border-box; max-width: 36rem; }
.gw-eyebrow { margin: 0; font-size: 12px; font-weight: 600; letter-spacing: .2em; text-transform: uppercase; color: #f3d9b1; }
.gw-title { margin: 16px 0 0; font-size: clamp(2.25rem, 1.4rem + 3.4vw, 3.75rem); line-height: 1.05; font-weight: 600; letter-spacing: -.025em; }
.gw-body { margin: 20px 0 0; font-size: clamp(1rem, .95rem + .25vw, 1.125rem); line-height: 1.65; color: var(--gw-muted); }
.gw-button { margin-top: 32px; display: inline-flex; align-items: center; height: 48px; padding: 0 24px; border-radius: 6px; background: var(--gw-button); color: var(--gw-button-text); font-size: 14px; font-weight: 600; text-decoration: none; }
.gw-button:hover { opacity: .9; }
.gw-button:focus-visible { outline: 2px solid var(--gw-button); outline-offset: 3px; }
@media (min-width: 640px) { .gw-inner { padding: 56px 40px; } }
/* With reduced motion the script draws one settled frame and stops (it reads prefers-reduced-motion). */
@media (prefers-reduced-motion: reduce) { .gw-button { transition: none; } }
</style>
<script>
(function () {
// The values worth changing.
const SETTINGS = {
density: 520, // square pixels per streak; lower draws more wind
speed: 1, // how fast the air moves; 0.6 is a calm day
trail: 0.075, // how quickly old strokes fade; higher gives shorter streaks
calm: "rgba(118, 152, 178, 0.55)", // slow air
breeze: "rgba(196, 216, 228, 0.7)", // middling air
gust: "rgba(255, 226, 184, 0.9)", // the fastest air, near the low
};
// The wind: a slow rolling field plus one low-pressure system that air spirals into.
function windAngle(x, y, t, w, h) {
const base = Math.sin(x * 0.0019 + t * 0.045) * 1.5 + Math.cos(y * 0.0026 - t * 0.035) * 1.3 + Math.sin((x + y) * 0.0011 + 1.3);
const cx = w * (w < 700 ? 0.6 : 0.72) + Math.sin(t * 0.05) * 40;
const cy = h * (w < 700 ? 0.8 : 0.5) + Math.cos(t * 0.04) * 30;
const dx = x - cx;
const dy = y - cy;
const d = Math.sqrt(dx * dx + dy * dy) + 1;
const spin = Math.atan2(dy, dx) + Math.PI / 2 + 0.35;
const k = Math.exp(-d / (Math.min(w, h) * 0.42));
// Blend directions as vectors, not angles, so there is no seam where the angle wraps around.
const vx = Math.cos(base) * (1 - k) + Math.cos(spin) * k;
const vy = Math.sin(base) * (1 - k) + Math.sin(spin) * k;
return { a: Math.atan2(vy, vx), s: 0.55 + 0.25 * Math.sin(x * 0.004 + y * 0.003 + t * 0.2) + k * 1.4 };
}
const SCENE = {
start: 0,
still: 0,
init(ctx, w, h) {
const n = Math.round((w * h) / SETTINGS.density);
const ps = [];
for (let i = 0; i < n; i++) ps.push({ x: Math.random() * w, y: Math.random() * h, life: Math.random() * 160 });
const st = { ps, t: 0 };
ctx.clearRect(0, 0, w, h);
// Run the wind for a while first so the very first frame is already full of streaks.
for (let i = 0; i < 70; i++) SCENE.step(ctx, w, h, st, 1 / 60);
return st;
},
step(ctx, w, h, st, dt) {
st.t += dt;
ctx.globalCompositeOperation = "destination-out";
ctx.fillStyle = `rgba(0,0,0,${SETTINGS.trail})`;
ctx.fillRect(0, 0, w, h);
ctx.globalCompositeOperation = "source-over";
const paths = [new Path2D(), new Path2D(), new Path2D()];
const move = SETTINGS.speed * dt * 60 * 1.6;
for (const p of st.ps) {
const f = windAngle(p.x, p.y, st.t, w, h);
const nx = p.x + Math.cos(f.a) * f.s * move;
const ny = p.y + Math.sin(f.a) * f.s * move;
const bucket = f.s < 0.6 ? 0 : f.s < 1 ? 1 : 2;
paths[bucket].moveTo(p.x, p.y);
paths[bucket].lineTo(nx, ny);
p.x = nx;
p.y = ny;
p.life -= dt * 60;
if (p.life < 0 || p.x < -10 || p.x > w + 10 || p.y < -10 || p.y > h + 10) {
p.x = Math.random() * w;
p.y = Math.random() * h;
p.life = 80 + Math.random() * 160;
}
}
ctx.lineWidth = 1;
ctx.lineCap = "round";
const colours = [SETTINGS.calm, SETTINGS.breeze, SETTINGS.gust];
for (let i = 0; i < 3; i++) {
ctx.strokeStyle = colours[i];
ctx.stroke(paths[i]);
}
},
draw(ctx, w, h, t, st, ptr, dt) {
if (dt > 0) SCENE.step(ctx, w, h, st, dt);
},
};
// Runs a scene on a canvas: sizes it to the block, pauses off screen and in hidden tabs, cleans up after itself.
function runScene(root, canvas, scene, reduced) {
const ctx = canvas.getContext("2d");
if (!ctx) return () => {};
let w = 0;
let h = 0;
let st = null;
let raf = 0;
let last = 0;
let acc = 0;
let onScreen = true;
let time = reduced ? (scene.still ?? scene.start ?? 0) : (scene.start ?? 0);
const ptr = { x: 0, y: 0, on: false, down: 0 };
const step = 1 / (scene.fps || 60);
const resize = () => {
const r = root.getBoundingClientRect();
const nw = Math.max(1, Math.round(r.width));
const nh = Math.max(1, Math.round(r.height));
if (nw === w && nh === h && st) return;
w = nw;
h = nh;
// Device pixels are capped at 1.5 and scaled by the scene's own quality factor.
const d = Math.min(window.devicePixelRatio || 1, 1.5) * (scene.scale || 1);
canvas.width = Math.max(1, Math.round(w * d));
canvas.height = Math.max(1, Math.round(h * d));
ctx.setTransform(canvas.width / w, 0, 0, canvas.height / h, 0, 0);
st = scene.init(ctx, w, h, canvas.width / w);
scene.draw(ctx, w, h, time, st, ptr, 0);
};
const tick = (now) => {
raf = requestAnimationFrame(tick);
const dt = last ? Math.min(0.1, (now - last) / 1000) : step;
last = now;
acc += dt;
if (acc < step * 0.95) return;
const used = Math.min(acc, 0.1);
acc = 0;
time += used;
scene.draw(ctx, w, h, time, st, ptr, used);
};
const play = () => {
if (raf || reduced || !onScreen || document.hidden) return;
last = 0;
raf = requestAnimationFrame(tick);
};
const stop = () => {
cancelAnimationFrame(raf);
raf = 0;
};
const ro = new ResizeObserver(resize);
ro.observe(root);
const io = new IntersectionObserver((entries) => {
onScreen = entries[0].isIntersecting;
if (onScreen) play();
else stop();
});
io.observe(root);
const onVis = () => (document.hidden ? stop() : play());
const onMove = (e) => {
const r = root.getBoundingClientRect();
ptr.x = e.clientX - r.left;
ptr.y = e.clientY - r.top;
ptr.on = true;
};
const onLeave = () => {
ptr.on = false;
};
const onDown = (e) => {
onMove(e);
ptr.down += 1;
};
document.addEventListener("visibilitychange", onVis);
root.addEventListener("pointermove", onMove);
root.addEventListener("pointerleave", onLeave);
root.addEventListener("pointerdown", onDown);
resize();
play();
return () => {
stop();
ro.disconnect();
io.disconnect();
document.removeEventListener("visibilitychange", onVis);
root.removeEventListener("pointermove", onMove);
root.removeEventListener("pointerleave", onLeave);
root.removeEventListener("pointerdown", onDown);
};
}
var reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
document.querySelectorAll("[data-gw]").forEach(function (root) {
if (root.getAttribute("data-gw") === "on") return;
root.setAttribute("data-gw", "on");
runScene(root, root.querySelector(".gw-canvas"), SCENE, reduced);
});
})();
</script>
With reduced motion: The chart shows one settled frame of streaks already drawn along the wind, and nothing moves.
Running cost: About one particle per 520 square pixels, drawn as short line segments in three batched strokes per frame on canvas 2D.
Full page for Wind Map Flow Field
A low sun sits over a line of mesas while the air above the sand wobbles the horizon in thin bands, the way a road shimmers in August. The sky stays calm and pale behind the headline, so dark text reads cleanly. Good for travel, outdoor gear, ceramics and anything sold on the feeling of heat and distance.
<section class="hz-root" data-hz>
<canvas class="hz-canvas" aria-hidden="true"></canvas>
<div class="hz-inner">
<div class="hz-copy">
<p class="hz-eyebrow">Saltpan Overland</p>
<h2 class="hz-title">Four slow days across the salt flats.</h2>
<p class="hz-body">Small-group crossings with camp cooks, star charts and a guide who grew up on the edge of the pan.</p>
<a class="hz-button" href="#">See departure dates</a>
</div>
</div>
</section>
<style>
/* Page colours: background, text and button. The scene colours live in SETTINGS in the script below. */
.hz-root { --hz-bg: #f6ead9; --hz-text: #3a2416; --hz-muted: #5b3d2a; --hz-button: #3a2416; --hz-button-text: #f8efe2;
position: relative; width: 100%; height: 660px; overflow: hidden; background: var(--hz-bg); color: var(--hz-text); font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
.hz-canvas { position: absolute; inset: 0; display: block; width: 100%; height: 100%; pointer-events: none; }
.hz-inner { position: relative; z-index: 1; box-sizing: border-box; height: 100%; max-width: 72rem; margin: 0 auto; padding: 56px 24px; display: flex; flex-direction: column; justify-content: flex-start; align-items: flex-start; text-align: left; }
.hz-copy { box-sizing: border-box; max-width: 36rem; }
.hz-eyebrow { margin: 0; font-size: 12px; font-weight: 600; letter-spacing: .2em; text-transform: uppercase; color: #8c4a26; }
.hz-title { margin: 16px 0 0; font-size: clamp(2.25rem, 1.4rem + 3.4vw, 3.75rem); line-height: 1.05; font-weight: 600; letter-spacing: -.025em; }
.hz-body { margin: 20px 0 0; font-size: clamp(1rem, .95rem + .25vw, 1.125rem); line-height: 1.65; color: var(--hz-muted); }
.hz-button { margin-top: 32px; display: inline-flex; align-items: center; height: 48px; padding: 0 24px; border-radius: 9999px; background: var(--hz-button); color: var(--hz-button-text); font-size: 14px; font-weight: 600; text-decoration: none; }
.hz-button:hover { opacity: .9; }
.hz-button:focus-visible { outline: 2px solid var(--hz-button); outline-offset: 3px; }
@media (min-width: 640px) { .hz-inner { padding: 56px 40px; } }
/* With reduced motion the script draws one settled frame and stops (it reads prefers-reduced-motion). */
@media (prefers-reduced-motion: reduce) { .hz-button { transition: none; } }
</style>
<script>
(function () {
// The values worth changing.
const SETTINGS = {
horizon: 0.68, // where the land meets the sky, as a share of the height
shimmer: 7, // how far the hot air bends the horizon, in pixels
band: 90, // how tall the shimmering band is, in pixels
skyTop: "#f6ead9", // the pale sky behind the headline
skyLow: "#f4c595", // the warm glow at the horizon
sand: "#d9a172", // near dunes
};
const SCENE = {
start: 0,
still: 0,
scale: 0.8,
init(ctx, w, h, k) {
// Paint the whole landscape once; each frame only re-draws it in thin, shifted strips.
const off = document.createElement("canvas");
off.width = Math.round(w * k);
off.height = Math.round(h * k);
const c = off.getContext("2d");
c.scale(k, k);
const hy = Math.round(h * SETTINGS.horizon);
const sky = c.createLinearGradient(0, 0, 0, hy);
sky.addColorStop(0, SETTINGS.skyTop);
sky.addColorStop(0.55, "#f6dcbc");
sky.addColorStop(1, SETTINGS.skyLow);
c.fillStyle = sky;
c.fillRect(0, 0, w, hy + 1);
const sx = w * (w < 700 ? 0.74 : 0.7);
const sun = c.createRadialGradient(sx, hy - 18, 0, sx, hy - 18, Math.max(w, h) * 0.35);
sun.addColorStop(0, "rgba(255,248,232,0.95)");
sun.addColorStop(0.06, "rgba(255,240,214,0.85)");
sun.addColorStop(0.07, "rgba(255,228,190,0.45)");
sun.addColorStop(1, "rgba(255,214,170,0)");
c.fillStyle = sun;
c.fillRect(0, 0, w, hy + 1);
// Two ranges of mesas, the far one paler from the haze.
const range = (seed, top, colour, rough) => {
c.beginPath();
c.moveTo(0, hy + 1);
let y = top;
for (let x = 0; x <= w + 8; x += 8) {
const n = Math.sin(x * 0.011 + seed) + Math.sin(x * 0.027 + seed * 2.1) * 0.5;
const flat = n > 0.55 ? -rough : n > -0.2 ? -rough * 0.35 : 0;
y += (hy + flat - y) * 0.35;
c.lineTo(x, y);
}
c.lineTo(w, hy + 1);
c.closePath();
c.fillStyle = colour;
c.fill();
};
range(1.3, hy, "rgba(205,150,132,0.55)", Math.min(46, h * 0.07));
range(4.1, hy, "rgba(166,108,96,0.75)", Math.min(26, h * 0.04));
const ground = c.createLinearGradient(0, hy, 0, h);
ground.addColorStop(0, "#efc49a");
ground.addColorStop(0.08, "#e8b689");
ground.addColorStop(1, SETTINGS.sand);
c.fillStyle = ground;
c.fillRect(0, hy, w, h - hy);
// A mirage: a pale strip of sky reflected just below the horizon.
const mir = c.createLinearGradient(0, hy, 0, hy + 22);
mir.addColorStop(0, "rgba(248,214,176,0.9)");
mir.addColorStop(1, "rgba(248,214,176,0)");
c.fillStyle = mir;
c.fillRect(0, hy, w, 22);
// Soft dune ridges, lighter on the lit side.
for (let i = 0; i < 7; i++) {
const y0 = hy + 30 + i * i * 7 + i * 10;
c.beginPath();
for (let x = -20; x <= w + 20; x += 10) {
const y = y0 + Math.sin(x * (0.004 + i * 0.0007) + i * 1.7) * (6 + i * 3);
if (x === -20) c.moveTo(x, y);
else c.lineTo(x, y);
}
c.strokeStyle = `rgba(255,236,210,${0.28 - i * 0.02})`;
c.lineWidth = 1.2 + i * 0.4;
c.stroke();
c.strokeStyle = `rgba(150,90,60,${0.1})`;
c.lineWidth = 1;
c.translate(0, 3 + i);
c.stroke();
c.setTransform(k, 0, 0, k, 0, 0);
}
return { off, k, hy };
},
draw(ctx, w, h, t, st) {
const { off, k, hy } = st;
const top = Math.max(0, hy - SETTINGS.band * 0.6);
const bottom = Math.min(h, hy + SETTINGS.band * 0.4);
ctx.drawImage(off, 0, 0, off.width, top * k, 0, 0, w, top);
ctx.drawImage(off, 0, bottom * k, off.width, off.height - bottom * k, 0, bottom, w, h - bottom);
// Hot air: each strip near the horizon slides sideways and stretches a little, strongest at the horizon itself.
for (let y = top; y < bottom; y += 2) {
const near = 1 - Math.abs(y - hy) / (SETTINGS.band * 0.6);
const amp = SETTINGS.shimmer * near * near;
const dx = amp * (Math.sin(y * 0.33 + t * 3.1) * 0.7 + Math.sin(y * 0.09 - t * 1.6) * 0.5);
const dy = amp * 0.3 * Math.sin(y * 0.21 + t * 2.3);
ctx.drawImage(off, 0, (y + dy) * k, off.width, 2 * k, dx - 8, y, w + 16, 2.2);
}
},
};
// Runs a scene on a canvas: sizes it to the block, pauses off screen and in hidden tabs, cleans up after itself.
function runScene(root, canvas, scene, reduced) {
const ctx = canvas.getContext("2d");
if (!ctx) return () => {};
let w = 0;
let h = 0;
let st = null;
let raf = 0;
let last = 0;
let acc = 0;
let onScreen = true;
let time = reduced ? (scene.still ?? scene.start ?? 0) : (scene.start ?? 0);
const ptr = { x: 0, y: 0, on: false, down: 0 };
const step = 1 / (scene.fps || 60);
const resize = () => {
const r = root.getBoundingClientRect();
const nw = Math.max(1, Math.round(r.width));
const nh = Math.max(1, Math.round(r.height));
if (nw === w && nh === h && st) return;
w = nw;
h = nh;
// Device pixels are capped at 1.5 and scaled by the scene's own quality factor.
const d = Math.min(window.devicePixelRatio || 1, 1.5) * (scene.scale || 1);
canvas.width = Math.max(1, Math.round(w * d));
canvas.height = Math.max(1, Math.round(h * d));
ctx.setTransform(canvas.width / w, 0, 0, canvas.height / h, 0, 0);
st = scene.init(ctx, w, h, canvas.width / w);
scene.draw(ctx, w, h, time, st, ptr, 0);
};
const tick = (now) => {
raf = requestAnimationFrame(tick);
const dt = last ? Math.min(0.1, (now - last) / 1000) : step;
last = now;
acc += dt;
if (acc < step * 0.95) return;
const used = Math.min(acc, 0.1);
acc = 0;
time += used;
scene.draw(ctx, w, h, time, st, ptr, used);
};
const play = () => {
if (raf || reduced || !onScreen || document.hidden) return;
last = 0;
raf = requestAnimationFrame(tick);
};
const stop = () => {
cancelAnimationFrame(raf);
raf = 0;
};
const ro = new ResizeObserver(resize);
ro.observe(root);
const io = new IntersectionObserver((entries) => {
onScreen = entries[0].isIntersecting;
if (onScreen) play();
else stop();
});
io.observe(root);
const onVis = () => (document.hidden ? stop() : play());
const onMove = (e) => {
const r = root.getBoundingClientRect();
ptr.x = e.clientX - r.left;
ptr.y = e.clientY - r.top;
ptr.on = true;
};
const onLeave = () => {
ptr.on = false;
};
const onDown = (e) => {
onMove(e);
ptr.down += 1;
};
document.addEventListener("visibilitychange", onVis);
root.addEventListener("pointermove", onMove);
root.addEventListener("pointerleave", onLeave);
root.addEventListener("pointerdown", onDown);
resize();
play();
return () => {
stop();
ro.disconnect();
io.disconnect();
document.removeEventListener("visibilitychange", onVis);
root.removeEventListener("pointermove", onMove);
root.removeEventListener("pointerleave", onLeave);
root.removeEventListener("pointerdown", onDown);
};
}
var reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
document.querySelectorAll("[data-hz]").forEach(function (root) {
if (root.getAttribute("data-hz") === "on") return;
root.setAttribute("data-hz", "on");
runScene(root, root.querySelector(".hz-canvas"), SCENE, reduced);
});
})();
</script>
With reduced motion: The landscape is shown once with a perfectly steady horizon and no shimmer.
Running cost: The landscape is painted once; each frame copies it back in about 45 thin strips with small sideways offsets.
A grid of ink dots behaves like stretched cloth: the pointer presses into it, the weave opens into larger rust-coloured dots, and the fabric springs back with a small wobble when you move away. When nobody is pointing, an unseen hand drifts across it on its own. It suits textile, interior and design studio pages.
<section class="df-root" data-df>
<canvas class="df-canvas" aria-hidden="true"></canvas>
<div class="df-scrim" aria-hidden="true"></div>
<div class="df-inner">
<div class="df-copy">
<p class="df-eyebrow">Weft and Warp Studio</p>
<h2 class="df-title">Woven textiles for quiet rooms.</h2>
<p class="df-body">Upholstery and curtain cloth designed in our studio and woven on old shuttle looms.</p>
<a class="df-button" href="#">Browse the swatch library</a>
</div>
</div>
</section>
<style>
/* Page colours: background, text and button. The scene colours live in SETTINGS in the script below. */
.df-root { --df-bg: #f3f1ec; --df-text: #1e2733; --df-muted: #48505c; --df-button: #a8452a; --df-button-text: #ffffff;
position: relative; width: 100%; height: 600px; overflow: hidden; background: var(--df-bg); color: var(--df-text); font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
.df-canvas { position: absolute; inset: 0; display: block; width: 100%; height: 100%; pointer-events: none; }
.df-scrim { position: absolute; inset: 0; pointer-events: none; background: radial-gradient(ellipse 46% 44% at 50% 50%, rgba(243,241,236,0.96) 0%, rgba(243,241,236,0.8) 60%, rgba(243,241,236,0) 100%); }
.df-inner { position: relative; z-index: 1; box-sizing: border-box; height: 100%; max-width: 72rem; margin: 0 auto; padding: 56px 24px; display: flex; flex-direction: column; justify-content: center; align-items: center; text-align: center; }
.df-copy { box-sizing: border-box; max-width: 36rem; }
.df-eyebrow { margin: 0; font-size: 12px; font-weight: 600; letter-spacing: .2em; text-transform: uppercase; color: #a8452a; }
.df-title { margin: 16px 0 0; font-size: clamp(2.25rem, 1.4rem + 3.4vw, 3.75rem); line-height: 1.05; font-weight: 600; letter-spacing: -.025em; }
.df-body { margin: 20px 0 0; font-size: clamp(1rem, .95rem + .25vw, 1.125rem); line-height: 1.65; color: var(--df-muted); }
.df-button { margin-top: 32px; display: inline-flex; align-items: center; height: 48px; padding: 0 24px; border-radius: 9999px; background: var(--df-button); color: var(--df-button-text); font-size: 14px; font-weight: 600; text-decoration: none; }
.df-button:hover { opacity: .9; }
.df-button:focus-visible { outline: 2px solid var(--df-button); outline-offset: 3px; }
@media (min-width: 640px) { .df-inner { padding: 56px 40px; } }
/* With reduced motion the script draws one settled frame and stops (it reads prefers-reduced-motion). */
@media (prefers-reduced-motion: reduce) { .df-button { transition: none; } }
</style>
<script>
(function () {
// The values worth changing.
const SETTINGS = {
gap: 22, // distance between dots in pixels
ink: "rgba(36, 44, 58, 0.62)", // dots at rest
thread: "rgba(190, 82, 46, 0.95)", // dots pulled furthest by the pointer
reach: 180, // radius of the pointer's push, in pixels
push: 46, // how far the cloth is pressed at the centre
spring: 18, // how quickly the cloth springs back; lower is looser
};
const SCENE = {
start: 0,
still: 2.2,
init(ctx, w, h) {
const g = SETTINGS.gap;
const cols = Math.ceil(w / g) + 1;
const rows = Math.ceil(h / g) + 1;
const ox = (w - (cols - 1) * g) / 2;
const oy = (h - (rows - 1) * g) / 2;
const n = cols * rows;
const st = { cols, rows, ox, oy, dx: new Float32Array(n), dy: new Float32Array(n), vx: new Float32Array(n), vy: new Float32Array(n), px: w * 0.7, py: h * 0.5 };
// Settle the cloth around the idle pointer before the first frame.
for (let i = 0; i < 40; i++) SCENE.step(st, w, h, 1 / 60, i / 60, { on: false });
return st;
},
step(st, w, h, dt, t, ptr) {
// Without a real pointer, an unseen hand drifts across the cloth in a slow figure of eight.
const tx = ptr.on ? ptr.x : w * (0.62 + 0.28 * Math.sin(t * 0.37));
const ty = ptr.on ? ptr.y : h * (0.5 + 0.32 * Math.sin(t * 0.74));
st.px += (tx - st.px) * Math.min(1, dt * 6);
st.py += (ty - st.py) * Math.min(1, dt * 6);
const g = SETTINGS.gap;
const R = SETTINGS.reach;
const k = SETTINGS.spring;
const damp = Math.exp(-dt * 5.5);
for (let r = 0; r < st.rows; r++) {
for (let c = 0; c < st.cols; c++) {
const i = r * st.cols + c;
const bx = st.ox + c * g;
const by = st.oy + r * g;
const ex = bx - st.px;
const ey = by - st.py;
const d2 = ex * ex + ey * ey;
let fx = -st.dx[i] * k * k * 0.05;
let fy = -st.dy[i] * k * k * 0.05;
if (d2 < R * R * 4) {
const d = Math.sqrt(d2) + 0.001;
const fall = Math.exp(-d2 / (R * R * 0.5));
fx += (ex / d) * fall * SETTINGS.push * 9;
fy += (ey / d) * fall * SETTINGS.push * 9;
}
st.vx[i] = (st.vx[i] + fx * dt) * damp;
st.vy[i] = (st.vy[i] + fy * dt) * damp;
st.dx[i] += st.vx[i] * dt;
st.dy[i] += st.vy[i] * dt;
}
}
},
draw(ctx, w, h, t, st, ptr, dt) {
if (dt > 0) SCENE.step(st, w, h, dt, t, ptr);
ctx.clearRect(0, 0, w, h);
const g = SETTINGS.gap;
const calm = new Path2D();
const mid = new Path2D();
const hot = new Path2D();
for (let r = 0; r < st.rows; r++) {
for (let c = 0; c < st.cols; c++) {
const i = r * st.cols + c;
const x = st.ox + c * g + st.dx[i];
const y = st.oy + r * g + st.dy[i];
const m = Math.sqrt(st.dx[i] * st.dx[i] + st.dy[i] * st.dy[i]) / SETTINGS.push;
// Stretched cloth shows bigger, warmer dots, as if the weave opens under the press.
const rad = 1.35 + Math.min(1.8, m * 1.8);
const p = m < 0.15 ? calm : m < 0.4 ? mid : hot;
p.moveTo(x + rad, y);
p.arc(x, y, rad, 0, 6.2832);
}
}
// A soft shadow where the cloth is pressed in.
const dent = ctx.createRadialGradient(st.px, st.py, 0, st.px, st.py, SETTINGS.reach * 1.1);
dent.addColorStop(0, "rgba(60, 50, 40, 0.10)");
dent.addColorStop(0.6, "rgba(60, 50, 40, 0.04)");
dent.addColorStop(1, "rgba(60, 50, 40, 0)");
ctx.fillStyle = dent;
ctx.fillRect(st.px - SETTINGS.reach * 1.2, st.py - SETTINGS.reach * 1.2, SETTINGS.reach * 2.4, SETTINGS.reach * 2.4);
ctx.fillStyle = SETTINGS.ink;
ctx.fill(calm);
ctx.fillStyle = "rgba(120, 70, 56, 0.75)";
ctx.fill(mid);
ctx.fillStyle = SETTINGS.thread;
ctx.fill(hot);
},
};
// Runs a scene on a canvas: sizes it to the block, pauses off screen and in hidden tabs, cleans up after itself.
function runScene(root, canvas, scene, reduced) {
const ctx = canvas.getContext("2d");
if (!ctx) return () => {};
let w = 0;
let h = 0;
let st = null;
let raf = 0;
let last = 0;
let acc = 0;
let onScreen = true;
let time = reduced ? (scene.still ?? scene.start ?? 0) : (scene.start ?? 0);
const ptr = { x: 0, y: 0, on: false, down: 0 };
const step = 1 / (scene.fps || 60);
const resize = () => {
const r = root.getBoundingClientRect();
const nw = Math.max(1, Math.round(r.width));
const nh = Math.max(1, Math.round(r.height));
if (nw === w && nh === h && st) return;
w = nw;
h = nh;
// Device pixels are capped at 1.5 and scaled by the scene's own quality factor.
const d = Math.min(window.devicePixelRatio || 1, 1.5) * (scene.scale || 1);
canvas.width = Math.max(1, Math.round(w * d));
canvas.height = Math.max(1, Math.round(h * d));
ctx.setTransform(canvas.width / w, 0, 0, canvas.height / h, 0, 0);
st = scene.init(ctx, w, h, canvas.width / w);
scene.draw(ctx, w, h, time, st, ptr, 0);
};
const tick = (now) => {
raf = requestAnimationFrame(tick);
const dt = last ? Math.min(0.1, (now - last) / 1000) : step;
last = now;
acc += dt;
if (acc < step * 0.95) return;
const used = Math.min(acc, 0.1);
acc = 0;
time += used;
scene.draw(ctx, w, h, time, st, ptr, used);
};
const play = () => {
if (raf || reduced || !onScreen || document.hidden) return;
last = 0;
raf = requestAnimationFrame(tick);
};
const stop = () => {
cancelAnimationFrame(raf);
raf = 0;
};
const ro = new ResizeObserver(resize);
ro.observe(root);
const io = new IntersectionObserver((entries) => {
onScreen = entries[0].isIntersecting;
if (onScreen) play();
else stop();
});
io.observe(root);
const onVis = () => (document.hidden ? stop() : play());
const onMove = (e) => {
const r = root.getBoundingClientRect();
ptr.x = e.clientX - r.left;
ptr.y = e.clientY - r.top;
ptr.on = true;
};
const onLeave = () => {
ptr.on = false;
};
const onDown = (e) => {
onMove(e);
ptr.down += 1;
};
document.addEventListener("visibilitychange", onVis);
root.addEventListener("pointermove", onMove);
root.addEventListener("pointerleave", onLeave);
root.addEventListener("pointerdown", onDown);
resize();
play();
return () => {
stop();
ro.disconnect();
io.disconnect();
document.removeEventListener("visibilitychange", onVis);
root.removeEventListener("pointermove", onMove);
root.removeEventListener("pointerleave", onLeave);
root.removeEventListener("pointerdown", onDown);
};
}
var reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
document.querySelectorAll("[data-df]").forEach(function (root) {
if (root.getAttribute("data-df") === "on") return;
root.setAttribute("data-df", "on");
runScene(root, root.querySelector(".df-canvas"), SCENE, reduced);
});
})();
</script>
With reduced motion: The grid of dots rests almost flat and still, with no idle hand moving across it.
Running cost: One spring per dot, about 1,700 at desktop size, updated each frame and filled in three batched paths.
Soft orange wax rises, stretches and sinks through dark warm glass, merging and splitting as blobs meet, with a pool resting at the bottom and a red glow around every piece. It is a slower, warmer take on blob backgrounds, made for record shops, bars, late-night radio and retro brands.
<section class="wx-root" data-wx>
<canvas class="wx-canvas" aria-hidden="true"></canvas>
<div class="wx-inner">
<div class="wx-copy">
<p class="wx-eyebrow">Lowtide Records</p>
<h2 class="wx-title">Late-night listening room and used vinyl.</h2>
<p class="wx-body">Pull a record from the crates, take a booth and let the needle drop. Open until one on weekends.</p>
<a class="wx-button" href="#">See tonight’s sessions</a>
</div>
</div>
</section>
<style>
/* Page colours: background, text and button. The scene colours live in SETTINGS in the script below. */
.wx-root { --wx-bg: #220e0c; --wx-text: #fbeee2; --wx-muted: #e8cfc0; --wx-button: #ffb27a; --wx-button-text: #2a100c;
position: relative; width: 100%; height: 640px; overflow: hidden; background: var(--wx-bg); color: var(--wx-text); font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
.wx-canvas { position: absolute; inset: 0; display: block; width: 100%; height: 100%; pointer-events: none; }
.wx-inner { position: relative; z-index: 1; box-sizing: border-box; height: 100%; max-width: 72rem; margin: 0 auto; padding: 56px 24px; display: flex; flex-direction: column; justify-content: flex-start; align-items: flex-start; text-align: left; }
.wx-copy { box-sizing: border-box; max-width: 36rem; }
.wx-eyebrow { margin: 0; font-size: 12px; font-weight: 600; letter-spacing: .2em; text-transform: uppercase; color: #ffb27a; }
.wx-title { margin: 16px 0 0; font-size: clamp(2.25rem, 1.4rem + 3.4vw, 3.75rem); line-height: 1.05; font-weight: 600; letter-spacing: -.025em; }
.wx-body { margin: 20px 0 0; font-size: clamp(1rem, .95rem + .25vw, 1.125rem); line-height: 1.65; color: var(--wx-muted); }
.wx-button { margin-top: 32px; display: inline-flex; align-items: center; height: 48px; padding: 0 24px; border-radius: 9999px; background: var(--wx-button); color: var(--wx-button-text); font-size: 14px; font-weight: 600; text-decoration: none; }
.wx-button:hover { opacity: .9; }
.wx-button:focus-visible { outline: 2px solid var(--wx-button); outline-offset: 3px; }
@media (min-width: 640px) { .wx-inner { padding: 56px 40px; } }
/* With reduced motion the script draws one settled frame and stops (it reads prefers-reduced-motion). */
@media (prefers-reduced-motion: reduce) { .wx-button { transition: none; } }
</style>
<script>
(function () {
// The values worth changing. Colours are [red, green, blue] from 0 to 255.
const SETTINGS = {
blobs: 7, // pieces of wax in the lamp
hot: [255, 168, 92], // the bright middle of the wax
cool: [214, 72, 44], // the edge of the wax
glass: [34, 14, 12], // the liquid around it
speed: 1, // how fast the wax rises and sinks
cell: 4, // CSS pixels per computed sample; higher is cheaper and softer
};
const SCENE = {
start: 12,
still: 12,
fps: 30,
init(ctx, w, h) {
const gw = Math.max(8, Math.ceil(w / SETTINGS.cell));
const gh = Math.max(8, Math.ceil(h / SETTINGS.cell));
const off = document.createElement("canvas");
off.width = gw;
off.height = gh;
const octx = off.getContext("2d");
const narrow = w < 700;
const blobs = [];
for (let i = 0; i < SETTINGS.blobs; i++) {
blobs.push({
// On wide blocks the wax stays right of the copy; on phones it lives in the lower half.
x: narrow ? 0.15 + Math.random() * 0.7 : 0.55 + Math.random() * 0.38,
r: (narrow ? 0.07 : 0.055) + Math.random() * 0.04,
period: 22 + Math.random() * 18,
phase: Math.random() * 6.28,
sway: 0.02 + Math.random() * 0.03,
});
}
return { gw, gh, off, octx, img: octx.createImageData(gw, gh), blobs, narrow };
},
draw(ctx, w, h, time, st) {
const t = time * SETTINGS.speed;
const { gw, gh, img, blobs, narrow } = st;
const aspect = gw / gh;
const top = narrow ? 0.5 : 0.06;
// Each blob rises while warm and sinks while cool: a slow, eased up-and-down loop.
const pos = blobs.map((b) => {
const cyc = Math.sin((t / b.period) * 6.28 + b.phase);
const y = top + (1 - top) * (0.5 - 0.42 * cyc);
const x = b.x + Math.sin(t * 0.13 + b.phase) * b.sway;
return [x * aspect, y, b.r * (1 + 0.12 * Math.sin(t * 0.3 + b.phase)) * (narrow ? 1 : 1)];
});
// Pools of wax resting on the bottom and clinging to the top.
pos.push([(narrow ? 0.5 : 0.74) * aspect, 1.08, narrow ? 0.16 : 0.12]);
const data = img.data;
const [hr, hg, hb] = SETTINGS.hot;
const [cr, cg, cb] = SETTINGS.cool;
const [gr, gg, gb] = SETTINGS.glass;
let o = 0;
for (let j = 0; j < gh; j++) {
const y = j / gh;
for (let i = 0; i < gw; i++) {
const x = (i / gh);
let f = 0;
for (let k = 0; k < pos.length; k++) {
const p = pos[k];
const dx = x - p[0];
const dy = y - p[1];
f += (p[2] * p[2]) / (dx * dx + dy * dy + 0.0004);
}
// Glass: dark, with a warm glow that grows near the wax.
const glow = Math.min(1, f * 0.22);
let r = gr + 120 * glow * glow;
let g = gg + 34 * glow * glow;
let b = gb + 14 * glow * glow;
const edge = Math.min(1, Math.max(0, (f - 0.96) / 0.08));
if (edge > 0) {
const core = Math.min(1, (f - 1) / 2.2);
const m = core < 0 ? 0 : core;
const shade = 0.82 + 0.18 * (1 - y);
const wr = (cr + (hr - cr) * m) * shade;
const wg = (cg + (hg - cg) * m) * shade;
const wb = (cb + (hb - cb) * m) * shade;
r += (wr - r) * edge;
g += (wg - g) * edge;
b += (wb - b) * edge;
}
data[o] = r;
data[o + 1] = g;
data[o + 2] = b;
data[o + 3] = 255;
o += 4;
}
}
st.octx.putImageData(img, 0, 0);
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = "high";
ctx.drawImage(st.off, 0, 0, w, h);
},
};
// Runs a scene on a canvas: sizes it to the block, pauses off screen and in hidden tabs, cleans up after itself.
function runScene(root, canvas, scene, reduced) {
const ctx = canvas.getContext("2d");
if (!ctx) return () => {};
let w = 0;
let h = 0;
let st = null;
let raf = 0;
let last = 0;
let acc = 0;
let onScreen = true;
let time = reduced ? (scene.still ?? scene.start ?? 0) : (scene.start ?? 0);
const ptr = { x: 0, y: 0, on: false, down: 0 };
const step = 1 / (scene.fps || 60);
const resize = () => {
const r = root.getBoundingClientRect();
const nw = Math.max(1, Math.round(r.width));
const nh = Math.max(1, Math.round(r.height));
if (nw === w && nh === h && st) return;
w = nw;
h = nh;
// Device pixels are capped at 1.5 and scaled by the scene's own quality factor.
const d = Math.min(window.devicePixelRatio || 1, 1.5) * (scene.scale || 1);
canvas.width = Math.max(1, Math.round(w * d));
canvas.height = Math.max(1, Math.round(h * d));
ctx.setTransform(canvas.width / w, 0, 0, canvas.height / h, 0, 0);
st = scene.init(ctx, w, h, canvas.width / w);
scene.draw(ctx, w, h, time, st, ptr, 0);
};
const tick = (now) => {
raf = requestAnimationFrame(tick);
const dt = last ? Math.min(0.1, (now - last) / 1000) : step;
last = now;
acc += dt;
if (acc < step * 0.95) return;
const used = Math.min(acc, 0.1);
acc = 0;
time += used;
scene.draw(ctx, w, h, time, st, ptr, used);
};
const play = () => {
if (raf || reduced || !onScreen || document.hidden) return;
last = 0;
raf = requestAnimationFrame(tick);
};
const stop = () => {
cancelAnimationFrame(raf);
raf = 0;
};
const ro = new ResizeObserver(resize);
ro.observe(root);
const io = new IntersectionObserver((entries) => {
onScreen = entries[0].isIntersecting;
if (onScreen) play();
else stop();
});
io.observe(root);
const onVis = () => (document.hidden ? stop() : play());
const onMove = (e) => {
const r = root.getBoundingClientRect();
ptr.x = e.clientX - r.left;
ptr.y = e.clientY - r.top;
ptr.on = true;
};
const onLeave = () => {
ptr.on = false;
};
const onDown = (e) => {
onMove(e);
ptr.down += 1;
};
document.addEventListener("visibilitychange", onVis);
root.addEventListener("pointermove", onMove);
root.addEventListener("pointerleave", onLeave);
root.addEventListener("pointerdown", onDown);
resize();
play();
return () => {
stop();
ro.disconnect();
io.disconnect();
document.removeEventListener("visibilitychange", onVis);
root.removeEventListener("pointermove", onMove);
root.removeEventListener("pointerleave", onLeave);
root.removeEventListener("pointerdown", onDown);
};
}
var reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
document.querySelectorAll("[data-wx]").forEach(function (root) {
if (root.getAttribute("data-wx") === "on") return;
root.setAttribute("data-wx", "on");
runScene(root, root.querySelector(".wx-canvas"), SCENE, reduced);
});
})();
</script>
With reduced motion: The wax is frozen mid-rise as a single warm still frame.
Running cost: A coarse field of samples, one per five pixels, summed over eight blobs at 30 frames a second and scaled up.
Shafts of warm light slant down from unseen high windows into a dark green fog, brightening and dimming as if clouds pass outside, while specks of dust glint only where they drift through a beam. It gives a quiet, sacred mood to venues, concert series, perfume, spas and slow hospitality pages.
<section class="fb-root" data-fb>
<canvas class="fb-canvas" aria-hidden="true"></canvas>
<div class="fb-scrim" aria-hidden="true"></div>
<div class="fb-inner">
<div class="fb-copy">
<p class="fb-eyebrow">Saint Brannoc’s, autumn season</p>
<h2 class="fb-title">Evensong in the old nave.</h2>
<p class="fb-body">Choral evenings under the high windows, Thursdays at seven. Doors open half an hour before.</p>
<a class="fb-button" href="#">Reserve a seat</a>
</div>
</div>
</section>
<style>
/* Page colours: background, text and button. The scene colours live in SETTINGS in the script below. */
.fb-root { --fb-bg: #0b1715; --fb-text: #f4efe4; --fb-muted: #cbd3cc; --fb-button: #f1dfb8; --fb-button-text: #1b1a14;
position: relative; width: 100%; height: 640px; overflow: hidden; background: var(--fb-bg); color: var(--fb-text); font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
.fb-canvas { position: absolute; inset: 0; display: block; width: 100%; height: 100%; pointer-events: none; }
.fb-scrim { position: absolute; inset: 0; pointer-events: none; background: linear-gradient(90deg, rgba(8,17,16,0.85) 0%, rgba(8,17,16,0.55) 40%, rgba(8,17,16,0) 65%); }
.fb-inner { position: relative; z-index: 1; box-sizing: border-box; height: 100%; max-width: 72rem; margin: 0 auto; padding: 56px 24px; display: flex; flex-direction: column; justify-content: center; align-items: flex-start; text-align: left; }
.fb-copy { box-sizing: border-box; max-width: 36rem; }
.fb-eyebrow { margin: 0; font-size: 12px; font-weight: 600; letter-spacing: .2em; text-transform: uppercase; color: #f1dfb8; }
.fb-title { font-family:Georgia,'Times New Roman',serif;margin: 16px 0 0; font-size: clamp(2.25rem, 1.4rem + 3.4vw, 3.75rem); line-height: 1.05; font-weight: 600; letter-spacing: -.025em; }
.fb-body { margin: 20px 0 0; font-size: clamp(1rem, .95rem + .25vw, 1.125rem); line-height: 1.65; color: var(--fb-muted); }
.fb-button { margin-top: 32px; display: inline-flex; align-items: center; height: 48px; padding: 0 24px; border-radius: 9999px; background: var(--fb-button); color: var(--fb-button-text); font-size: 14px; font-weight: 600; text-decoration: none; }
.fb-button:hover { opacity: .9; }
.fb-button:focus-visible { outline: 2px solid var(--fb-button); outline-offset: 3px; }
@media (min-width: 640px) { .fb-inner { padding: 56px 40px; } }
/* With reduced motion the script draws one settled frame and stops (it reads prefers-reduced-motion). */
@media (prefers-reduced-motion: reduce) { .fb-button { transition: none; } }
</style>
<script>
(function () {
// The values worth changing.
const SETTINGS = {
beams: 6, // shafts of light from the high windows
light: [255, 236, 196], // colour of the light as [red, green, blue]
fog: [120, 160, 150], // colour of the fog banks
tilt: 0.32, // lean of the shafts in radians; 0 falls straight down
motes: 150, // specks of dust that glint inside the light
};
function beamSprite() {
// One soft shaft, narrow at the window and wider at the floor, fading as it falls.
const sw = 96;
const sh = 256;
const c = document.createElement("canvas");
c.width = sw;
c.height = sh;
const g = c.getContext("2d");
const img = g.createImageData(sw, sh);
const [r, gr, b] = SETTINGS.light;
for (let y = 0; y < sh; y++) {
const v = y / sh;
const width = 0.16 + v * 0.3;
const fall = Math.pow(1 - v, 1.4) * Math.min(1, v * 12);
for (let x = 0; x < sw; x++) {
const u = (x / sw - 0.5) / width;
const a = Math.exp(-u * u * 3) * fall;
const o = (y * sw + x) * 4;
img.data[o] = r;
img.data[o + 1] = gr;
img.data[o + 2] = b;
img.data[o + 3] = a * 255;
}
}
g.putImageData(img, 0, 0);
return c;
}
function fogSprite() {
const c = document.createElement("canvas");
c.width = c.height = 128;
const g = c.getContext("2d");
const [r, gr, b] = SETTINGS.fog;
const grad = g.createRadialGradient(64, 64, 0, 64, 64, 64);
grad.addColorStop(0, `rgba(${r},${gr},${b},0.55)`);
grad.addColorStop(0.5, `rgba(${r},${gr},${b},0.22)`);
grad.addColorStop(1, `rgba(${r},${gr},${b},0)`);
g.fillStyle = grad;
g.fillRect(0, 0, 128, 128);
return c;
}
const SCENE = {
start: 6,
still: 6,
scale: 0.7,
init(ctx, w, h) {
const rand = (a, b) => a + Math.random() * (b - a);
const beams = [];
for (let i = 0; i < SETTINGS.beams; i++) {
beams.push({ x: (0.3 + (i / SETTINGS.beams) * 0.85) * w + rand(-30, 30), width: rand(120, 230), strength: rand(0.35, 0.75), phase: rand(0, 6.28), rate: rand(0.15, 0.35) });
}
const fogs = [];
for (let i = 0; i < 16; i++) fogs.push({ x: rand(-0.2, 1.2) * w, y: rand(0.25, 1.05) * h, s: rand(0.35, 0.8) * Math.max(w, h), v: rand(4, 14), a: rand(0.25, 0.6) });
const motes = [];
for (let i = 0; i < SETTINGS.motes; i++) motes.push({ x: rand(0, w), y: rand(0, h), v: rand(2, 9), p: rand(0, 6.28), s: rand(0.6, 1.6) });
return { beam: beamSprite(), fog: fogSprite(), beams, fogs, motes, last: 0 };
},
draw(ctx, w, h, t, st, ptr, dt) {
ctx.globalCompositeOperation = "source-over";
const bg = ctx.createLinearGradient(0, 0, 0, h);
bg.addColorStop(0, "#0d1b19");
bg.addColorStop(1, "#081110");
ctx.fillStyle = bg;
ctx.fillRect(0, 0, w, h);
// Fog banks drift slowly sideways and wrap around.
for (const f of st.fogs) {
const x = ((f.x + t * f.v + f.s) % (w + f.s * 2)) - f.s;
ctx.globalAlpha = f.a;
ctx.drawImage(st.fog, x - f.s / 2, f.y - f.s * 0.25, f.s, f.s * 0.5);
}
// Shafts of light, added on top, breathing as unseen clouds pass the windows.
ctx.globalCompositeOperation = "lighter";
const len = h * 1.5;
for (const b of st.beams) {
const breathe = 0.65 + 0.35 * Math.sin(t * b.rate + b.phase) * Math.sin(t * b.rate * 0.37 + b.phase * 2);
ctx.globalAlpha = b.strength * breathe;
ctx.save();
ctx.translate(b.x + Math.sin(t * 0.05 + b.phase) * 12, -h * 0.08);
ctx.rotate(SETTINGS.tilt);
ctx.drawImage(st.beam, -b.width, 0, b.width * 2, len);
ctx.restore();
}
// Dust: bright only where it floats inside a shaft.
const [r, g, bl] = SETTINGS.light;
const sin = Math.sin(SETTINGS.tilt);
const cos = Math.cos(SETTINGS.tilt);
for (const m of st.motes) {
const y = (m.y + t * m.v) % h;
const x = m.x + Math.sin(t * 0.4 + m.p) * 10;
let lit = 0;
for (const b of st.beams) {
const ox = x - b.x;
const oy = y + h * 0.08;
const across = ox * cos - oy * sin;
const along = ox * sin + oy * cos;
const half = b.width * (0.16 + (along / len) * 0.3);
lit = Math.max(lit, Math.exp(-(across * across) / (half * half * 0.5)) * b.strength);
}
if (lit < 0.05) continue;
ctx.globalAlpha = Math.min(1, lit * (0.6 + 0.4 * Math.sin(t * 2 + m.p)));
ctx.fillStyle = `rgb(${r},${g},${bl})`;
ctx.fillRect(x, y, m.s, m.s);
}
ctx.globalAlpha = 1;
ctx.globalCompositeOperation = "source-over";
},
};
// Runs a scene on a canvas: sizes it to the block, pauses off screen and in hidden tabs, cleans up after itself.
function runScene(root, canvas, scene, reduced) {
const ctx = canvas.getContext("2d");
if (!ctx) return () => {};
let w = 0;
let h = 0;
let st = null;
let raf = 0;
let last = 0;
let acc = 0;
let onScreen = true;
let time = reduced ? (scene.still ?? scene.start ?? 0) : (scene.start ?? 0);
const ptr = { x: 0, y: 0, on: false, down: 0 };
const step = 1 / (scene.fps || 60);
const resize = () => {
const r = root.getBoundingClientRect();
const nw = Math.max(1, Math.round(r.width));
const nh = Math.max(1, Math.round(r.height));
if (nw === w && nh === h && st) return;
w = nw;
h = nh;
// Device pixels are capped at 1.5 and scaled by the scene's own quality factor.
const d = Math.min(window.devicePixelRatio || 1, 1.5) * (scene.scale || 1);
canvas.width = Math.max(1, Math.round(w * d));
canvas.height = Math.max(1, Math.round(h * d));
ctx.setTransform(canvas.width / w, 0, 0, canvas.height / h, 0, 0);
st = scene.init(ctx, w, h, canvas.width / w);
scene.draw(ctx, w, h, time, st, ptr, 0);
};
const tick = (now) => {
raf = requestAnimationFrame(tick);
const dt = last ? Math.min(0.1, (now - last) / 1000) : step;
last = now;
acc += dt;
if (acc < step * 0.95) return;
const used = Math.min(acc, 0.1);
acc = 0;
time += used;
scene.draw(ctx, w, h, time, st, ptr, used);
};
const play = () => {
if (raf || reduced || !onScreen || document.hidden) return;
last = 0;
raf = requestAnimationFrame(tick);
};
const stop = () => {
cancelAnimationFrame(raf);
raf = 0;
};
const ro = new ResizeObserver(resize);
ro.observe(root);
const io = new IntersectionObserver((entries) => {
onScreen = entries[0].isIntersecting;
if (onScreen) play();
else stop();
});
io.observe(root);
const onVis = () => (document.hidden ? stop() : play());
const onMove = (e) => {
const r = root.getBoundingClientRect();
ptr.x = e.clientX - r.left;
ptr.y = e.clientY - r.top;
ptr.on = true;
};
const onLeave = () => {
ptr.on = false;
};
const onDown = (e) => {
onMove(e);
ptr.down += 1;
};
document.addEventListener("visibilitychange", onVis);
root.addEventListener("pointermove", onMove);
root.addEventListener("pointerleave", onLeave);
root.addEventListener("pointerdown", onDown);
resize();
play();
return () => {
stop();
ro.disconnect();
io.disconnect();
document.removeEventListener("visibilitychange", onVis);
root.removeEventListener("pointermove", onMove);
root.removeEventListener("pointerleave", onLeave);
root.removeEventListener("pointerdown", onDown);
};
}
var reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
document.querySelectorAll("[data-fb]").forEach(function (root) {
if (root.getAttribute("data-fb") === "on") return;
root.setAttribute("data-fb", "on");
runScene(root, root.querySelector(".fb-canvas"), SCENE, reduced);
});
})();
</script>
With reduced motion: The beams, fog and dust are painted in a single still frame.
Running cost: Six pre-drawn beam images and sixteen fog sprites, blended at reduced resolution, plus a short loop over the dust specks.
Drops land on a shallow pool and send rings outward that bend the white grout lines of the tiles below, with light catching the near side of every wave. Tap or move the pointer to add your own drops. It suits swimming schools, spas, lidos and summer campaigns that want a bright, cool hero.
<section class="rp-root" data-rp>
<canvas class="rp-canvas" aria-hidden="true"></canvas>
<div class="rp-inner">
<div class="rp-copy">
<p class="rp-eyebrow">Saltwater Lido</p>
<h2 class="rp-title">Heated outdoor pool, open from six.</h2>
<p class="rp-body">Lane swimming in the morning, family sessions after school and a sauna by the deep end.</p>
<a class="rp-button" href="#">Book a lane</a>
</div>
</div>
</section>
<style>
/* Page colours: background, text and button. The scene colours live in SETTINGS in the script below. */
.rp-root { --rp-bg: #b9e4e2; --rp-text: #0f2f36; --rp-muted: #2c4b52; --rp-button: #0f4c5c; --rp-button-text: #f2fbfa;
position: relative; width: 100%; height: 620px; overflow: hidden; background: var(--rp-bg); color: var(--rp-text); font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
.rp-canvas { position: absolute; inset: 0; display: block; width: 100%; height: 100%; pointer-events: none; }
.rp-inner { position: relative; z-index: 1; box-sizing: border-box; height: 100%; max-width: 72rem; margin: 0 auto; padding: 56px 24px; display: flex; flex-direction: column; justify-content: center; align-items: flex-start; text-align: left; }
.rp-copy { box-sizing: border-box; max-width: 36rem; border: 1px solid rgba(15,47,54,0.12); background: #f7fbfa; border-radius: 16px; padding: 28px; }
.rp-eyebrow { margin: 0; font-size: 12px; font-weight: 600; letter-spacing: .2em; text-transform: uppercase; color: #0f4c5c; }
.rp-title { margin: 16px 0 0; font-size: clamp(2.25rem, 1.4rem + 3.4vw, 3.75rem); line-height: 1.05; font-weight: 600; letter-spacing: -.025em; }
.rp-body { margin: 20px 0 0; font-size: clamp(1rem, .95rem + .25vw, 1.125rem); line-height: 1.65; color: var(--rp-muted); }
.rp-button { margin-top: 32px; display: inline-flex; align-items: center; height: 48px; padding: 0 24px; border-radius: 10px; background: var(--rp-button); color: var(--rp-button-text); font-size: 14px; font-weight: 600; text-decoration: none; }
.rp-button:hover { opacity: .9; }
.rp-button:focus-visible { outline: 2px solid var(--rp-button); outline-offset: 3px; }
@media (min-width: 640px) { .rp-inner { padding: 56px 40px; } .rp-copy { padding: 40px; } }
/* With reduced motion the script draws one settled frame and stops (it reads prefers-reduced-motion). */
@media (prefers-reduced-motion: reduce) { .rp-button { transition: none; } }
</style>
<script>
(function () {
// The values worth changing.
const SETTINGS = {
tile: 46, // tile size in pixels
water: ["#b9e4e2", "#7fc6cc"], // shallow and deep water over the tiles
grout: "rgba(255, 255, 255, 0.75)", // the lines between tiles
drops: 1.4, // drops falling each second; 0 leaves only the ones you tap
bend: 16, // how strongly the ripples bend the tile lines
calm: 0.986, // how long rings last; closer to 1 rings travel further
};
const SCENE = {
start: 0,
still: 0,
init(ctx, w, h) {
const cell = 8;
const gw = Math.ceil(w / cell) + 2;
const gh = Math.ceil(h / cell) + 2;
const off = document.createElement("canvas");
off.width = gw;
off.height = gh;
const octx = off.getContext("2d");
const st = { cell, gw, gh, a: new Float32Array(gw * gh), b: new Float32Array(gw * gh), off, octx, img: octx.createImageData(gw, gh), clock: 0, taps: 0 };
// A few drops have already landed when the block first appears.
for (let i = 0; i < 5; i++) {
SCENE.drop(st, Math.random() * w, Math.random() * h, 1);
for (let k = 0; k < 22; k++) SCENE.step(st);
}
return st;
},
drop(st, x, y, power) {
const cx = Math.round(x / st.cell) + 1;
const cy = Math.round(y / st.cell) + 1;
for (let j = -2; j <= 2; j++)
for (let i = -2; i <= 2; i++) {
const xi = cx + i;
const yj = cy + j;
if (xi < 1 || yj < 1 || xi >= st.gw - 1 || yj >= st.gh - 1) continue;
const d = Math.sqrt(i * i + j * j);
st.a[yj * st.gw + xi] -= Math.max(0, 1 - d / 2.6) * 14 * power;
}
},
step(st) {
// The classic two-buffer wave: each cell moves toward the average of its neighbours, then loses a little energy.
const { gw, gh, a, b } = st;
for (let y = 1; y < gh - 1; y++) {
for (let x = 1; x < gw - 1; x++) {
const i = y * gw + x;
b[i] = ((a[i - 1] + a[i + 1] + a[i - gw] + a[i + gw]) / 2 - b[i]) * SETTINGS.calm;
}
}
st.a = b;
st.b = a;
},
draw(ctx, w, h, t, st, ptr, dt) {
if (dt > 0) {
st.clock += dt * SETTINGS.drops;
while (st.clock > 1) {
st.clock -= Math.random() * 1.5;
SCENE.drop(st, Math.random() * w, Math.random() * h, 0.6 + Math.random() * 0.6);
}
if (ptr.down !== st.taps) {
st.taps = ptr.down;
SCENE.drop(st, ptr.x, ptr.y, 1.4);
}
if (ptr.on && Math.random() < 0.25) SCENE.drop(st, ptr.x, ptr.y, 0.25);
SCENE.step(st);
SCENE.step(st);
}
const { gw, gh, cell } = st;
const hgt = st.a;
const water = ctx.createLinearGradient(0, 0, w, h);
water.addColorStop(0, SETTINGS.water[0]);
water.addColorStop(1, SETTINGS.water[1]);
ctx.fillStyle = water;
ctx.fillRect(0, 0, w, h);
const slope = (x, y) => {
const xi = Math.min(gw - 2, Math.max(1, Math.round(x / cell) + 1));
const yi = Math.min(gh - 2, Math.max(1, Math.round(y / cell) + 1));
const i = yi * gw + xi;
return [hgt[i + 1] - hgt[i - 1], hgt[i + gw] - hgt[i - gw]];
};
// Grout lines, each point pushed sideways by the slope of the water above it.
const k = SETTINGS.bend / 10;
ctx.strokeStyle = SETTINGS.grout;
ctx.lineWidth = 2;
ctx.beginPath();
const s = SETTINGS.tile;
for (let x = (w % s) / 2; x <= w; x += s) {
for (let y = -6; y <= h + 6; y += 6) {
const g = slope(x, y);
if (y === -6) ctx.moveTo(x + g[0] * k, y + g[1] * k);
else ctx.lineTo(x + g[0] * k, y + g[1] * k);
}
}
for (let y = (h % s) / 2; y <= h; y += s) {
for (let x = -6; x <= w + 6; x += 6) {
const g = slope(x, y);
if (x === -6) ctx.moveTo(x + g[0] * k, y + g[1] * k);
else ctx.lineTo(x + g[0] * k, y + g[1] * k);
}
}
ctx.stroke();
// Light and shade on each ring: slopes facing the sun brighten, the far side darkens.
const data = st.img.data;
for (let y = 0; y < gh; y++) {
for (let x = 0; x < gw; x++) {
const i = y * gw + x;
const l = x > 0 && y > 0 && x < gw - 1 && y < gh - 1 ? hgt[i - 1] - hgt[i + 1] + hgt[i - gw] - hgt[i + gw] : 0;
const o = i * 4;
if (l > 0) {
data[o] = 255;
data[o + 1] = 255;
data[o + 2] = 250;
data[o + 3] = Math.min(150, l * 90);
} else {
data[o] = 20;
data[o + 1] = 92;
data[o + 2] = 110;
data[o + 3] = Math.min(90, -l * 60);
}
}
}
st.octx.putImageData(st.img, 0, 0);
ctx.imageSmoothingEnabled = true;
ctx.drawImage(st.off, -cell, -cell, gw * cell, gh * cell);
},
};
// Runs a scene on a canvas: sizes it to the block, pauses off screen and in hidden tabs, cleans up after itself.
function runScene(root, canvas, scene, reduced) {
const ctx = canvas.getContext("2d");
if (!ctx) return () => {};
let w = 0;
let h = 0;
let st = null;
let raf = 0;
let last = 0;
let acc = 0;
let onScreen = true;
let time = reduced ? (scene.still ?? scene.start ?? 0) : (scene.start ?? 0);
const ptr = { x: 0, y: 0, on: false, down: 0 };
const step = 1 / (scene.fps || 60);
const resize = () => {
const r = root.getBoundingClientRect();
const nw = Math.max(1, Math.round(r.width));
const nh = Math.max(1, Math.round(r.height));
if (nw === w && nh === h && st) return;
w = nw;
h = nh;
// Device pixels are capped at 1.5 and scaled by the scene's own quality factor.
const d = Math.min(window.devicePixelRatio || 1, 1.5) * (scene.scale || 1);
canvas.width = Math.max(1, Math.round(w * d));
canvas.height = Math.max(1, Math.round(h * d));
ctx.setTransform(canvas.width / w, 0, 0, canvas.height / h, 0, 0);
st = scene.init(ctx, w, h, canvas.width / w);
scene.draw(ctx, w, h, time, st, ptr, 0);
};
const tick = (now) => {
raf = requestAnimationFrame(tick);
const dt = last ? Math.min(0.1, (now - last) / 1000) : step;
last = now;
acc += dt;
if (acc < step * 0.95) return;
const used = Math.min(acc, 0.1);
acc = 0;
time += used;
scene.draw(ctx, w, h, time, st, ptr, used);
};
const play = () => {
if (raf || reduced || !onScreen || document.hidden) return;
last = 0;
raf = requestAnimationFrame(tick);
};
const stop = () => {
cancelAnimationFrame(raf);
raf = 0;
};
const ro = new ResizeObserver(resize);
ro.observe(root);
const io = new IntersectionObserver((entries) => {
onScreen = entries[0].isIntersecting;
if (onScreen) play();
else stop();
});
io.observe(root);
const onVis = () => (document.hidden ? stop() : play());
const onMove = (e) => {
const r = root.getBoundingClientRect();
ptr.x = e.clientX - r.left;
ptr.y = e.clientY - r.top;
ptr.on = true;
};
const onLeave = () => {
ptr.on = false;
};
const onDown = (e) => {
onMove(e);
ptr.down += 1;
};
document.addEventListener("visibilitychange", onVis);
root.addEventListener("pointermove", onMove);
root.addEventListener("pointerleave", onLeave);
root.addEventListener("pointerdown", onDown);
resize();
play();
return () => {
stop();
ro.disconnect();
io.disconnect();
document.removeEventListener("visibilitychange", onVis);
root.removeEventListener("pointermove", onMove);
root.removeEventListener("pointerleave", onLeave);
root.removeEventListener("pointerdown", onDown);
};
}
var reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
document.querySelectorAll("[data-rp]").forEach(function (root) {
if (root.getAttribute("data-rp") === "on") return;
root.setAttribute("data-rp", "on");
runScene(root, root.querySelector(".rp-canvas"), SCENE, reduced);
});
})();
</script>
With reduced motion: A few rings are frozen on the water, already bending the tile lines.
Running cost: A wave grid with one cell per eight pixels updated twice a frame, then tile lines and a scaled shading layer on canvas 2D.
You look out through a wet window at a street full of out-of-focus lamps. Beads of water cling to the glass, and larger drops break loose, run in bursts and leave a trail of tiny droplets. Each running drop is a little lens that shows the lights upside down and sharp. Made for bars, late shows, night radio and moody fiction.
<section class="rg-root" data-rg>
<canvas class="rg-canvas" aria-hidden="true"></canvas>
<div class="rg-scrim" aria-hidden="true"></div>
<div class="rg-inner">
<div class="rg-copy">
<p class="rg-eyebrow">The Lamplight, Harbour Street</p>
<h2 class="rg-title">Rainy night? Your window seat is waiting.</h2>
<p class="rg-body">Small plates, a long list of bitter aperitifs and a pianist from nine. Walk in, no booking for two.</p>
<a class="rg-button" href="#">See the menu</a>
</div>
</div>
</section>
<style>
/* Page colours: background, text and button. The scene colours live in SETTINGS in the script below. */
.rg-root { --rg-bg: #0a0f1c; --rg-text: #f5f1ea; --rg-muted: #cdd2dc; --rg-button: #ffcf8a; --rg-button-text: #1a140c;
position: relative; width: 100%; height: 640px; overflow: hidden; background: var(--rg-bg); color: var(--rg-text); font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
.rg-canvas { position: absolute; inset: 0; display: block; width: 100%; height: 100%; pointer-events: none; }
.rg-scrim { position: absolute; inset: 0; pointer-events: none; background: linear-gradient(90deg, rgba(8,12,22,0.82) 0%, rgba(8,12,22,0.5) 45%, rgba(8,12,22,0) 72%); }
.rg-inner { position: relative; z-index: 1; box-sizing: border-box; height: 100%; max-width: 72rem; margin: 0 auto; padding: 56px 24px; display: flex; flex-direction: column; justify-content: center; align-items: flex-start; text-align: left; }
.rg-copy { box-sizing: border-box; max-width: 36rem; }
.rg-eyebrow { margin: 0; font-size: 12px; font-weight: 600; letter-spacing: .2em; text-transform: uppercase; color: #ffcf8a; }
.rg-title { margin: 16px 0 0; font-size: clamp(2.25rem, 1.4rem + 3.4vw, 3.75rem); line-height: 1.05; font-weight: 600; letter-spacing: -.025em; }
.rg-body { margin: 20px 0 0; font-size: clamp(1rem, .95rem + .25vw, 1.125rem); line-height: 1.65; color: var(--rg-muted); }
.rg-button { margin-top: 32px; display: inline-flex; align-items: center; height: 48px; padding: 0 24px; border-radius: 9999px; background: var(--rg-button); color: var(--rg-button-text); font-size: 14px; font-weight: 600; text-decoration: none; }
.rg-button:hover { opacity: .9; }
.rg-button:focus-visible { outline: 2px solid var(--rg-button); outline-offset: 3px; }
@media (min-width: 640px) { .rg-inner { padding: 56px 40px; } }
/* With reduced motion the script draws one settled frame and stops (it reads prefers-reduced-motion). */
@media (prefers-reduced-motion: reduce) { .rg-button { transition: none; } }
</style>
<script>
(function () {
// The values worth changing.
const SETTINGS = {
night: ["#0a0f1c", "#141b2d"], // the dark street behind the glass, top to bottom
lights: ["255,176,92", "255,214,150", "120,200,210", "232,96,80", "190,170,255"], // blurred street lights as "red,green,blue"
runners: 60, // big drops on the pane; most cling, a few run at a time
beads: 520, // small drops stuck to the glass
rain: 1, // how often big drops break loose and run
};
function streetSprite(w, h, k) {
// The view through the glass: out-of-focus lamps, shop signs and car lights on a dark street.
const c = document.createElement("canvas");
c.width = Math.max(1, Math.round(w * k));
c.height = Math.max(1, Math.round(h * k));
const g = c.getContext("2d");
g.scale(k, k);
const bg = g.createLinearGradient(0, 0, 0, h);
bg.addColorStop(0, SETTINGS.night[0]);
bg.addColorStop(1, SETTINGS.night[1]);
g.fillStyle = bg;
g.fillRect(0, 0, w, h);
let s = 7;
const rnd = () => {
s = (s * 16807) % 2147483647;
return s / 2147483647;
};
for (let i = 0; i < 46; i++) {
const col = SETTINGS.lights[Math.floor(rnd() * SETTINGS.lights.length)];
const x = (0.25 + rnd() * 0.8) * w;
const y = (0.3 + rnd() * 0.55) * h;
const r = 18 + rnd() * 46;
const a = 0.1 + rnd() * 0.22;
const grad = g.createRadialGradient(x, y, 0, x, y, r);
grad.addColorStop(0, `rgba(${col},${a})`);
grad.addColorStop(0.75, `rgba(${col},${a * 0.8})`);
grad.addColorStop(1, `rgba(${col},0)`);
g.fillStyle = grad;
g.fillRect(x - r, y - r, r * 2, r * 2);
}
return c;
}
const SCENE = {
start: 0,
still: 0,
scale: 0.85,
init(ctx, w, h, k) {
const rand = (a, b) => a + Math.random() * (b - a);
const blur = streetSprite(w, h, k);
// Beads: small drops painted once onto their own layer.
const beads = document.createElement("canvas");
beads.width = blur.width;
beads.height = blur.height;
const b = beads.getContext("2d");
b.scale(k, k);
for (let i = 0; i < SETTINGS.beads; i++) {
const x = rand(0, w);
const y = rand(0, h);
const r = rand(0.8, 2.8);
b.fillStyle = "rgba(0,0,0,0.22)";
b.beginPath();
b.arc(x, y - r * 0.2, r, 0, 6.29);
b.fill();
b.fillStyle = "rgba(255,226,190,0.22)";
b.beginPath();
b.arc(x, y + r * 0.25, r * 0.8, 0, 6.29);
b.fill();
b.fillStyle = "rgba(255,240,220,0.45)";
b.beginPath();
b.arc(x - r * 0.3, y - r * 0.3, r * 0.45, 0, 6.29);
b.fill();
}
const runners = [];
for (let i = 0; i < SETTINGS.runners; i++) runners.push({ x: rand(0, w), y: rand(0, h), r: rand(3, 9), v: 0, wait: i < 8 ? rand(0, 1.5) : rand(3, 24) });
return { blur, beads, runners, trail: [], k, rand };
},
draw(ctx, w, h, t, st, ptr, dt) {
ctx.drawImage(st.blur, 0, 0, w, h);
ctx.drawImage(st.beads, 0, 0, w, h);
// Trails of tiny drops left behind by the runners, slowly evaporating.
for (const d of st.trail) {
d.life -= dt * 0.12;
ctx.fillStyle = `rgba(255,232,200,${0.35 * Math.max(0, d.life)})`;
ctx.fillRect(d.x, d.y, d.r, d.r);
}
st.trail = st.trail.filter((d) => d.life > 0);
const bw = st.blur.width / w;
for (const d of st.runners) {
// Drops cling, then break loose and run in bursts, wandering a little sideways.
if (dt > 0) {
if (d.wait > 0) d.wait -= dt * SETTINGS.rain;
else {
d.v = Math.min(150, d.v + dt * 260 * (0.5 + Math.random()));
if (Math.random() < dt * 1.2) d.v *= 0.2;
d.y += d.v * dt * (d.r / 6);
d.x += (Math.random() - 0.5) * 0.8;
if (Math.random() < dt * 20) st.trail.push({ x: d.x + (Math.random() - 0.5) * d.r, y: d.y - d.r * 1.5, r: 0.8 + Math.random() * 1.4, life: 1 });
if (d.y - d.r * 2 > h) {
// A new drop gathers somewhere else on the pane and clings for a while.
d.y = st.rand(0, h * 0.7);
d.x = st.rand(0, w);
d.r = st.rand(3, 9);
d.v = 0;
d.wait = st.rand(2, 14);
}
}
}
// Each drop is a tiny lens: it gathers a wide patch of the street, upside down and brighter.
const rx = d.r;
const ry = d.r * 1.12;
ctx.save();
ctx.beginPath();
ctx.ellipse(d.x, d.y, rx, ry, 0, 0, 6.29);
ctx.clip();
const view = d.r * 14;
ctx.translate(d.x, d.y);
ctx.scale(-1, -1);
ctx.drawImage(st.blur, (d.x - view) * bw, (d.y - view * 0.6) * bw, view * 2 * bw, view * 1.2 * bw, -rx, -ry, rx * 2, ry * 2);
ctx.globalCompositeOperation = "lighter";
ctx.globalAlpha = 0.6;
ctx.drawImage(st.blur, (d.x - view) * bw, (d.y - view * 0.6) * bw, view * 2 * bw, view * 1.2 * bw, -rx, -ry, rx * 2, ry * 2);
ctx.globalAlpha = 1;
ctx.globalCompositeOperation = "source-over";
ctx.restore();
// Light pools along the lower rim and a dark edge sits along the top, as in a real bead of water.
const rim = ctx.createLinearGradient(0, d.y - ry, 0, d.y + ry);
rim.addColorStop(0, "rgba(0,0,0,0.5)");
rim.addColorStop(0.45, "rgba(0,0,0,0)");
rim.addColorStop(0.8, "rgba(255,236,210,0.08)");
rim.addColorStop(1, "rgba(255,236,210,0.45)");
ctx.strokeStyle = rim;
ctx.lineWidth = Math.max(0.8, d.r * 0.22);
ctx.beginPath();
ctx.ellipse(d.x, d.y, rx * 0.92, ry * 0.92, 0, 0, 6.29);
ctx.stroke();
ctx.fillStyle = "rgba(255,248,236,0.85)";
ctx.beginPath();
ctx.ellipse(d.x - rx * 0.32, d.y - ry * 0.45, Math.max(0.6, rx * 0.2), Math.max(0.4, ry * 0.12), -0.5, 0, 6.29);
ctx.fill();
}
},
};
// Runs a scene on a canvas: sizes it to the block, pauses off screen and in hidden tabs, cleans up after itself.
function runScene(root, canvas, scene, reduced) {
const ctx = canvas.getContext("2d");
if (!ctx) return () => {};
let w = 0;
let h = 0;
let st = null;
let raf = 0;
let last = 0;
let acc = 0;
let onScreen = true;
let time = reduced ? (scene.still ?? scene.start ?? 0) : (scene.start ?? 0);
const ptr = { x: 0, y: 0, on: false, down: 0 };
const step = 1 / (scene.fps || 60);
const resize = () => {
const r = root.getBoundingClientRect();
const nw = Math.max(1, Math.round(r.width));
const nh = Math.max(1, Math.round(r.height));
if (nw === w && nh === h && st) return;
w = nw;
h = nh;
// Device pixels are capped at 1.5 and scaled by the scene's own quality factor.
const d = Math.min(window.devicePixelRatio || 1, 1.5) * (scene.scale || 1);
canvas.width = Math.max(1, Math.round(w * d));
canvas.height = Math.max(1, Math.round(h * d));
ctx.setTransform(canvas.width / w, 0, 0, canvas.height / h, 0, 0);
st = scene.init(ctx, w, h, canvas.width / w);
scene.draw(ctx, w, h, time, st, ptr, 0);
};
const tick = (now) => {
raf = requestAnimationFrame(tick);
const dt = last ? Math.min(0.1, (now - last) / 1000) : step;
last = now;
acc += dt;
if (acc < step * 0.95) return;
const used = Math.min(acc, 0.1);
acc = 0;
time += used;
scene.draw(ctx, w, h, time, st, ptr, used);
};
const play = () => {
if (raf || reduced || !onScreen || document.hidden) return;
last = 0;
raf = requestAnimationFrame(tick);
};
const stop = () => {
cancelAnimationFrame(raf);
raf = 0;
};
const ro = new ResizeObserver(resize);
ro.observe(root);
const io = new IntersectionObserver((entries) => {
onScreen = entries[0].isIntersecting;
if (onScreen) play();
else stop();
});
io.observe(root);
const onVis = () => (document.hidden ? stop() : play());
const onMove = (e) => {
const r = root.getBoundingClientRect();
ptr.x = e.clientX - r.left;
ptr.y = e.clientY - r.top;
ptr.on = true;
};
const onLeave = () => {
ptr.on = false;
};
const onDown = (e) => {
onMove(e);
ptr.down += 1;
};
document.addEventListener("visibilitychange", onVis);
root.addEventListener("pointermove", onMove);
root.addEventListener("pointerleave", onLeave);
root.addEventListener("pointerdown", onDown);
resize();
play();
return () => {
stop();
ro.disconnect();
io.disconnect();
document.removeEventListener("visibilitychange", onVis);
root.removeEventListener("pointermove", onMove);
root.removeEventListener("pointerleave", onLeave);
root.removeEventListener("pointerdown", onDown);
};
}
var reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
document.querySelectorAll("[data-rg]").forEach(function (root) {
if (root.getAttribute("data-rg") === "on") return;
root.setAttribute("data-rg", "on");
runScene(root, root.querySelector(".rg-canvas"), SCENE, reduced);
});
})();
</script>
With reduced motion: A still pane of glass with clinging drops in front of the blurred street lights.
Running cost: Three images built once at load; each frame copies them and clips about 26 small drops, which is light work on canvas 2D.
A banded gas giant hangs in the lower right of a dark sky, wrapped in a tilted ring of dust grains that orbit at their own speeds, inner grains faster than outer ones. The ring passes behind the planet and in front of it, with gaps between the bands. It suits observatories, science courses, astronomy clubs and space-themed launches.
<section class="pr-root" data-pr>
<canvas class="pr-canvas" aria-hidden="true"></canvas>
<div class="pr-inner">
<div class="pr-copy">
<p class="pr-eyebrow">Perihelion Observatory</p>
<h2 class="pr-title">Public nights under a dark sky.</h2>
<p class="pr-body">Look through the big telescope on the ridge every clear Friday, with an astronomer at the eyepiece.</p>
<a class="pr-button" href="#">Book a viewing night</a>
</div>
</div>
</section>
<style>
/* Page colours: background, text and button. The scene colours live in SETTINGS in the script below. */
.pr-root { --pr-bg: #05070d; --pr-text: #f1f2f6; --pr-muted: #c4c9d6; --pr-button: #ead9bc; --pr-button-text: #14100a;
position: relative; width: 100%; height: 660px; overflow: hidden; background: var(--pr-bg); color: var(--pr-text); font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
.pr-canvas { position: absolute; inset: 0; display: block; width: 100%; height: 100%; pointer-events: none; }
.pr-inner { position: relative; z-index: 1; box-sizing: border-box; height: 100%; max-width: 72rem; margin: 0 auto; padding: 56px 24px; display: flex; flex-direction: column; justify-content: flex-start; align-items: flex-start; text-align: left; }
.pr-copy { box-sizing: border-box; max-width: 36rem; }
.pr-eyebrow { margin: 0; font-size: 12px; font-weight: 600; letter-spacing: .2em; text-transform: uppercase; color: #ead9bc; }
.pr-title { margin: 16px 0 0; font-size: clamp(2.25rem, 1.4rem + 3.4vw, 3.75rem); line-height: 1.05; font-weight: 600; letter-spacing: -.025em; }
.pr-body { margin: 20px 0 0; font-size: clamp(1rem, .95rem + .25vw, 1.125rem); line-height: 1.65; color: var(--pr-muted); }
.pr-button { margin-top: 32px; display: inline-flex; align-items: center; height: 48px; padding: 0 24px; border-radius: 6px; background: var(--pr-button); color: var(--pr-button-text); font-size: 14px; font-weight: 600; text-decoration: none; }
.pr-button:hover { opacity: .9; }
.pr-button:focus-visible { outline: 2px solid var(--pr-button); outline-offset: 3px; }
@media (min-width: 640px) { .pr-inner { padding: 56px 40px; } }
/* With reduced motion the script draws one settled frame and stops (it reads prefers-reduced-motion). */
@media (prefers-reduced-motion: reduce) { .pr-button { transition: none; } }
</style>
<script>
(function () {
// The values worth changing.
const SETTINGS = {
dust: [236, 220, 194], // ring dust colour as [red, green, blue]
planet: ["#e3c79c", "#9c7451", "#2a1c14"], // lit side, middle, night side
grains: 3200, // dust grains in the ring; fewer is cheaper
tilt: -0.22, // how much the ring leans, in radians
speed: 1, // how fast the ring turns
};
const SCENE = {
start: 0,
still: 0,
init(ctx, w, h) {
const narrow = w < 700;
const R = narrow ? w * 0.3 : Math.min(w * 0.15, h * 0.3);
const cx = narrow ? w * 0.6 : w * 0.72;
const cy = narrow ? h * 0.8 : h * 0.56;
// Bands of dust with gaps between them, like real ring systems.
const bands = [[1.38, 1.55, 0.5], [1.58, 1.86, 1], [1.93, 2.12, 0.75], [2.16, 2.24, 0.35]];
const grains = [];
for (let i = 0; i < SETTINGS.grains; i++) {
const b = bands[Math.floor(Math.random() * bands.length)];
const r = b[0] + Math.random() * (b[1] - b[0]);
grains.push({ r, a: Math.random() * 6.2832, s: Math.random() < 0.08 ? 1.6 : 1, l: b[2] * (0.35 + Math.random() * 0.65) });
}
const stars = [];
for (let i = 0; i < 160; i++) stars.push({ x: Math.random() * w, y: Math.random() * h, a: 0.15 + Math.random() * 0.6, s: Math.random() < 0.1 ? 1.5 : 1 });
return { R, cx, cy, bands, grains, stars };
},
ring(ctx, st, t, front) {
const { R, cx, cy } = st;
const flat = 0.24;
const [dr, dg, db] = SETTINGS.dust;
ctx.save();
ctx.translate(cx, cy);
ctx.rotate(SETTINGS.tilt);
// A faint continuous sheen under the grains gives each band body.
for (const b of st.bands) {
ctx.strokeStyle = `rgba(${dr},${dg},${db},${(front ? 0.3 : 0.2) * b[2]})`;
ctx.lineWidth = (b[1] - b[0]) * R * flat * 1.15;
ctx.beginPath();
ctx.ellipse(0, 0, ((b[0] + b[1]) / 2) * R, ((b[0] + b[1]) / 2) * R * flat, 0, front ? 0 : Math.PI, front ? Math.PI : Math.PI * 2);
ctx.stroke();
}
ctx.fillStyle = `rgb(${dr},${dg},${db})`;
for (const g of st.grains) {
// Inner dust orbits faster than outer dust.
const a = g.a + (t * SETTINGS.speed * 0.12) / Math.pow(g.r, 1.5);
const sa = Math.sin(a);
if (front ? sa < 0 : sa >= 0) continue;
const x = Math.cos(a) * g.r * R;
const y = sa * g.r * R * flat;
// Dust on the far right is lit by the sun; dust in the planet's shadow goes dark.
const shadow = !front && Math.abs(x) < R * 1.02 ? 0.25 : 1;
ctx.globalAlpha = Math.min(1, g.l * shadow * (0.7 + 0.5 * Math.cos(a - 0.6)));
ctx.fillRect(x, y, g.s, g.s);
}
ctx.globalAlpha = 1;
ctx.restore();
},
draw(ctx, w, h, t, st) {
ctx.fillStyle = "#05070d";
ctx.fillRect(0, 0, w, h);
for (const s of st.stars) {
ctx.globalAlpha = s.a;
ctx.fillStyle = "#e8ecf5";
ctx.fillRect(s.x, s.y, s.s, s.s);
}
ctx.globalAlpha = 1;
SCENE.ring(ctx, st, t, false);
const { R, cx, cy } = st;
// The planet: lit from the upper left, with soft cloud belts and a dark limb.
ctx.save();
ctx.beginPath();
ctx.arc(cx, cy, R, 0, 6.2832);
ctx.clip();
const body = ctx.createRadialGradient(cx - R * 0.45, cy - R * 0.5, R * 0.1, cx, cy, R * 1.15);
body.addColorStop(0, SETTINGS.planet[0]);
body.addColorStop(0.55, SETTINGS.planet[1]);
body.addColorStop(1, SETTINGS.planet[2]);
ctx.fillStyle = body;
ctx.fillRect(cx - R, cy - R, R * 2, R * 2);
ctx.translate(cx, cy);
ctx.rotate(SETTINGS.tilt);
for (let i = -9; i <= 9; i++) {
const y = (i / 9) * R + Math.sin(i * 1.7 + t * 0.02) * 3;
ctx.fillStyle = i % 2 ? "rgba(60,36,20,0.1)" : "rgba(255,236,200,0.06)";
ctx.fillRect(-R * 1.2, y, R * 2.4, R * (0.05 + 0.04 * Math.abs(Math.sin(i * 2.3))));
}
ctx.rotate(-SETTINGS.tilt);
// Night side: the sun is up and to the left, so the lower right falls into shadow.
const night = ctx.createRadialGradient(-R * 0.55, -R * 0.6, R * 0.6, -R * 0.35, -R * 0.4, R * 2.1);
night.addColorStop(0, "rgba(5,7,13,0)");
night.addColorStop(0.55, "rgba(5,7,13,0.55)");
night.addColorStop(1, "rgba(5,7,13,0.97)");
ctx.fillStyle = night;
ctx.fillRect(-R, -R, R * 2, R * 2);
ctx.restore();
// A thin bright crescent on the lit edge.
ctx.strokeStyle = "rgba(255,240,215,0.35)";
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.arc(cx, cy, R - 0.75, Math.PI * 0.95, Math.PI * 1.6);
ctx.stroke();
SCENE.ring(ctx, st, t, true);
},
};
// Runs a scene on a canvas: sizes it to the block, pauses off screen and in hidden tabs, cleans up after itself.
function runScene(root, canvas, scene, reduced) {
const ctx = canvas.getContext("2d");
if (!ctx) return () => {};
let w = 0;
let h = 0;
let st = null;
let raf = 0;
let last = 0;
let acc = 0;
let onScreen = true;
let time = reduced ? (scene.still ?? scene.start ?? 0) : (scene.start ?? 0);
const ptr = { x: 0, y: 0, on: false, down: 0 };
const step = 1 / (scene.fps || 60);
const resize = () => {
const r = root.getBoundingClientRect();
const nw = Math.max(1, Math.round(r.width));
const nh = Math.max(1, Math.round(r.height));
if (nw === w && nh === h && st) return;
w = nw;
h = nh;
// Device pixels are capped at 1.5 and scaled by the scene's own quality factor.
const d = Math.min(window.devicePixelRatio || 1, 1.5) * (scene.scale || 1);
canvas.width = Math.max(1, Math.round(w * d));
canvas.height = Math.max(1, Math.round(h * d));
ctx.setTransform(canvas.width / w, 0, 0, canvas.height / h, 0, 0);
st = scene.init(ctx, w, h, canvas.width / w);
scene.draw(ctx, w, h, time, st, ptr, 0);
};
const tick = (now) => {
raf = requestAnimationFrame(tick);
const dt = last ? Math.min(0.1, (now - last) / 1000) : step;
last = now;
acc += dt;
if (acc < step * 0.95) return;
const used = Math.min(acc, 0.1);
acc = 0;
time += used;
scene.draw(ctx, w, h, time, st, ptr, used);
};
const play = () => {
if (raf || reduced || !onScreen || document.hidden) return;
last = 0;
raf = requestAnimationFrame(tick);
};
const stop = () => {
cancelAnimationFrame(raf);
raf = 0;
};
const ro = new ResizeObserver(resize);
ro.observe(root);
const io = new IntersectionObserver((entries) => {
onScreen = entries[0].isIntersecting;
if (onScreen) play();
else stop();
});
io.observe(root);
const onVis = () => (document.hidden ? stop() : play());
const onMove = (e) => {
const r = root.getBoundingClientRect();
ptr.x = e.clientX - r.left;
ptr.y = e.clientY - r.top;
ptr.on = true;
};
const onLeave = () => {
ptr.on = false;
};
const onDown = (e) => {
onMove(e);
ptr.down += 1;
};
document.addEventListener("visibilitychange", onVis);
root.addEventListener("pointermove", onMove);
root.addEventListener("pointerleave", onLeave);
root.addEventListener("pointerdown", onDown);
resize();
play();
return () => {
stop();
ro.disconnect();
io.disconnect();
document.removeEventListener("visibilitychange", onVis);
root.removeEventListener("pointermove", onMove);
root.removeEventListener("pointerleave", onLeave);
root.removeEventListener("pointerdown", onDown);
};
}
var reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
document.querySelectorAll("[data-pr]").forEach(function (root) {
if (root.getAttribute("data-pr") === "on") return;
root.setAttribute("data-pr", "on");
runScene(root, root.querySelector(".pr-canvas"), SCENE, reduced);
});
})();
</script>
With reduced motion: The planet and its ring are shown once with every grain held in place.
Running cost: About 3,200 one-pixel grains drawn each frame plus a few gradient fills; light for canvas 2D.
A single incense stick smoulders on charcoal and sends up a smooth thread of smoke that breaks into slow curls as it climbs and fades. The ember glows at the tip. It brings calm and a little ritual to tea rooms, candle and fragrance brands, yoga studios and meditation apps.
<section class="sm-root" data-sm>
<canvas class="sm-canvas" aria-hidden="true"></canvas>
<div class="sm-inner">
<div class="sm-copy">
<p class="sm-eyebrow">Juniper Hall Incense Works</p>
<h2 class="sm-title">Cedar and hinoki, rolled by hand.</h2>
<p class="sm-body">Slow-burning sticks made in small batches, with no added perfume and nothing that catches in the throat.</p>
<a class="sm-button" href="#">Shop the sampler</a>
</div>
</div>
</section>
<style>
/* Page colours: background, text and button. The scene colours live in SETTINGS in the script below. */
.sm-root { --sm-bg: #141110; --sm-text: #f3ede6; --sm-muted: #cfc6bc; --sm-button: #e9dccb; --sm-button-text: #1c1612;
position: relative; width: 100%; height: 640px; overflow: hidden; background: var(--sm-bg); color: var(--sm-text); font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
.sm-canvas { position: absolute; inset: 0; display: block; width: 100%; height: 100%; pointer-events: none; }
.sm-inner { position: relative; z-index: 1; box-sizing: border-box; height: 100%; max-width: 72rem; margin: 0 auto; padding: 56px 24px; display: flex; flex-direction: column; justify-content: center; align-items: flex-start; text-align: left; }
.sm-copy { box-sizing: border-box; max-width: 36rem; }
.sm-eyebrow { margin: 0; font-size: 12px; font-weight: 600; letter-spacing: .2em; text-transform: uppercase; color: #e7b98c; }
.sm-title { font-family:Georgia,'Times New Roman',serif;margin: 16px 0 0; font-size: clamp(2.25rem, 1.4rem + 3.4vw, 3.75rem); line-height: 1.05; font-weight: 600; letter-spacing: -.025em; }
.sm-body { margin: 20px 0 0; font-size: clamp(1rem, .95rem + .25vw, 1.125rem); line-height: 1.65; color: var(--sm-muted); }
.sm-button { margin-top: 32px; display: inline-flex; align-items: center; height: 48px; padding: 0 24px; border-radius: 2px; background: var(--sm-button); color: var(--sm-button-text); font-size: 14px; font-weight: 600; text-decoration: none; }
.sm-button:hover { opacity: .9; }
.sm-button:focus-visible { outline: 2px solid var(--sm-button); outline-offset: 3px; }
@media (min-width: 640px) { .sm-inner { padding: 56px 40px; } }
/* With reduced motion the script draws one settled frame and stops (it reads prefers-reduced-motion). */
@media (prefers-reduced-motion: reduce) { .sm-button { transition: none; } }
</style>
<script>
(function () {
// The values worth changing.
const SETTINGS = {
smoke: [214, 210, 204], // colour of the smoke as [red, green, blue]
ember: "#ff9a4d", // the glowing tip of the incense stick
rise: 38, // how fast the smoke climbs, in pixels per second
curl: 1, // how much the wisps twist; 0.5 is a still room, 1.6 a draughty one
puffs: 900, // how many puffs make up the smoke; fewer is cheaper and thinner
};
function smokeSprite() {
const c = document.createElement("canvas");
c.width = c.height = 64;
const g = c.getContext("2d");
const [r, gr, b] = SETTINGS.smoke;
const grad = g.createRadialGradient(32, 32, 0, 32, 32, 32);
grad.addColorStop(0, `rgba(${r},${gr},${b},1)`);
grad.addColorStop(0.4, `rgba(${r},${gr},${b},0.45)`);
grad.addColorStop(1, `rgba(${r},${gr},${b},0)`);
g.fillStyle = grad;
g.fillRect(0, 0, 64, 64);
return c;
}
const SCENE = {
start: 0,
still: 0,
scale: 0.75,
init(ctx, w, h) {
const narrow = w < 700;
const src = { x: w * (narrow ? 0.72 : 0.68), y: h * (narrow ? 0.9 : 0.82) };
const st = { ps: [], src, sprite: smokeSprite(), t: 0, narrow };
// Let the incense burn for a while first so the column is already tall on the first frame.
for (let i = 0; i < 600; i++) SCENE.step(st, 1 / 60, h);
return st;
},
step(st, dt, h) {
st.t += dt;
const t = st.t;
const life = (st.src.y + 40) / SETTINGS.rise;
const spawn = (SETTINGS.puffs / life) * dt;
st.carry = (st.carry || 0) + spawn;
while (st.carry >= 1) {
st.carry -= 1;
st.ps.push({ x: st.src.x + (Math.random() - 0.5) * 1.5, y: st.src.y - 4, vx: 0, age: 0, seed: Math.random() * 6.28 });
}
const c = SETTINGS.curl;
for (const p of st.ps) {
p.age += dt;
const up = st.src.y - p.y;
// Near the stick the smoke rises as a thin laminar thread, then breaks into curls higher up.
const turb = Math.min(1, up / (h * 0.25));
const sway = Math.sin(t * 0.35 + up * 0.004) * 30 * turb;
const curlx = (Math.sin(up * 0.021 - t * 1.1 + p.seed * 0.15) * 22 + Math.sin(up * 0.047 + t * 0.7) * 9) * turb * turb * c;
p.vx += ((sway + curlx) - p.vx) * Math.min(1, dt * 2.2);
p.x += p.vx * dt + Math.sin(t * 0.9 + p.seed) * 0.08 * turb * c;
p.x += (Math.random() - 0.5) * turb * 0.9;
p.y -= SETTINGS.rise * dt * (1 + 0.3 * turb);
}
st.ps = st.ps.filter((p) => p.y > -60);
},
draw(ctx, w, h, t, st, ptr, dt) {
if (dt > 0) SCENE.step(st, dt, h);
const bg = ctx.createRadialGradient(st.src.x, h * 0.45, 0, st.src.x, h * 0.45, Math.max(w, h) * 0.8);
bg.addColorStop(0, "#26211d");
bg.addColorStop(1, "#0f0d0c");
ctx.fillStyle = bg;
ctx.fillRect(0, 0, w, h);
const sy = st.src.y;
for (const p of st.ps) {
const up = sy - p.y;
const k = up / sy;
const size = 3 + up * 0.09;
const a = Math.min(1, up / 18) * Math.pow(Math.max(0, 1 - k), 1.6) * (0.16 - Math.min(0.1, up * 0.0004));
if (a <= 0.002) continue;
ctx.globalAlpha = a;
ctx.drawImage(st.sprite, p.x - size, p.y - size, size * 2, size * 2);
}
ctx.globalAlpha = 1;
// The incense stick and its ember.
ctx.strokeStyle = "#5a4636";
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(st.src.x, sy);
ctx.lineTo(st.src.x + 18, h + 4);
ctx.stroke();
const glow = ctx.createRadialGradient(st.src.x, sy, 0, st.src.x, sy, 16);
glow.addColorStop(0, "rgba(255,170,90,0.9)");
glow.addColorStop(0.25, "rgba(255,120,50,0.35)");
glow.addColorStop(1, "rgba(255,120,50,0)");
ctx.fillStyle = glow;
ctx.fillRect(st.src.x - 16, sy - 16, 32, 32);
ctx.fillStyle = SETTINGS.ember;
ctx.beginPath();
ctx.arc(st.src.x, sy, 1.8, 0, 6.29);
ctx.fill();
},
};
// Runs a scene on a canvas: sizes it to the block, pauses off screen and in hidden tabs, cleans up after itself.
function runScene(root, canvas, scene, reduced) {
const ctx = canvas.getContext("2d");
if (!ctx) return () => {};
let w = 0;
let h = 0;
let st = null;
let raf = 0;
let last = 0;
let acc = 0;
let onScreen = true;
let time = reduced ? (scene.still ?? scene.start ?? 0) : (scene.start ?? 0);
const ptr = { x: 0, y: 0, on: false, down: 0 };
const step = 1 / (scene.fps || 60);
const resize = () => {
const r = root.getBoundingClientRect();
const nw = Math.max(1, Math.round(r.width));
const nh = Math.max(1, Math.round(r.height));
if (nw === w && nh === h && st) return;
w = nw;
h = nh;
// Device pixels are capped at 1.5 and scaled by the scene's own quality factor.
const d = Math.min(window.devicePixelRatio || 1, 1.5) * (scene.scale || 1);
canvas.width = Math.max(1, Math.round(w * d));
canvas.height = Math.max(1, Math.round(h * d));
ctx.setTransform(canvas.width / w, 0, 0, canvas.height / h, 0, 0);
st = scene.init(ctx, w, h, canvas.width / w);
scene.draw(ctx, w, h, time, st, ptr, 0);
};
const tick = (now) => {
raf = requestAnimationFrame(tick);
const dt = last ? Math.min(0.1, (now - last) / 1000) : step;
last = now;
acc += dt;
if (acc < step * 0.95) return;
const used = Math.min(acc, 0.1);
acc = 0;
time += used;
scene.draw(ctx, w, h, time, st, ptr, used);
};
const play = () => {
if (raf || reduced || !onScreen || document.hidden) return;
last = 0;
raf = requestAnimationFrame(tick);
};
const stop = () => {
cancelAnimationFrame(raf);
raf = 0;
};
const ro = new ResizeObserver(resize);
ro.observe(root);
const io = new IntersectionObserver((entries) => {
onScreen = entries[0].isIntersecting;
if (onScreen) play();
else stop();
});
io.observe(root);
const onVis = () => (document.hidden ? stop() : play());
const onMove = (e) => {
const r = root.getBoundingClientRect();
ptr.x = e.clientX - r.left;
ptr.y = e.clientY - r.top;
ptr.on = true;
};
const onLeave = () => {
ptr.on = false;
};
const onDown = (e) => {
onMove(e);
ptr.down += 1;
};
document.addEventListener("visibilitychange", onVis);
root.addEventListener("pointermove", onMove);
root.addEventListener("pointerleave", onLeave);
root.addEventListener("pointerdown", onDown);
resize();
play();
return () => {
stop();
ro.disconnect();
io.disconnect();
document.removeEventListener("visibilitychange", onVis);
root.removeEventListener("pointermove", onMove);
root.removeEventListener("pointerleave", onLeave);
root.removeEventListener("pointerdown", onDown);
};
}
var reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
document.querySelectorAll("[data-sm]").forEach(function (root) {
if (root.getAttribute("data-sm") === "on") return;
root.setAttribute("data-sm", "on");
runScene(root, root.querySelector(".sm-canvas"), SCENE, reduced);
});
})();
</script>
With reduced motion: A finished column of smoke hangs in place above the glowing stick.
Running cost: Around 900 soft sprites drawn per frame at reduced resolution, with a simple drift calculation for each.
Winding bands of deep indigo and burnt sienna ink run through bone-coloured paper like the cut edge of a marbled block, each edged with a faint darker vein. The bands drift so slowly the page reads as a printed endpaper that happens to be alive. It fits stationers, bookbinders, galleries and anyone selling something made by hand.
<section class="ink-root" data-ink>
<canvas class="ink-canvas" aria-hidden="true"></canvas>
<div class="ink-inner">
<div class="ink-copy">
<p class="ink-eyebrow">Halden and Rowe Bindery</p>
<h2 class="ink-title">Endpapers marbled one sheet at a time.</h2>
<p class="ink-body">Hand-floated papers in two inks for journals, wedding albums and small-run books, cut to your trim size.</p>
<a class="ink-button" href="#">Order a sample pack</a>
</div>
</div>
</section>
<style>
/* Page colours: background, text and button. The scene colours live in SETTINGS in the script below. */
.ink-root { --ink-bg: #efe8dc; --ink-text: #1c2333; --ink-muted: #3d4352; --ink-button: #1c2946; --ink-button-text: #f4efe5;
position: relative; width: 100%; height: 620px; overflow: hidden; background: var(--ink-bg); color: var(--ink-text); font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
.ink-canvas { position: absolute; inset: 0; display: block; width: 100%; height: 100%; pointer-events: none; }
.ink-inner { position: relative; z-index: 1; box-sizing: border-box; height: 100%; max-width: 72rem; margin: 0 auto; padding: 56px 24px; display: flex; flex-direction: column; justify-content: center; align-items: center; text-align: center; }
.ink-copy { box-sizing: border-box; max-width: 36rem; border: 1px solid rgba(28,41,70,0.18); background: #f5f0e6; border-radius: 16px; padding: 28px; }
.ink-eyebrow { margin: 0; font-size: 12px; font-weight: 600; letter-spacing: .2em; text-transform: uppercase; color: #8a4a28; }
.ink-title { font-family:Georgia,'Times New Roman',serif;margin: 16px 0 0; font-size: clamp(2.25rem, 1.4rem + 3.4vw, 3.75rem); line-height: 1.05; font-weight: 600; letter-spacing: -.025em; }
.ink-body { margin: 20px 0 0; font-size: clamp(1rem, .95rem + .25vw, 1.125rem); line-height: 1.65; color: var(--ink-muted); }
.ink-button { margin-top: 32px; display: inline-flex; align-items: center; height: 48px; padding: 0 24px; border-radius: 2px; background: var(--ink-button); color: var(--ink-button-text); font-size: 14px; font-weight: 600; text-decoration: none; }
.ink-button:hover { opacity: .9; }
.ink-button:focus-visible { outline: 2px solid var(--ink-button); outline-offset: 3px; }
@media (min-width: 640px) { .ink-inner { padding: 56px 40px; } .ink-copy { padding: 40px; } }
/* With reduced motion the script draws one settled frame and stops (it reads prefers-reduced-motion). */
@media (prefers-reduced-motion: reduce) { .ink-button { transition: none; } }
</style>
<script>
(function () {
// The values worth changing. Colours are [red, green, blue] from 0 to 255.
const SETTINGS = {
paper: [239, 232, 219], // the bare sheet between the inks
inkA: [28, 41, 70], // the deep ink
inkB: [172, 96, 56], // the warm ink
speed: 1, // how fast the inks swirl; 0.5 is barely moving
cell: 4, // CSS pixels per computed sample; higher is cheaper and softer
};
function inkMix(a, b, k) {
return [a[0] + (b[0] - a[0]) * k, a[1] + (b[1] - a[1]) * k, a[2] + (b[2] - a[2]) * k];
}
function smooth(e0, e1, x) {
const k = Math.min(1, Math.max(0, (x - e0) / (e1 - e0)));
return k * k * (3 - 2 * k);
}
const SCENE = {
start: 40,
still: 40,
fps: 30,
init(ctx, w, h) {
const gw = Math.max(8, Math.ceil(w / SETTINGS.cell));
const gh = Math.max(8, Math.ceil(h / SETTINGS.cell));
const off = document.createElement("canvas");
off.width = gw;
off.height = gh;
const octx = off.getContext("2d");
return { gw, gh, off, octx, img: octx.createImageData(gw, gh) };
},
draw(ctx, w, h, time, st) {
const t = time * SETTINGS.speed;
const { gw, gh, img } = st;
const data = img.data;
const unit = 2.4 / Math.min(gw, gh);
let o = 0;
for (let j = 0; j < gh; j++) {
const v = j * unit;
for (let i = 0; i < gw; i++) {
const u = i * unit;
// Two rounds of warping fold the bands into slow marble swirls.
const q1 = Math.sin(u * 1.7 + t * 0.07) + Math.sin(v * 2.3 - t * 0.05);
const q2 = Math.cos(v * 1.9 + t * 0.06) + Math.sin(u * 2.1 + t * 0.04);
const wx = u + 0.55 * q1;
const wy = v + 0.55 * q2;
// Thin, winding strata of ink, like a cut through marbled paper: the stripe index picks the colour.
const band = wx * 1.5 + wy * 0.7 + 0.6 * Math.sin(wy * 2.2 + wx * 0.7 + t * 0.04);
const f = band - Math.floor(band);
let c = SETTINGS.paper;
// Each stripe: paper, a dark vein, a band of the deep ink, paper, a hairline, a band of the warm ink.
const inA = smooth(0.08, 0.12, f) * (1 - smooth(0.3, 0.34, f));
const inB = smooth(0.56, 0.6, f) * (1 - smooth(0.74, 0.78, f));
c = inkMix(c, SETTINGS.inkA, inA * (0.75 + 0.25 * Math.sin(band * 1.7)));
c = inkMix(c, SETTINGS.inkB, inB * (0.7 + 0.3 * Math.cos(band * 1.3)));
const vein = Math.max(0, 1 - Math.abs(f - 0.05) * 18) + Math.max(0, 1 - Math.abs(f - 0.47) * 20) * 0.6 + Math.max(0, 1 - Math.abs(f - 0.9) * 20) * 0.4;
const dark = 1 - vein * 0.22;
data[o] = c[0] * dark;
data[o + 1] = c[1] * dark;
data[o + 2] = c[2] * dark;
data[o + 3] = 255;
o += 4;
}
}
st.octx.putImageData(img, 0, 0);
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = "high";
ctx.drawImage(st.off, 0, 0, w, h);
},
};
// Runs a scene on a canvas: sizes it to the block, pauses off screen and in hidden tabs, cleans up after itself.
function runScene(root, canvas, scene, reduced) {
const ctx = canvas.getContext("2d");
if (!ctx) return () => {};
let w = 0;
let h = 0;
let st = null;
let raf = 0;
let last = 0;
let acc = 0;
let onScreen = true;
let time = reduced ? (scene.still ?? scene.start ?? 0) : (scene.start ?? 0);
const ptr = { x: 0, y: 0, on: false, down: 0 };
const step = 1 / (scene.fps || 60);
const resize = () => {
const r = root.getBoundingClientRect();
const nw = Math.max(1, Math.round(r.width));
const nh = Math.max(1, Math.round(r.height));
if (nw === w && nh === h && st) return;
w = nw;
h = nh;
// Device pixels are capped at 1.5 and scaled by the scene's own quality factor.
const d = Math.min(window.devicePixelRatio || 1, 1.5) * (scene.scale || 1);
canvas.width = Math.max(1, Math.round(w * d));
canvas.height = Math.max(1, Math.round(h * d));
ctx.setTransform(canvas.width / w, 0, 0, canvas.height / h, 0, 0);
st = scene.init(ctx, w, h, canvas.width / w);
scene.draw(ctx, w, h, time, st, ptr, 0);
};
const tick = (now) => {
raf = requestAnimationFrame(tick);
const dt = last ? Math.min(0.1, (now - last) / 1000) : step;
last = now;
acc += dt;
if (acc < step * 0.95) return;
const used = Math.min(acc, 0.1);
acc = 0;
time += used;
scene.draw(ctx, w, h, time, st, ptr, used);
};
const play = () => {
if (raf || reduced || !onScreen || document.hidden) return;
last = 0;
raf = requestAnimationFrame(tick);
};
const stop = () => {
cancelAnimationFrame(raf);
raf = 0;
};
const ro = new ResizeObserver(resize);
ro.observe(root);
const io = new IntersectionObserver((entries) => {
onScreen = entries[0].isIntersecting;
if (onScreen) play();
else stop();
});
io.observe(root);
const onVis = () => (document.hidden ? stop() : play());
const onMove = (e) => {
const r = root.getBoundingClientRect();
ptr.x = e.clientX - r.left;
ptr.y = e.clientY - r.top;
ptr.on = true;
};
const onLeave = () => {
ptr.on = false;
};
const onDown = (e) => {
onMove(e);
ptr.down += 1;
};
document.addEventListener("visibilitychange", onVis);
root.addEventListener("pointermove", onMove);
root.addEventListener("pointerleave", onLeave);
root.addEventListener("pointerdown", onDown);
resize();
play();
return () => {
stop();
ro.disconnect();
io.disconnect();
document.removeEventListener("visibilitychange", onVis);
root.removeEventListener("pointermove", onMove);
root.removeEventListener("pointerleave", onLeave);
root.removeEventListener("pointerdown", onDown);
};
}
var reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
document.querySelectorAll("[data-ink]").forEach(function (root) {
if (root.getAttribute("data-ink") === "on") return;
root.setAttribute("data-ink", "on");
runScene(root, root.querySelector(".ink-canvas"), SCENE, reduced);
});
})();
</script>
With reduced motion: One fixed pattern of marbled ink is painted once and left in place.
Running cost: A small grid of colour samples, about one per five pixels, worked out on the main thread at 30 frames a second and scaled up.
A graphite wireframe of hills rolls toward a low camera on warm paper, fading into mist at the horizon, with one contour height traced in rust. Hidden lines behind each ridge are removed, so it reads like a survey drawing rather than a neon grid. It suits architects, surveyors, mapping tools and outdoor brands.
<section class="tp-root" data-tp>
<canvas class="tp-canvas" aria-hidden="true"></canvas>
<div class="tp-scrim" aria-hidden="true"></div>
<div class="tp-inner">
<div class="tp-copy">
<p class="tp-eyebrow">Ridgeline Survey Co.</p>
<h2 class="tp-title">Ground models you can build on.</h2>
<p class="tp-body">Measured terrain for architects and landscape designers, walked on foot and flown by drone.</p>
<a class="tp-button" href="#">Request a survey</a>
</div>
</div>
</section>
<style>
/* Page colours: background, text and button. The scene colours live in SETTINGS in the script below. */
.tp-root { --tp-bg: #f3f1ec; --tp-text: #1f242c; --tp-muted: #4a515c; --tp-button: #1f242c; --tp-button-text: #f3f1ec;
position: relative; width: 100%; height: 640px; overflow: hidden; background: var(--tp-bg); color: var(--tp-text); font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
.tp-canvas { position: absolute; inset: 0; display: block; width: 100%; height: 100%; pointer-events: none; }
.tp-scrim { position: absolute; inset: 0; pointer-events: none; background: linear-gradient(180deg, rgba(243,241,236,0.96) 0%, rgba(243,241,236,0.85) 42%, rgba(243,241,236,0) 62%); }
.tp-inner { position: relative; z-index: 1; box-sizing: border-box; height: 100%; max-width: 72rem; margin: 0 auto; padding: 56px 24px; display: flex; flex-direction: column; justify-content: flex-start; align-items: center; text-align: center; }
.tp-copy { box-sizing: border-box; max-width: 36rem; }
.tp-eyebrow { margin: 0; font-size: 12px; font-weight: 600; letter-spacing: .2em; text-transform: uppercase; color: #9a4424; }
.tp-title { margin: 16px 0 0; font-size: clamp(2.25rem, 1.4rem + 3.4vw, 3.75rem); line-height: 1.05; font-weight: 600; letter-spacing: -.025em; }
.tp-body { margin: 20px 0 0; font-size: clamp(1rem, .95rem + .25vw, 1.125rem); line-height: 1.65; color: var(--tp-muted); }
.tp-button { margin-top: 32px; display: inline-flex; align-items: center; height: 48px; padding: 0 24px; border-radius: 4px; background: var(--tp-button); color: var(--tp-button-text); font-size: 14px; font-weight: 600; text-decoration: none; }
.tp-button:hover { opacity: .9; }
.tp-button:focus-visible { outline: 2px solid var(--tp-button); outline-offset: 3px; }
@media (min-width: 640px) { .tp-inner { padding: 56px 40px; } }
/* With reduced motion the script draws one settled frame and stops (it reads prefers-reduced-motion). */
@media (prefers-reduced-motion: reduce) { .tp-button { transition: none; } }
</style>
<script>
(function () {
// The values worth changing.
const SETTINGS = {
paper: "#f3f1ec", // the page colour; also hides the lines behind each hill
line: [38, 44, 54], // graphite line colour as [red, green, blue]
accent: "rgba(184, 84, 48, 0.85)", // the one contour line picked out in rust
speed: 1.1, // how fast the ground glides toward you
relief: 1, // height of the hills; 0.5 is rolling farmland, 1.6 is alpine
rows: 46, // lines from front to back
cols: 96, // lines from left to right
};
function terrainHeight(x, z) {
const a = Math.sin(x * 0.18 + z * 0.07) * 1.1 + Math.sin(z * 0.13 - x * 0.05) * 1.4;
const b = Math.sin(x * 0.41 + z * 0.29 + 1.7) * 0.45 + Math.sin(x * 0.07 - z * 0.21) * 1.6;
const ridge = 1 - Math.abs(Math.sin(x * 0.09 + z * 0.045));
return (a + b + ridge * ridge * 2.4) * SETTINGS.relief;
}
const SCENE = {
start: 0,
still: 0,
init() {
return {};
},
draw(ctx, w, h, t) {
ctx.fillStyle = SETTINGS.paper;
ctx.fillRect(0, 0, w, h);
const horizon = h * (w < 700 ? 0.56 : 0.5);
const focal = Math.max(w, 700) * 0.9;
const camY = 7.5;
const dz = 1;
const scroll = t * SETTINGS.speed;
const shift = scroll % dz;
const rows = SETTINGS.rows;
const cols = SETTINGS.cols;
const span = 90;
const pts = [];
// Project a grid of ground points; row 0 is nearest the viewer.
for (let r = 0; r < rows; r++) {
const z = 1.6 + r * dz - shift;
const row = [];
for (let c = 0; c <= cols; c++) {
const x = (c / cols - 0.5) * span;
const y = terrainHeight(x, z + scroll);
row.push([w / 2 + (x * focal) / (z * 3.2), horizon + ((camY - y) * focal) / (z * 4.2), y]);
}
pts.push({ z, row });
}
const [lr, lg, lb] = SETTINGS.line;
ctx.lineJoin = "round";
// Draw from back to front so the fog fades distant lines into the paper.
for (let r = rows - 1; r >= 0; r--) {
const { z, row } = pts[r];
const fade = Math.max(0, Math.min(1, (rows - z) / (rows * 0.75))) * Math.min(1, (z - 1.2) * 1.2);
// Paper under this row hides whatever lies behind the ridge.
ctx.fillStyle = SETTINGS.paper;
ctx.beginPath();
ctx.moveTo(row[0][0], h + 2);
for (let c = 0; c <= cols; c++) ctx.lineTo(row[c][0], row[c][1]);
ctx.lineTo(row[cols][0], h + 2);
ctx.fill();
ctx.strokeStyle = `rgba(${lr},${lg},${lb},${0.62 * fade})`;
ctx.lineWidth = 0.6 + 0.9 * fade;
ctx.beginPath();
for (let c = 0; c <= cols; c++) {
if (c === 0) ctx.moveTo(row[c][0], row[c][1]);
else ctx.lineTo(row[c][0], row[c][1]);
}
ctx.stroke();
if (r > 0) {
const near = pts[r - 1].row;
ctx.strokeStyle = `rgba(${lr},${lg},${lb},${0.34 * fade})`;
ctx.lineWidth = 0.6 + 0.5 * fade;
ctx.beginPath();
for (let c = 0; c <= cols; c++) {
ctx.moveTo(row[c][0], row[c][1]);
ctx.lineTo(near[c][0], near[c][1]);
}
ctx.stroke();
// One contour height traced in rust where the ground crosses it.
ctx.strokeStyle = SETTINGS.accent.replace(/[\d.]+\)$/, `${0.85 * fade})`);
ctx.lineWidth = 1.4;
ctx.beginPath();
const level = 3.2;
for (let c = 0; c < cols; c++) {
const a = row[c];
const b = row[c + 1];
const d = near[c];
const cross = (p, q) => {
if ((p[2] - level) * (q[2] - level) >= 0) return null;
const s = (level - p[2]) / (q[2] - p[2]);
return [p[0] + (q[0] - p[0]) * s, p[1] + (q[1] - p[1]) * s];
};
const hits = [cross(a, b), cross(a, d), cross(b, near[c + 1]), cross(d, near[c + 1])].filter(Boolean);
if (hits.length >= 2) {
ctx.moveTo(hits[0][0], hits[0][1]);
ctx.lineTo(hits[1][0], hits[1][1]);
}
}
ctx.stroke();
}
}
},
};
// Runs a scene on a canvas: sizes it to the block, pauses off screen and in hidden tabs, cleans up after itself.
function runScene(root, canvas, scene, reduced) {
const ctx = canvas.getContext("2d");
if (!ctx) return () => {};
let w = 0;
let h = 0;
let st = null;
let raf = 0;
let last = 0;
let acc = 0;
let onScreen = true;
let time = reduced ? (scene.still ?? scene.start ?? 0) : (scene.start ?? 0);
const ptr = { x: 0, y: 0, on: false, down: 0 };
const step = 1 / (scene.fps || 60);
const resize = () => {
const r = root.getBoundingClientRect();
const nw = Math.max(1, Math.round(r.width));
const nh = Math.max(1, Math.round(r.height));
if (nw === w && nh === h && st) return;
w = nw;
h = nh;
// Device pixels are capped at 1.5 and scaled by the scene's own quality factor.
const d = Math.min(window.devicePixelRatio || 1, 1.5) * (scene.scale || 1);
canvas.width = Math.max(1, Math.round(w * d));
canvas.height = Math.max(1, Math.round(h * d));
ctx.setTransform(canvas.width / w, 0, 0, canvas.height / h, 0, 0);
st = scene.init(ctx, w, h, canvas.width / w);
scene.draw(ctx, w, h, time, st, ptr, 0);
};
const tick = (now) => {
raf = requestAnimationFrame(tick);
const dt = last ? Math.min(0.1, (now - last) / 1000) : step;
last = now;
acc += dt;
if (acc < step * 0.95) return;
const used = Math.min(acc, 0.1);
acc = 0;
time += used;
scene.draw(ctx, w, h, time, st, ptr, used);
};
const play = () => {
if (raf || reduced || !onScreen || document.hidden) return;
last = 0;
raf = requestAnimationFrame(tick);
};
const stop = () => {
cancelAnimationFrame(raf);
raf = 0;
};
const ro = new ResizeObserver(resize);
ro.observe(root);
const io = new IntersectionObserver((entries) => {
onScreen = entries[0].isIntersecting;
if (onScreen) play();
else stop();
});
io.observe(root);
const onVis = () => (document.hidden ? stop() : play());
const onMove = (e) => {
const r = root.getBoundingClientRect();
ptr.x = e.clientX - r.left;
ptr.y = e.clientY - r.top;
ptr.on = true;
};
const onLeave = () => {
ptr.on = false;
};
const onDown = (e) => {
onMove(e);
ptr.down += 1;
};
document.addEventListener("visibilitychange", onVis);
root.addEventListener("pointermove", onMove);
root.addEventListener("pointerleave", onLeave);
root.addEventListener("pointerdown", onDown);
resize();
play();
return () => {
stop();
ro.disconnect();
io.disconnect();
document.removeEventListener("visibilitychange", onVis);
root.removeEventListener("pointermove", onMove);
root.removeEventListener("pointerleave", onLeave);
root.removeEventListener("pointerdown", onDown);
};
}
var reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
document.querySelectorAll("[data-tp]").forEach(function (root) {
if (root.getAttribute("data-tp") === "on") return;
root.setAttribute("data-tp", "on");
runScene(root, root.querySelector(".tp-canvas"), SCENE, reduced);
});
})();
</script>
With reduced motion: The terrain is drawn once, held still at its starting position.
Running cost: About 4,400 projected points drawn as line paths each frame on canvas 2D, with no per-pixel work.
A full-screen shader runs once per pixel per frame, so resolution is the main cost. The WebGL pieces render at a fraction of the screen size and stretch the result, which suits soft imagery, and they cap the pixel ratio so a high-density laptop does not do four times the work.
Every piece stops its frame loop when it leaves the screen and when the tab is hidden, and releases its observers and listeners when removed. If you put one of these behind a long page, keep it to the hero; a background that keeps rendering behind content nobody is looking at only drains batteries.
Only when the effect needs it. Many backgrounds look just as good in CSS or a 2D canvas and cost less. WebGL earns its place for effects computed per pixel, such as liquid, fog or refraction, and even then it needs a fallback.
Each WebGL piece here tests for a context before starting and shows its canvas or CSS fallback when there is none. It also listens for a lost context and switches over instead of leaving a blank box.
They can if the script blocks the first render or the effect repaints heavily. Start the animation after the page has painted, keep the background out of the main content's way, and let it pause off screen; the headline and button should render without waiting for it.
Copy the React version. It creates the canvas in a ref, starts the loop in an effect, and cleans up the frame, observers and listeners when the component unmounts, so it is safe with client-side routing.
More collections: Particle Backgrounds in Plain JavaScript, Animated Gradient Backgrounds, CSS Wave Animations, Animated Backgrounds in Pure CSS. All animated backgrounds.
We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.