Animated Backgrounds for Websites

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.

Compare them

EffectBuilt withSize (gzipped)Running cost
Wind Map Flow FieldCanvas 2D3.5 KBAbout one particle per 520 square pixels, drawn as short line segments in three batched strokes per frame on canvas 2D.
Desert Heat Haze HorizonCanvas 2D3.6 KBThe landscape is painted once; each frame copies it back in about 45 thin strips with small sideways offsets.
Dot Grid FabricCanvas 2D3.5 KBOne spring per dot, about 1,700 at desktop size, updated each frame and filled in three batched paths.
Lava Lamp WaxCanvas 2D3.5 KBA coarse field of samples, one per five pixels, summed over eight blobs at 30 frames a second and scaled up.
Light Beams Through FogCanvas 2D3.9 KBSix pre-drawn beam images and sixteen fog sprites, blended at reduced resolution, plus a short loop over the dust specks.
Pool Water RipplesCanvas 2D3.8 KBA 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 WindowCanvas 2D4.1 KBThree 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 BandsCanvas 2D3.8 KBAbout 3,200 one-pixel grains drawn each frame plus a few gradient fills; light for canvas 2D.
Rising Incense SmokeCanvas 2D3.6 KBAround 900 soft sprites drawn per frame at reduced resolution, with a simple drift calculation for each.
Two Ink Liquid MarbleCanvas 2D3.3 KBA 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 GlideCanvas 2D3.6 KBAbout 4,400 projected points drawn as line paths each frame on canvas 2D, with no per-pixel work.

Wind Map Flow Field

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

Desert Heat Haze Horizon

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.

Dot Grid Fabric

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.

Lava Lamp Wax

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.

Light Beams Through Fog

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.

Pool Water Ripples

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.

Rain on a Night Window

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.

Ringed Planet Dust Bands

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.

Rising Incense Smoke

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.

Two Ink Liquid Marble

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.

Wireframe Terrain Glide

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.

Performance notes

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.

Questions

Should I use WebGL for a website background?

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.

What if the visitor's browser cannot run WebGL?

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.

Do animated backgrounds hurt page speed scores?

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.

How do I use one of these in React?

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.

Product

Who It's For

Features

Resources

Company

View Sitemap