Particle Backgrounds in Plain JavaScript

Updated 2026-10-14. 12 effects.

A particle background draws many small moving points on a canvas behind your content. You can build one without a library: keep particles in an array, update them each animation frame scaled by elapsed time, and redraw. The effects here do exactly that in a few kilobytes, pause off screen and settle to a still frame for reduced motion.

Particles read as weather and atmosphere, so match them to the story of the page: drifting dust in sunlight for a slow, warm brand; falling snow for a seasonal campaign; embers for anything about heat, food or craft; a quiet network of points for software. Depth is what makes them look real, so each effect varies size, softness and speed with distance.

Every snippet is self-contained: one canvas, one script, a handful of settings at the top for count, colour and speed. Particle count scales with the area of the box, so a phone draws fewer than a wide desktop screen and both look equally full.

Compare them

EffectBuilt withSize (gzipped)Running cost
Constellation Network with Depth FadeCanvas 2D3.1 KBUp to 170 points with a box-tested pair loop for links, roughly one to two milliseconds a frame on a laptop, stopped off screen.
Dust Motes in a Sunlight ShaftCanvas 2D3.3 KBOne half-resolution beam image plus up to 560 small sprite draws per frame; light on CPU and paused when scrolled away.
Falling Snow with Depth and GustsCanvas 2D2.8 KBUp to 760 sprite draws per frame with no per-frame allocation; a light load that pauses whenever the hero leaves view.
Fireflies over a Dusk MeadowCanvas 2D3.1 KBOne cached grass image plus up to 100 additive sprite draws per frame; very cheap, and the loop pauses off screen.
Floating Dandelion Seeds and PollenCanvas 2D3.3 KBUp to a few dozen rotated seed images and 500 pollen sprites per frame; light, and stopped when off screen.
Glowing Plankton in Deep WaterCanvas 2D3.3 KBThree sprite types, up to about 800 draws per frame plus one cached ray image; moderate, and paused off screen.
Out-of-Focus Bokeh LightsCanvas 2D3 KBUnder 90 large additive sprite draws per frame from cached discs; cheap on CPU, with some fill cost on very wide screens.
Parallax Starfield with Slow DriftCanvas 2D3.1 KBOne cached galaxy image drawn twice plus up to 1,100 star sprites per frame; light to moderate, paused off screen.
Pointer-Repelled Dot FieldCanvas 2D3 KBSpring physics for around 2,000 to 3,000 dots on a wide screen, one sprite each; the heaviest piece here, still near a couple of milliseconds.
Rain on a Night WindowCanvas 2D3.3 KBOne cached city image, up to 500 short lines and 600 small bead sprites per frame; moderate, and stopped off screen.
Rising Embers over a Dark HearthCanvas 2D3 KBUp to 420 additive sprite draws plus one glow image per frame, all from three pre-rendered tints; paused off screen.
Wind-Blown Sand over DunesCanvas 2D3 KBOne cached dune image and up to 1,400 thin rectangles per frame; light, and the loop sleeps off screen.

Constellation Network with Depth Fade

A network of slate points drifts slowly across pale paper, and any two that come close are joined by a hairline. Each point has a depth value, so far points are smaller, fainter and slower and their links are shorter and lighter, which gives the mesh real thickness instead of a flat web. A handful of teal hub points pulse softly. It is a small, dependency-free replacement for the classic particles library look, drawn with canvas 2D in roughly 3 KB of script.

<!-- Constellation Network with Depth Fade: plain HTML, CSS and JavaScript. Paste it anywhere; the class names all start with "cnl-" so they will not clash. -->
<section class="cnl-hero">
  <canvas class="cnl-canvas" aria-hidden="true"></canvas>
  <div class="cnl-scrim" aria-hidden="true"></div>
  <div class="cnl-inner">
    <div class="cnl-copy">
      <p class="cnl-eyebrow">Meridian Survey Labs</p>
      <h2 class="cnl-title">See how every site in your region connects.</h2>
      <p class="cnl-body">We map utilities, transit and land records into one shared model your planning team can search, filter and export.</p>
      <a class="cnl-cta" href="#">Request a sample map</a>
    </div>
  </div>
</section>

<style>
  /* Colours to change: --cnl-bg (the backdrop), --cnl-text and --cnl-sub (copy), --cnl-btn and --cnl-btn-text (button).
     Particle colour, density and speed are the constants at the top of the script below. */
  .cnl-hero {
    --cnl-bg: linear-gradient(160deg, #f5f7f9 0%, #e8edf2 100%);
    --cnl-text: #0f172a;
    --cnl-sub: #334155;
    --cnl-btn: #0f766e;
    --cnl-btn-text: #ffffff;
    position: relative;
    isolation: isolate;
    display: flex;
    align-items: center;
    min-height: 560px;
    padding: 80px 24px;
    overflow: hidden;
    background: var(--cnl-bg);
    box-sizing: border-box;
  }
  .cnl-canvas, .cnl-scrim { position: absolute; inset: 0; width: 100%; height: 100%; z-index: -1; pointer-events: none; }
  .cnl-scrim { background: radial-gradient(55% 75% at 22% 50%, rgba(245,247,249,0.92) 0%, rgba(245,247,249,0) 75%); }
  .cnl-inner { width: 100%; max-width: 72rem; margin: 0 auto; }
  .cnl-copy { max-width: 36rem; }
  .cnl-eyebrow { margin: 0; font-size: 0.875rem; font-weight: 600; letter-spacing: 0.14em; text-transform: uppercase; color: #0f766e; }
  .cnl-title { margin: 16px 0 0; font-size: clamp(2.25rem, 1.7rem + 2.2vw, 3rem); line-height: 1.08; font-weight: 600; letter-spacing: -0.025em; color: var(--cnl-text); }
  .cnl-body { margin: 20px 0 0; font-size: 1.125rem; line-height: 1.65; color: var(--cnl-sub); }
  .cnl-cta { display: inline-flex; align-items: center; height: 48px; margin-top: 32px; padding: 0 24px; border-radius: 6px; background: var(--cnl-btn); color: var(--cnl-btn-text); font-size: 0.875rem; font-weight: 600; text-decoration: none; }
  .cnl-cta:hover { opacity: 0.9; }
  .cnl-cta:focus-visible { outline: 2px solid #0f766e; outline-offset: 3px; }
  @media (min-width: 640px) { .cnl-hero { padding: 80px 48px; } }
  /* With prefers-reduced-motion: reduce, the script paints one settled frame and never starts its loop. */
</style>

<script>
  (function () {
    // Values worth changing:
    const NODES_PER_MPX = 165; // how many points per million pixels of area
    const INK = "51,65,85"; // colour of the points and links, as r,g,b
    const ACCENT = "15,118,110"; // colour of the few highlighted hub points
    const LINK_DISTANCE = 165; // pixels: points closer than this get joined
    const SPEED = 1; // 0.5 for a slower drift, 2 for faster
    const WARMUP = 30; // frames simulated before the first paint

    function glow(size, rgb, stops) {
      const c = document.createElement("canvas");
      c.width = c.height = size;
      const g = c.getContext("2d");
      const r = size / 2;
      const grd = g.createRadialGradient(r, r, 0, r, r, r);
      for (const [o, a] of stops) grd.addColorStop(o, `rgba(${rgb},${a})`);
      g.fillStyle = grd;
      g.fillRect(0, 0, size, size);
      return c;
    }
    function mount(canvas, root) {
      const ctx = canvas.getContext("2d");
      if (!ctx) return () => {
      };
      const still = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
      const ptr = { x: -1e4, y: -1e4, on: false };
      let w = 0, h = 0, t = 0, last = 0, raf = 0, seen = true;
      let scene = null;
      const fit = () => {
        const r = canvas.getBoundingClientRect();
        if (scene && Math.abs(r.width - w) < 1 && Math.abs(r.height - h) < 1) return;
        const dpr = Math.min(window.devicePixelRatio || 1, 1.5);
        w = Math.max(1, r.width);
        h = Math.max(1, r.height);
        canvas.width = Math.round(w * dpr);
        canvas.height = Math.round(h * dpr);
        ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
        scene = createScene(w, h, ptr);
        for (let i = 0; i < WARMUP; i++) {
          t += 1 / 30;
          scene.update(1 / 30, t);
        }
        scene.draw(ctx, t);
      };
      const frame = (now) => {
        const dt = Math.min(0.05, Math.max(0, (now - last) / 1e3));
        last = now;
        t += dt;
        if (scene) {
          scene.update(dt, t);
          scene.draw(ctx, t);
        }
        raf = requestAnimationFrame(frame);
      };
      const run = () => {
        cancelAnimationFrame(raf);
        raf = 0;
        if (!still && seen && !document.hidden) {
          last = performance.now();
          raf = requestAnimationFrame(frame);
        }
      };
      const ro = new ResizeObserver(fit);
      ro.observe(canvas);
      const io = new IntersectionObserver((es) => {
        seen = es[es.length - 1].isIntersecting;
        run();
      });
      io.observe(canvas);
      document.addEventListener("visibilitychange", run);
      fit();
      run();
      return () => {
        cancelAnimationFrame(raf);
        ro.disconnect();
        io.disconnect();
        document.removeEventListener("visibilitychange", run);
      };
    }
    function createScene(w, h, _ptr) {
      const n = Math.max(28, Math.min(200, Math.round(w * h / 1e6 * NODES_PER_MPX)));
      const dot = glow(32, INK, [[0, 1], [0.3, 1], [0.42, 0]]);
      const hubDot = glow(32, ACCENT, [[0, 1], [0.3, 1], [0.42, 0]]);
      const halo = glow(96, ACCENT, [[0, 0.28], [0.25, 0.12], [1, 0]]);
      const ps = Array.from({ length: n }, () => {
        const z = Math.random();
        const a = Math.random() * Math.PI * 2;
        const v = (3 + Math.random() * 7) * (0.35 + z);
        return { x: Math.random() * w, y: Math.random() * h, z, vx: Math.cos(a) * v, vy: Math.sin(a) * v, hub: Math.random() < 0.07, s: Math.random() * 9 };
      });
      return {
        update(dt) {
          for (const p of ps) {
            p.x += p.vx * dt * SPEED;
            p.y += p.vy * dt * SPEED;
            if (p.x < -60) p.x += w + 120;
            else if (p.x > w + 60) p.x -= w + 120;
            if (p.y < -60) p.y += h + 120;
            else if (p.y > h + 60) p.y -= h + 120;
          }
        },
        draw(c, t) {
          c.clearRect(0, 0, w, h);
          c.strokeStyle = `rgb(${INK})`;
          for (let i = 0; i < n; i++) {
            const a = ps[i];
            for (let j = i + 1; j < n; j++) {
              const b = ps[j];
              const zz = (a.z + b.z) / 2;
              const L = LINK_DISTANCE * (0.55 + 0.65 * zz);
              const dx = a.x - b.x, dy = a.y - b.y;
              if (dx > L || dx < -L || dy > L || dy < -L) continue;
              const d2 = dx * dx + dy * dy;
              if (d2 > L * L) continue;
              const f = 1 - Math.sqrt(d2) / L;
              c.globalAlpha = f * (0.06 + 0.4 * zz);
              c.lineWidth = 0.5 + zz * 0.7;
              c.beginPath();
              c.moveTo(a.x, a.y);
              c.lineTo(b.x, b.y);
              c.stroke();
            }
          }
          for (const p of ps) {
            const s = 3 + p.z * 5;
            if (p.hub) {
              const hs = 44 + 14 * Math.sin(t * 0.9 + p.s);
              c.globalAlpha = 1;
              c.drawImage(halo, p.x - hs / 2, p.y - hs / 2, hs, hs);
              c.drawImage(hubDot, p.x - s * 0.7, p.y - s * 0.7, s * 1.4, s * 1.4);
            } else {
              c.globalAlpha = 0.25 + p.z * 0.7;
              c.drawImage(dot, p.x - s / 2, p.y - s / 2, s, s);
            }
          }
          c.globalAlpha = 1;
        }
      };
    }

    document.querySelectorAll(".cnl-hero").forEach(function (root) {
      var canvas = root.querySelector(".cnl-canvas");
      if (!canvas || root.hasAttribute("data-cnl-on")) return;
      root.setAttribute("data-cnl-on", "");
      mount(canvas, root);
    });
  })();
</script>

With reduced motion: A single still frame of the linked network, with every point and line in place.

Running cost: Up to 170 points with a box-tested pair loop for links, roughly one to two milliseconds a frame on a laptop, stopped off screen.

Full page for Constellation Network with Depth Fade

Dust Motes in a Sunlight Shaft

A dim workshop with one slanted beam of sunlight falling through it. Hundreds of dust motes wander on soft Brownian currents and only catch fire while they cross the beam, then fade back into the dark. A few out-of-focus specks drift close to the lens. It suits craft, heritage and interior brands that want warmth instead of tech gloss.

<!-- Dust Motes in a Sunlight Shaft: plain HTML, CSS and JavaScript. Paste it anywhere; the class names all start with "dms-" so they will not clash. -->
<section class="dms-hero">
  <canvas class="dms-canvas" aria-hidden="true"></canvas>
  <div class="dms-inner">
    <div class="dms-copy">
      <p class="dms-eyebrow">Linden &amp; Oak furniture restoration</p>
      <h2 class="dms-title">Old wood, ready for its second century.</h2>
      <p class="dms-body">Hand-stripped finishes, rebuilt joints and French polish for the pieces your family plans to keep.</p>
      <a class="dms-cta" href="#">Book a workshop visit</a>
    </div>
  </div>
</section>

<style>
  /* Colours to change: --dms-bg (the backdrop), --dms-text and --dms-sub (copy), --dms-btn and --dms-btn-text (button).
     Particle colour, density and speed are the constants at the top of the script below. */
  .dms-hero {
    --dms-bg: radial-gradient(70% 55% at 62% 108%, #4b311d 0%, rgba(75,49,29,0) 70%), linear-gradient(180deg, #1b130d 0%, #261a11 100%);
    --dms-text: #f6ecdc;
    --dms-sub: #d8c6ac;
    --dms-btn: #f1d8a6;
    --dms-btn-text: #24180f;
    position: relative;
    isolation: isolate;
    display: flex;
    align-items: center;
    min-height: 560px;
    padding: 80px 24px;
    overflow: hidden;
    background: var(--dms-bg);
    box-sizing: border-box;
  }
  .dms-canvas { position: absolute; inset: 0; width: 100%; height: 100%; z-index: -1; pointer-events: none; }
  .dms-inner { width: 100%; max-width: 72rem; margin: 0 auto; }
  .dms-copy { max-width: 36rem; }
  .dms-eyebrow { margin: 0; font-size: 0.875rem; font-weight: 600; letter-spacing: 0.14em; text-transform: uppercase; color: var(--dms-sub); }
  .dms-title { margin: 16px 0 0; font-size: clamp(2.25rem, 1.7rem + 2.2vw, 3rem); line-height: 1.08; font-weight: 600; letter-spacing: -0.025em; color: var(--dms-text); }
  .dms-body { margin: 20px 0 0; font-size: 1.125rem; line-height: 1.65; color: var(--dms-sub); }
  .dms-cta { display: inline-flex; align-items: center; height: 48px; margin-top: 32px; padding: 0 24px; border-radius: 999px; background: var(--dms-btn); color: var(--dms-btn-text); font-size: 0.875rem; font-weight: 600; text-decoration: none; }
  .dms-cta:hover { opacity: 0.9; }
  .dms-cta:focus-visible { outline: 2px solid #f6ecdc; outline-offset: 3px; }
  @media (min-width: 640px) { .dms-hero { padding: 80px 48px; } }
  /* With prefers-reduced-motion: reduce, the script paints one settled frame and never starts its loop. */
</style>

<script>
  (function () {
    // Values worth changing:
    const MOTES_PER_MPX = 320; // dust density: motes per million pixels of area
    const LIGHT = "255,224,168"; // colour of the sunlight and the lit dust, as r,g,b
    const DRIFT_SPEED = 1; // 0.5 for still air, 2 for a draughty room
    const BEAM_STRENGTH = 0.014; // brightness of the light shaft itself
    const WARMUP = 90; // frames simulated before the first paint, so it never starts empty

    function glow(size, rgb, stops) {
      const c = document.createElement("canvas");
      c.width = c.height = size;
      const g = c.getContext("2d");
      const r = size / 2;
      const grd = g.createRadialGradient(r, r, 0, r, r, r);
      for (const [o, a] of stops) grd.addColorStop(o, `rgba(${rgb},${a})`);
      g.fillStyle = grd;
      g.fillRect(0, 0, size, size);
      return c;
    }
    function mount(canvas, root) {
      const ctx = canvas.getContext("2d");
      if (!ctx) return () => {
      };
      const still = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
      const ptr = { x: -1e4, y: -1e4, on: false };
      let w = 0, h = 0, t = 0, last = 0, raf = 0, seen = true;
      let scene = null;
      const fit = () => {
        const r = canvas.getBoundingClientRect();
        if (scene && Math.abs(r.width - w) < 1 && Math.abs(r.height - h) < 1) return;
        const dpr = Math.min(window.devicePixelRatio || 1, 1.5);
        w = Math.max(1, r.width);
        h = Math.max(1, r.height);
        canvas.width = Math.round(w * dpr);
        canvas.height = Math.round(h * dpr);
        ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
        scene = createScene(w, h, ptr);
        for (let i = 0; i < WARMUP; i++) {
          t += 1 / 30;
          scene.update(1 / 30, t);
        }
        scene.draw(ctx, t);
      };
      const frame = (now) => {
        const dt = Math.min(0.05, Math.max(0, (now - last) / 1e3));
        last = now;
        t += dt;
        if (scene) {
          scene.update(dt, t);
          scene.draw(ctx, t);
        }
        raf = requestAnimationFrame(frame);
      };
      const run = () => {
        cancelAnimationFrame(raf);
        raf = 0;
        if (!still && seen && !document.hidden) {
          last = performance.now();
          raf = requestAnimationFrame(frame);
        }
      };
      const ro = new ResizeObserver(fit);
      ro.observe(canvas);
      const io = new IntersectionObserver((es) => {
        seen = es[es.length - 1].isIntersecting;
        run();
      });
      io.observe(canvas);
      document.addEventListener("visibilitychange", run);
      fit();
      run();
      return () => {
        cancelAnimationFrame(raf);
        ro.disconnect();
        io.disconnect();
        document.removeEventListener("visibilitychange", run);
      };
    }
    function createScene(w, h, _ptr) {
      const n = Math.min(560, Math.round(w * h / 1e6 * MOTES_PER_MPX));
      const fine = glow(16, LIGHT, [[0, 1], [0.3, 0.75], [1, 0]]);
      const blur = glow(64, LIGHT, [[0, 0.4], [0.55, 0.14], [1, 0]]);
      const tx0 = w * 0.52, tx1 = w * 0.84, bx0 = w * 0.3, bx1 = w * 0.8;
      const beam = document.createElement("canvas");
      beam.width = Math.ceil(w / 2);
      beam.height = Math.ceil(h / 2);
      const b = beam.getContext("2d");
      b.scale(0.5, 0.5);
      const m0 = (tx0 + tx1) / 2, m1 = (bx0 + bx1) / 2;
      for (let i = 0; i < 24; i++) {
        const k = i / 24 * 0.85;
        const g = b.createLinearGradient(0, 0, 0, h);
        g.addColorStop(0, `rgba(${LIGHT},${BEAM_STRENGTH})`);
        g.addColorStop(1, `rgba(${LIGHT},${BEAM_STRENGTH * 0.3})`);
        b.fillStyle = g;
        b.beginPath();
        b.moveTo(tx0 + (m0 - tx0) * k, 0);
        b.lineTo(tx1 - (tx1 - m0) * k, 0);
        b.lineTo(bx1 - (bx1 - m1) * k, h);
        b.lineTo(bx0 + (m1 - bx0) * k, h);
        b.fill();
      }
      const pool = b.createRadialGradient(m1, h, 0, m1, h, w * 0.32);
      pool.addColorStop(0, `rgba(${LIGHT},0.16)`);
      pool.addColorStop(1, `rgba(${LIGHT},0)`);
      b.fillStyle = pool;
      b.fillRect(0, 0, w, h);
      const ps = Array.from({ length: n }, () => ({
        x: Math.random() * w,
        y: Math.random() * h,
        z: Math.random() ** 1.5,
        vx: 0,
        vy: 0,
        s: Math.random() * 99
      }));
      const lit = (x, y) => {
        const f = y / h, l = tx0 + (bx0 - tx0) * f, r = tx1 + (bx1 - tx1) * f;
        const d = Math.abs(x - (l + r) / 2) / ((r - l) / 2);
        return d >= 1 ? 0 : 1 - d * d;
      };
      return {
        update(dt, t) {
          const damp = Math.pow(0.35, dt);
          for (const p of ps) {
            p.vx = p.vx * damp + (Math.sin(t * 0.21 + p.s) * 3 + (Math.random() - 0.5) * 36) * dt;
            p.vy = p.vy * damp + (Math.cos(t * 0.17 + p.s) * 3 - 1 + (Math.random() - 0.5) * 36) * dt;
            const k = (0.35 + p.z) * DRIFT_SPEED * 5;
            p.x += p.vx * k * dt;
            p.y += p.vy * k * dt;
            if (p.x < -40) p.x += w + 80;
            else if (p.x > w + 40) p.x -= w + 80;
            if (p.y < -40) p.y += h + 80;
            else if (p.y > h + 40) p.y -= h + 80;
          }
        },
        draw(c, t) {
          c.clearRect(0, 0, w, h);
          c.globalAlpha = 0.85 + 0.15 * Math.sin(t * 0.35);
          c.drawImage(beam, 0, 0, w, h);
          c.globalCompositeOperation = "lighter";
          for (const p of ps) {
            const a = (0.05 + 0.95 * lit(p.x, p.y)) * (0.5 + 0.5 * Math.sin(t * 1.3 + p.s));
            if (a < 0.02) continue;
            if (p.z > 0.9) {
              const s = 16 + (p.z - 0.9) * 340;
              c.globalAlpha = a * 0.55;
              c.drawImage(blur, p.x - s / 2, p.y - s / 2, s, s);
            } else {
              const s = 2 + p.z * 6;
              c.globalAlpha = a * (0.35 + p.z * 0.65);
              c.drawImage(fine, p.x - s / 2, p.y - s / 2, s, s);
            }
          }
          c.globalAlpha = 1;
          c.globalCompositeOperation = "source-over";
        }
      };
    }

    document.querySelectorAll(".dms-hero").forEach(function (root) {
      var canvas = root.querySelector(".dms-canvas");
      if (!canvas || root.hasAttribute("data-dms-on")) return;
      root.setAttribute("data-dms-on", "");
      mount(canvas, root);
    });
  })();
</script>

With reduced motion: The beam and its motes are painted once in a settled position and nothing moves after that.

Running cost: One half-resolution beam image plus up to 560 small sprite draws per frame; light on CPU and paused when scrolled away.

Falling Snow with Depth and Gusts

Snow falling past a dusk sky in three apparent depths: tiny slow flakes far away, crisp ones in the middle and a few large blurred flakes drifting right past the lens. A wind model mixes slow swells with an occasional strong gust, and near flakes are pushed harder than far ones, so the whole fall leans and recovers like real weather.

<!-- Falling Snow with Depth and Gusts: plain HTML, CSS and JavaScript. Paste it anywhere; the class names all start with "snw-" so they will not clash. -->
<section class="snw-hero">
  <canvas class="snw-canvas" aria-hidden="true"></canvas>
  <div class="snw-inner">
    <div class="snw-copy">
      <p class="snw-eyebrow">Hollow Pine Lodge</p>
      <h2 class="snw-title">Wake up to fresh snow and a fire already lit.</h2>
      <p class="snw-body">Timber cabins with wood stoves, a cedar sauna and groomed trails that start at your door.</p>
      <a class="snw-cta" href="#">Check winter dates</a>
    </div>
  </div>
</section>

<style>
  /* Colours to change: --snw-bg (the backdrop), --snw-text and --snw-sub (copy), --snw-btn and --snw-btn-text (button).
     Particle colour, density and speed are the constants at the top of the script below. */
  .snw-hero {
    --snw-bg: radial-gradient(90% 60% at 50% 115%, #5b6878 0%, rgba(91,104,120,0) 70%), linear-gradient(180deg, #19212c 0%, #283342 60%, #3a4655 100%);
    --snw-text: #f1f5f9;
    --snw-sub: #cbd5e1;
    --snw-btn: #f8fafc;
    --snw-btn-text: #1e293b;
    position: relative;
    isolation: isolate;
    display: flex;
    align-items: center;
    min-height: 560px;
    padding: 80px 24px;
    overflow: hidden;
    background: var(--snw-bg);
    box-sizing: border-box;
  }
  .snw-canvas { position: absolute; inset: 0; width: 100%; height: 100%; z-index: -1; pointer-events: none; }
  .snw-inner { width: 100%; max-width: 72rem; margin: 0 auto; }
  .snw-copy { max-width: 36rem; }
  .snw-eyebrow { margin: 0; font-size: 0.875rem; font-weight: 600; letter-spacing: 0.14em; text-transform: uppercase; color: var(--snw-sub); }
  .snw-title { margin: 16px 0 0; font-size: clamp(2.25rem, 1.7rem + 2.2vw, 3rem); line-height: 1.08; font-weight: 600; letter-spacing: -0.025em; color: var(--snw-text); }
  .snw-body { margin: 20px 0 0; font-size: 1.125rem; line-height: 1.65; color: var(--snw-sub); }
  .snw-cta { display: inline-flex; align-items: center; height: 48px; margin-top: 32px; padding: 0 24px; border-radius: 999px; background: var(--snw-btn); color: var(--snw-btn-text); font-size: 0.875rem; font-weight: 600; text-decoration: none; }
  .snw-cta:hover { opacity: 0.9; }
  .snw-cta:focus-visible { outline: 2px solid #f1f5f9; outline-offset: 3px; }
  @media (min-width: 640px) { .snw-hero { padding: 80px 48px; } }
  /* With prefers-reduced-motion: reduce, the script paints one settled frame and never starts its loop. */
</style>

<script>
  (function () {
    // Values worth changing:
    const FLAKES_PER_MPX = 420; // snowfall density per million pixels
    const SNOW = "238,243,250"; // flake colour, as r,g,b
    const WIND = 1; // gust strength: 0 for still air, 2 for a blizzard
    const FALL_SPEED = 1; // how fast flakes sink
    const WARMUP = 150; // frames simulated before the first paint

    function glow(size, rgb, stops) {
      const c = document.createElement("canvas");
      c.width = c.height = size;
      const g = c.getContext("2d");
      const r = size / 2;
      const grd = g.createRadialGradient(r, r, 0, r, r, r);
      for (const [o, a] of stops) grd.addColorStop(o, `rgba(${rgb},${a})`);
      g.fillStyle = grd;
      g.fillRect(0, 0, size, size);
      return c;
    }
    function mount(canvas, root) {
      const ctx = canvas.getContext("2d");
      if (!ctx) return () => {
      };
      const still = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
      const ptr = { x: -1e4, y: -1e4, on: false };
      let w = 0, h = 0, t = 0, last = 0, raf = 0, seen = true;
      let scene = null;
      const fit = () => {
        const r = canvas.getBoundingClientRect();
        if (scene && Math.abs(r.width - w) < 1 && Math.abs(r.height - h) < 1) return;
        const dpr = Math.min(window.devicePixelRatio || 1, 1.5);
        w = Math.max(1, r.width);
        h = Math.max(1, r.height);
        canvas.width = Math.round(w * dpr);
        canvas.height = Math.round(h * dpr);
        ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
        scene = createScene(w, h, ptr);
        for (let i = 0; i < WARMUP; i++) {
          t += 1 / 30;
          scene.update(1 / 30, t);
        }
        scene.draw(ctx, t);
      };
      const frame = (now) => {
        const dt = Math.min(0.05, Math.max(0, (now - last) / 1e3));
        last = now;
        t += dt;
        if (scene) {
          scene.update(dt, t);
          scene.draw(ctx, t);
        }
        raf = requestAnimationFrame(frame);
      };
      const run = () => {
        cancelAnimationFrame(raf);
        raf = 0;
        if (!still && seen && !document.hidden) {
          last = performance.now();
          raf = requestAnimationFrame(frame);
        }
      };
      const ro = new ResizeObserver(fit);
      ro.observe(canvas);
      const io = new IntersectionObserver((es) => {
        seen = es[es.length - 1].isIntersecting;
        run();
      });
      io.observe(canvas);
      document.addEventListener("visibilitychange", run);
      fit();
      run();
      return () => {
        cancelAnimationFrame(raf);
        ro.disconnect();
        io.disconnect();
        document.removeEventListener("visibilitychange", run);
      };
    }
    function createScene(w, h, _ptr) {
      const n = Math.min(760, Math.round(w * h / 1e6 * FLAKES_PER_MPX));
      const crisp = glow(16, SNOW, [[0, 1], [0.4, 0.85], [1, 0]]);
      const soft = glow(64, SNOW, [[0, 0.55], [0.45, 0.22], [1, 0]]);
      const ps = Array.from({ length: n }, () => ({ x: Math.random() * w, y: Math.random() * h, z: Math.random() ** 1.4, s: Math.random() * 99 }));
      let gust = 0;
      return {
        update(dt, t) {
          const target = (Math.sin(t * 0.13) * 0.5 + Math.sin(t * 0.37 + 1.3) * 0.3 + Math.max(0, Math.sin(t * 0.11 - 1)) ** 6 * 2.4) * 46 * WIND;
          gust += (target - gust) * Math.min(1, dt * 0.9);
          for (const p of ps) {
            const depth = 0.25 + p.z * 1.25;
            p.y += (12 + p.z * 48) * FALL_SPEED * dt;
            p.x += (gust * depth + Math.sin(t * 0.7 + p.s) * (5 + p.z * 12)) * dt;
            if (p.y > h + 40) {
              p.y = -40;
              p.x = Math.random() * w;
            }
            if (p.x > w + 40) p.x -= w + 80;
            else if (p.x < -40) p.x += w + 80;
          }
        },
        draw(c) {
          c.clearRect(0, 0, w, h);
          for (const p of ps) {
            if (p.z > 0.9) {
              const s = 14 + (p.z - 0.9) * 280;
              c.globalAlpha = 0.5;
              c.drawImage(soft, p.x - s / 2, p.y - s / 2, s, s);
            } else {
              const s = 1.6 + p.z * 5.5;
              c.globalAlpha = 0.3 + p.z * 0.65;
              c.drawImage(crisp, p.x - s / 2, p.y - s / 2, s, s);
            }
          }
          c.globalAlpha = 1;
        }
      };
    }

    document.querySelectorAll(".snw-hero").forEach(function (root) {
      var canvas = root.querySelector(".snw-canvas");
      if (!canvas || root.hasAttribute("data-snw-on")) return;
      root.setAttribute("data-snw-on", "");
      mount(canvas, root);
    });
  })();
</script>

With reduced motion: A still frame of snow hanging in the air at every depth, with no wind or fall.

Running cost: Up to 760 sprite draws per frame with no per-frame allocation; a light load that pauses whenever the hero leaves view.

Fireflies over a Dusk Meadow

Fireflies wander over a meadow at the end of the day, each one blinking on its own slow rhythm. The grass is a silhouette drawn once at load in two layers, and fireflies are split by depth: the far ones glow behind the grass, the near ones in front of it and larger. Good for camping, outdoor and summer event pages.

<!-- Fireflies over a Dusk Meadow: plain HTML, CSS and JavaScript. Paste it anywhere; the class names all start with "ffm-" so they will not clash. -->
<section class="ffm-hero">
  <canvas class="ffm-canvas" aria-hidden="true"></canvas>
  <div class="ffm-inner">
    <div class="ffm-copy">
      <p class="ffm-eyebrow">Wren Hollow Campground</p>
      <h2 class="ffm-title">Stay out past dark. The meadow lights up.</h2>
      <p class="ffm-body">Quiet walk-in sites along the creek, no generators after nine, and a ranger-led night walk on summer Fridays.</p>
      <a class="ffm-cta" href="#">Reserve a site</a>
    </div>
  </div>
</section>

<style>
  /* Colours to change: --ffm-bg (the backdrop), --ffm-text and --ffm-sub (copy), --ffm-btn and --ffm-btn-text (button).
     Particle colour, density and speed are the constants at the top of the script below. */
  .ffm-hero {
    --ffm-bg: linear-gradient(180deg, #273746 0%, #22332f 45%, #121d18 100%);
    --ffm-text: #f4f1de;
    --ffm-sub: #d5d3bf;
    --ffm-btn: #e1ea9b;
    --ffm-btn-text: #13201a;
    position: relative;
    isolation: isolate;
    display: flex;
    align-items: center;
    min-height: 560px;
    padding: 80px 24px;
    overflow: hidden;
    background: var(--ffm-bg);
    box-sizing: border-box;
  }
  .ffm-canvas { position: absolute; inset: 0; width: 100%; height: 100%; z-index: -1; pointer-events: none; }
  .ffm-inner { width: 100%; max-width: 72rem; margin: 0 auto; }
  .ffm-copy { max-width: 36rem; }
  .ffm-eyebrow { margin: 0; font-size: 0.875rem; font-weight: 600; letter-spacing: 0.14em; text-transform: uppercase; color: #dfe9a8; }
  .ffm-title { margin: 16px 0 0; font-size: clamp(2.25rem, 1.7rem + 2.2vw, 3rem); line-height: 1.08; font-weight: 600; letter-spacing: -0.025em; color: var(--ffm-text); }
  .ffm-body { margin: 20px 0 0; font-size: 1.125rem; line-height: 1.65; color: var(--ffm-sub); }
  .ffm-cta { display: inline-flex; align-items: center; height: 48px; margin-top: 32px; padding: 0 24px; border-radius: 999px; background: var(--ffm-btn); color: var(--ffm-btn-text); font-size: 0.875rem; font-weight: 600; text-decoration: none; }
  .ffm-cta:hover { opacity: 0.9; }
  .ffm-cta:focus-visible { outline: 2px solid #f4f1de; outline-offset: 3px; }
  @media (min-width: 640px) { .ffm-hero { padding: 80px 48px; } }
  /* With prefers-reduced-motion: reduce, the script paints one settled frame and never starts its loop. */
</style>

<script>
  (function () {
    // Values worth changing:
    const FLIES_PER_MPX = 120; // fireflies per million pixels
    const GLOW = "222,240,128"; // firefly light, as r,g,b
    const NEAR_GRASS = "#0a120e"; // front grass silhouette
    const FAR_GRASS = "#16241e"; // back grass silhouette
    const BLINK_RATE = 1; // 0.5 for lazy blinking, 2 for busy
    const WARMUP = 60; // frames simulated before the first paint

    function glow(size, rgb, stops) {
      const c = document.createElement("canvas");
      c.width = c.height = size;
      const g = c.getContext("2d");
      const r = size / 2;
      const grd = g.createRadialGradient(r, r, 0, r, r, r);
      for (const [o, a] of stops) grd.addColorStop(o, `rgba(${rgb},${a})`);
      g.fillStyle = grd;
      g.fillRect(0, 0, size, size);
      return c;
    }
    function mount(canvas, root) {
      const ctx = canvas.getContext("2d");
      if (!ctx) return () => {
      };
      const still = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
      const ptr = { x: -1e4, y: -1e4, on: false };
      let w = 0, h = 0, t = 0, last = 0, raf = 0, seen = true;
      let scene = null;
      const fit = () => {
        const r = canvas.getBoundingClientRect();
        if (scene && Math.abs(r.width - w) < 1 && Math.abs(r.height - h) < 1) return;
        const dpr = Math.min(window.devicePixelRatio || 1, 1.5);
        w = Math.max(1, r.width);
        h = Math.max(1, r.height);
        canvas.width = Math.round(w * dpr);
        canvas.height = Math.round(h * dpr);
        ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
        scene = createScene(w, h, ptr);
        for (let i = 0; i < WARMUP; i++) {
          t += 1 / 30;
          scene.update(1 / 30, t);
        }
        scene.draw(ctx, t);
      };
      const frame = (now) => {
        const dt = Math.min(0.05, Math.max(0, (now - last) / 1e3));
        last = now;
        t += dt;
        if (scene) {
          scene.update(dt, t);
          scene.draw(ctx, t);
        }
        raf = requestAnimationFrame(frame);
      };
      const run = () => {
        cancelAnimationFrame(raf);
        raf = 0;
        if (!still && seen && !document.hidden) {
          last = performance.now();
          raf = requestAnimationFrame(frame);
        }
      };
      const ro = new ResizeObserver(fit);
      ro.observe(canvas);
      const io = new IntersectionObserver((es) => {
        seen = es[es.length - 1].isIntersecting;
        run();
      });
      io.observe(canvas);
      document.addEventListener("visibilitychange", run);
      fit();
      run();
      return () => {
        cancelAnimationFrame(raf);
        ro.disconnect();
        io.disconnect();
        document.removeEventListener("visibilitychange", run);
      };
    }
    function createScene(w, h, _ptr) {
      const n = Math.max(14, Math.min(140, Math.round(w * h / 1e6 * FLIES_PER_MPX)));
      const light = glow(96, GLOW, [[0, 1], [0.06, 0.9], [0.18, 0.3], [0.5, 0.06], [1, 0]]);
      const grass = document.createElement("canvas");
      grass.width = Math.ceil(w);
      grass.height = Math.ceil(h);
      const g = grass.getContext("2d");
      g.lineCap = "round";
      const blades = (count, color, len) => {
        g.strokeStyle = color;
        g.fillStyle = color;
        g.fillRect(0, h - len * 0.12, w, len * 0.12 + 2);
        for (let i = 0; i < count; i++) {
          const x = Math.random() * w;
          const lh = len * (0.45 + Math.random() * 0.75);
          const bend = (Math.random() - 0.5) * lh * 0.7;
          g.lineWidth = 1 + Math.random() * 2;
          g.beginPath();
          g.moveTo(x, h + 2);
          g.quadraticCurveTo(x + bend * 0.2, h - lh * 0.6, x + bend, h - lh);
          g.stroke();
        }
      };
      blades(Math.round(w * 0.55), FAR_GRASS, h * 0.34);
      blades(Math.round(w * 0.9), NEAR_GRASS, h * 0.2);
      const ps = Array.from({ length: n }, () => ({
        x: Math.random() * w,
        y: h * (0.3 + Math.random() * 0.66),
        z: Math.random(),
        a: Math.random() * Math.PI * 2,
        ph: Math.random() * 9,
        per: 3 + Math.random() * 4
      }));
      const fly = (c, p, t) => {
        const k = (t * BLINK_RATE / p.per + p.ph) % 1;
        const on = k < 0.35 ? Math.sin(k / 0.35 * Math.PI) : 0;
        const b = 0.12 + 0.88 * on;
        const s = (14 + p.z * 40) * (0.7 + 0.3 * on);
        c.globalAlpha = b * (0.45 + p.z * 0.55);
        c.drawImage(light, p.x - s / 2, p.y - s / 2, s, s);
      };
      return {
        update(dt) {
          for (const p of ps) {
            p.a += (Math.random() - 0.5) * 3 * dt;
            const v = (8 + p.z * 14) * dt;
            p.x += Math.cos(p.a) * v;
            p.y += Math.sin(p.a) * v * 0.6;
            if (p.y < h * 0.25) p.a = Math.abs(p.a);
            if (p.y > h * 0.98) p.a = -Math.abs(p.a);
            if (p.x < -30) p.x += w + 60;
            else if (p.x > w + 30) p.x -= w + 60;
          }
        },
        draw(c, t) {
          c.clearRect(0, 0, w, h);
          c.globalCompositeOperation = "lighter";
          for (const p of ps) if (p.z < 0.45) fly(c, p, t);
          c.globalCompositeOperation = "source-over";
          c.globalAlpha = 1;
          c.drawImage(grass, 0, 0, w, h);
          c.globalCompositeOperation = "lighter";
          for (const p of ps) if (p.z >= 0.45) fly(c, p, t);
          c.globalCompositeOperation = "source-over";
          c.globalAlpha = 1;
        }
      };
    }

    document.querySelectorAll(".ffm-hero").forEach(function (root) {
      var canvas = root.querySelector(".ffm-canvas");
      if (!canvas || root.hasAttribute("data-ffm-on")) return;
      root.setAttribute("data-ffm-on", "");
      mount(canvas, root);
    });
  })();
</script>

With reduced motion: The meadow appears with fireflies frozen at different stages of their glow.

Running cost: One cached grass image plus up to 100 additive sprite draws per frame; very cheap, and the loop pauses off screen.

Floating Dandelion Seeds and Pollen

Late sun behind a summer meadow, with dandelion seeds floating across on a soft breeze. Each seed is a backlit crown of fine white filaments on a slender stalk, drawn once and then rotated as it bobs, among specks of glowing pollen. Seeds near the viewer are larger and softer. A warm, natural option for food, wellness and garden brands.

<!-- Floating Dandelion Seeds and Pollen: plain HTML, CSS and JavaScript. Paste it anywhere; the class names all start with "sdp-" so they will not clash. -->
<section class="sdp-hero">
  <canvas class="sdp-canvas" aria-hidden="true"></canvas>
  <div class="sdp-scrim" aria-hidden="true"></div>
  <div class="sdp-inner">
    <div class="sdp-copy">
      <p class="sdp-eyebrow">Fieldnote Botanical Tea</p>
      <h2 class="sdp-title">Meadow herbs, dried slowly and blended by hand.</h2>
      <p class="sdp-body">Chamomile, nettle and lemon balm from small farms nearby, sold loose leaf and shipped in plain paper.</p>
      <a class="sdp-cta" href="#">Shop the spring blends</a>
    </div>
  </div>
</section>

<style>
  /* Colours to change: --sdp-bg (the backdrop), --sdp-text and --sdp-sub (copy), --sdp-btn and --sdp-btn-text (button).
     Particle colour, density and speed are the constants at the top of the script below. */
  .sdp-hero {
    --sdp-bg: radial-gradient(60% 70% at 88% 5%, #f4e3a8 0%, rgba(244,227,168,0) 70%), linear-gradient(180deg, #c9c690 0%, #a9b47a 55%, #8b9a5e 100%);
    --sdp-text: #26301c;
    --sdp-sub: #3b4229;
    --sdp-btn: #3f5a2a;
    --sdp-btn-text: #fbf7ea;
    position: relative;
    isolation: isolate;
    display: flex;
    align-items: center;
    min-height: 560px;
    padding: 80px 24px;
    overflow: hidden;
    background: var(--sdp-bg);
    box-sizing: border-box;
  }
  .sdp-canvas, .sdp-scrim { position: absolute; inset: 0; width: 100%; height: 100%; z-index: -1; pointer-events: none; }
  .sdp-scrim { background: radial-gradient(52% 72% at 20% 50%, rgba(238,234,204,0.9) 0%, rgba(238,234,204,0) 78%); }
  .sdp-inner { width: 100%; max-width: 72rem; margin: 0 auto; }
  .sdp-copy { max-width: 36rem; }
  .sdp-eyebrow { margin: 0; font-size: 0.875rem; font-weight: 600; letter-spacing: 0.14em; text-transform: uppercase; color: #3f4d1f; }
  .sdp-title { margin: 16px 0 0; font-size: clamp(2.25rem, 1.7rem + 2.2vw, 3rem); line-height: 1.08; font-weight: 600; letter-spacing: -0.025em; color: var(--sdp-text); }
  .sdp-body { margin: 20px 0 0; font-size: 1.125rem; line-height: 1.65; color: var(--sdp-sub); }
  .sdp-cta { display: inline-flex; align-items: center; height: 48px; margin-top: 32px; padding: 0 24px; border-radius: 999px; background: var(--sdp-btn); color: var(--sdp-btn-text); font-size: 0.875rem; font-weight: 600; text-decoration: none; }
  .sdp-cta:hover { opacity: 0.9; }
  .sdp-cta:focus-visible { outline: 2px solid #26301c; outline-offset: 3px; }
  @media (min-width: 640px) { .sdp-hero { padding: 80px 48px; } }
  /* With prefers-reduced-motion: reduce, the script paints one settled frame and never starts its loop. */
</style>

<script>
  (function () {
    // Values worth changing:
    const SEEDS_PER_MPX = 30; // dandelion seeds per million pixels
    const POLLEN_PER_MPX = 200; // tiny pollen grains per million pixels
    const FILAMENT = "255,253,244"; // seed fluff colour, as r,g,b (backlit, so near white)
    const POLLEN = "255,232,150"; // pollen colour
    const BREEZE = 1; // 0.5 for a still afternoon, 2 for a windy one
    const WARMUP = 120; // frames simulated before the first paint

    function glow(size, rgb, stops) {
      const c = document.createElement("canvas");
      c.width = c.height = size;
      const g = c.getContext("2d");
      const r = size / 2;
      const grd = g.createRadialGradient(r, r, 0, r, r, r);
      for (const [o, a] of stops) grd.addColorStop(o, `rgba(${rgb},${a})`);
      g.fillStyle = grd;
      g.fillRect(0, 0, size, size);
      return c;
    }
    function mount(canvas, root) {
      const ctx = canvas.getContext("2d");
      if (!ctx) return () => {
      };
      const still = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
      const ptr = { x: -1e4, y: -1e4, on: false };
      let w = 0, h = 0, t = 0, last = 0, raf = 0, seen = true;
      let scene = null;
      const fit = () => {
        const r = canvas.getBoundingClientRect();
        if (scene && Math.abs(r.width - w) < 1 && Math.abs(r.height - h) < 1) return;
        const dpr = Math.min(window.devicePixelRatio || 1, 1.5);
        w = Math.max(1, r.width);
        h = Math.max(1, r.height);
        canvas.width = Math.round(w * dpr);
        canvas.height = Math.round(h * dpr);
        ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
        scene = createScene(w, h, ptr);
        for (let i = 0; i < WARMUP; i++) {
          t += 1 / 30;
          scene.update(1 / 30, t);
        }
        scene.draw(ctx, t);
      };
      const frame = (now) => {
        const dt = Math.min(0.05, Math.max(0, (now - last) / 1e3));
        last = now;
        t += dt;
        if (scene) {
          scene.update(dt, t);
          scene.draw(ctx, t);
        }
        raf = requestAnimationFrame(frame);
      };
      const run = () => {
        cancelAnimationFrame(raf);
        raf = 0;
        if (!still && seen && !document.hidden) {
          last = performance.now();
          raf = requestAnimationFrame(frame);
        }
      };
      const ro = new ResizeObserver(fit);
      ro.observe(canvas);
      const io = new IntersectionObserver((es) => {
        seen = es[es.length - 1].isIntersecting;
        run();
      });
      io.observe(canvas);
      document.addEventListener("visibilitychange", run);
      fit();
      run();
      return () => {
        cancelAnimationFrame(raf);
        ro.disconnect();
        io.disconnect();
        document.removeEventListener("visibilitychange", run);
      };
    }
    function createScene(w, h, _ptr) {
      const area = w * h / 1e6;
      const seed = document.createElement("canvas");
      seed.width = seed.height = 96;
      const q = seed.getContext("2d");
      q.translate(48, 40);
      q.lineCap = "round";
      const crown = q.createRadialGradient(0, -6, 0, 0, -6, 34);
      crown.addColorStop(0, `rgba(${FILAMENT},0.32)`);
      crown.addColorStop(1, `rgba(${FILAMENT},0)`);
      q.fillStyle = crown;
      q.fillRect(-40, -40, 80, 70);
      for (let i = 0; i < 44; i++) {
        const a = Math.PI * 0.82 + i / 43 * Math.PI * 1.36;
        const len = 20 + Math.random() * 12;
        q.strokeStyle = `rgba(${FILAMENT},0.7)`;
        q.lineWidth = 0.7;
        q.beginPath();
        q.moveTo(0, 0);
        q.quadraticCurveTo(Math.cos(a) * len * 0.5, Math.sin(a) * len * 0.4, Math.cos(a) * len, Math.sin(a) * len * 0.8);
        q.stroke();
        q.fillStyle = `rgba(${FILAMENT},0.9)`;
        q.beginPath();
        q.arc(Math.cos(a) * len, Math.sin(a) * len * 0.8, 1.1, 0, 7);
        q.fill();
      }
      q.strokeStyle = "rgba(120,104,70,0.6)";
      q.lineWidth = 0.9;
      q.beginPath();
      q.moveTo(0, 0);
      q.lineTo(0, 30);
      q.stroke();
      q.fillStyle = "rgba(110,86,52,0.85)";
      q.beginPath();
      q.ellipse(0, 34, 1.6, 4.5, 0, 0, 7);
      q.fill();
      const grain = glow(16, POLLEN, [[0, 1], [0.35, 0.8], [1, 0]]);
      const seeds = Array.from({ length: Math.max(6, Math.round(area * SEEDS_PER_MPX)) }, () => ({
        x: Math.random() * w,
        y: Math.random() * h,
        z: Math.random(),
        r: (Math.random() - 0.5) * 0.6,
        s: Math.random() * 99
      }));
      const dust = Array.from({ length: Math.min(500, Math.round(area * POLLEN_PER_MPX)) }, () => ({
        x: Math.random() * w,
        y: Math.random() * h,
        z: Math.random(),
        s: Math.random() * 99
      }));
      let breeze = 0;
      return {
        update(dt, t) {
          breeze = (14 + Math.sin(t * 0.17) * 10 + Math.sin(t * 0.43) * 6) * BREEZE;
          for (const p of seeds) {
            const k = 0.4 + p.z;
            p.x += (breeze + Math.sin(t * 0.6 + p.s) * 8) * k * dt;
            p.y += (-6 + Math.sin(t * 0.9 + p.s * 1.3) * 10) * k * dt;
            p.r = Math.sin(t * 0.5 + p.s) * 0.35;
            if (p.x > w + 80) {
              p.x = -80;
              p.y = Math.random() * h;
            }
            if (p.y < -80) p.y = h + 80;
            else if (p.y > h + 80) p.y = -80;
          }
          for (const p of dust) {
            p.x += (breeze * 0.7 + Math.sin(t + p.s) * 6) * (0.3 + p.z) * dt;
            p.y += Math.cos(t * 0.8 + p.s) * 7 * dt - 2 * dt;
            if (p.x > w + 10) p.x = -10;
            if (p.y < -10) p.y = h + 10;
            else if (p.y > h + 10) p.y = -10;
          }
        },
        draw(c, t) {
          c.clearRect(0, 0, w, h);
          for (const p of dust) {
            const s = 1.5 + p.z * 3.5;
            c.globalAlpha = (0.25 + p.z * 0.5) * (0.6 + 0.4 * Math.sin(t * 2 + p.s));
            c.drawImage(grain, p.x - s / 2, p.y - s / 2, s, s);
          }
          for (const p of seeds) {
            const s = 34 + p.z * 70;
            c.globalAlpha = p.z > 0.85 ? 0.6 : 0.7 + p.z * 0.3;
            c.save();
            c.translate(p.x, p.y);
            c.rotate(p.r);
            c.drawImage(seed, -s / 2, -s * 0.42, s, s);
            c.restore();
          }
          c.globalAlpha = 1;
        }
      };
    }

    document.querySelectorAll(".sdp-hero").forEach(function (root) {
      var canvas = root.querySelector(".sdp-canvas");
      if (!canvas || root.hasAttribute("data-sdp-on")) return;
      root.setAttribute("data-sdp-on", "");
      mount(canvas, root);
    });
  })();
</script>

With reduced motion: Seeds and pollen rest in the air in one calm composition with no drift.

Running cost: Up to a few dozen rotated seed images and 500 pollen sprites per frame; light, and stopped when off screen.

Glowing Plankton in Deep Water

Deep water lit by the creatures in it. Hundreds of plankton specks drift on slow currents with a dim pulse, and any one of them can flash bright and fade, the way real bioluminescence reacts to movement. Large, faint blue glows wander past, marine snow sinks through the frame and pale rays reach down from the surface.

<!-- Glowing Plankton in Deep Water: plain HTML, CSS and JavaScript. Paste it anywhere; the class names all start with "plk-" so they will not clash. -->
<section class="plk-hero">
  <canvas class="plk-canvas" aria-hidden="true"></canvas>
  <div class="plk-inner">
    <div class="plk-copy">
      <p class="plk-eyebrow">Tidewater Night Dives</p>
      <h2 class="plk-title">Turn off your torch and watch the water glow.</h2>
      <p class="plk-body">Guided night dives in small groups, with gear fitted before you board and an instructor beside you the whole way down.</p>
      <a class="plk-cta" href="#">Pick a dive date</a>
    </div>
  </div>
</section>

<style>
  /* Colours to change: --plk-bg (the backdrop), --plk-text and --plk-sub (copy), --plk-btn and --plk-btn-text (button).
     Particle colour, density and speed are the constants at the top of the script below. */
  .plk-hero {
    --plk-bg: radial-gradient(90% 60% at 50% -10%, #0e4450 0%, rgba(14,68,80,0) 70%), linear-gradient(180deg, #052029 0%, #03141a 55%, #020a0d 100%);
    --plk-text: #e6fbf7;
    --plk-sub: #a9d3cf;
    --plk-btn: #7be0cf;
    --plk-btn-text: #03181c;
    position: relative;
    isolation: isolate;
    display: flex;
    align-items: center;
    min-height: 560px;
    padding: 80px 24px;
    overflow: hidden;
    background: var(--plk-bg);
    box-sizing: border-box;
  }
  .plk-canvas { position: absolute; inset: 0; width: 100%; height: 100%; z-index: -1; pointer-events: none; }
  .plk-inner { width: 100%; max-width: 72rem; margin: 0 auto; }
  .plk-copy { max-width: 36rem; }
  .plk-eyebrow { margin: 0; font-size: 0.875rem; font-weight: 600; letter-spacing: 0.14em; text-transform: uppercase; color: #8be6d6; }
  .plk-title { margin: 16px 0 0; font-size: clamp(2.25rem, 1.7rem + 2.2vw, 3rem); line-height: 1.08; font-weight: 600; letter-spacing: -0.025em; color: var(--plk-text); }
  .plk-body { margin: 20px 0 0; font-size: 1.125rem; line-height: 1.65; color: var(--plk-sub); }
  .plk-cta { display: inline-flex; align-items: center; height: 48px; margin-top: 32px; padding: 0 24px; border-radius: 999px; background: var(--plk-btn); color: var(--plk-btn-text); font-size: 0.875rem; font-weight: 600; text-decoration: none; }
  .plk-cta:hover { opacity: 0.9; }
  .plk-cta:focus-visible { outline: 2px solid #e6fbf7; outline-offset: 3px; }
  @media (min-width: 640px) { .plk-hero { padding: 80px 48px; } }
  /* With prefers-reduced-motion: reduce, the script paints one settled frame and never starts its loop. */
</style>

<script>
  (function () {
    // Values worth changing:
    const PLANKTON_PER_MPX = 320; // glowing plankton per million pixels
    const GLOW = "110,245,220"; // bioluminescent colour, as r,g,b
    const JELLY = "96,160,255"; // the few large, dim drifting glows
    const FLASH_CHANCE = 0.06; // chance per second that a speck flashes
    const CURRENT = 1; // water movement speed
    const WARMUP = 90; // frames simulated before the first paint

    function glow(size, rgb, stops) {
      const c = document.createElement("canvas");
      c.width = c.height = size;
      const g = c.getContext("2d");
      const r = size / 2;
      const grd = g.createRadialGradient(r, r, 0, r, r, r);
      for (const [o, a] of stops) grd.addColorStop(o, `rgba(${rgb},${a})`);
      g.fillStyle = grd;
      g.fillRect(0, 0, size, size);
      return c;
    }
    function mount(canvas, root) {
      const ctx = canvas.getContext("2d");
      if (!ctx) return () => {
      };
      const still = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
      const ptr = { x: -1e4, y: -1e4, on: false };
      let w = 0, h = 0, t = 0, last = 0, raf = 0, seen = true;
      let scene = null;
      const fit = () => {
        const r = canvas.getBoundingClientRect();
        if (scene && Math.abs(r.width - w) < 1 && Math.abs(r.height - h) < 1) return;
        const dpr = Math.min(window.devicePixelRatio || 1, 1.5);
        w = Math.max(1, r.width);
        h = Math.max(1, r.height);
        canvas.width = Math.round(w * dpr);
        canvas.height = Math.round(h * dpr);
        ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
        scene = createScene(w, h, ptr);
        for (let i = 0; i < WARMUP; i++) {
          t += 1 / 30;
          scene.update(1 / 30, t);
        }
        scene.draw(ctx, t);
      };
      const frame = (now) => {
        const dt = Math.min(0.05, Math.max(0, (now - last) / 1e3));
        last = now;
        t += dt;
        if (scene) {
          scene.update(dt, t);
          scene.draw(ctx, t);
        }
        raf = requestAnimationFrame(frame);
      };
      const run = () => {
        cancelAnimationFrame(raf);
        raf = 0;
        if (!still && seen && !document.hidden) {
          last = performance.now();
          raf = requestAnimationFrame(frame);
        }
      };
      const ro = new ResizeObserver(fit);
      ro.observe(canvas);
      const io = new IntersectionObserver((es) => {
        seen = es[es.length - 1].isIntersecting;
        run();
      });
      io.observe(canvas);
      document.addEventListener("visibilitychange", run);
      fit();
      run();
      return () => {
        cancelAnimationFrame(raf);
        ro.disconnect();
        io.disconnect();
        document.removeEventListener("visibilitychange", run);
      };
    }
    function createScene(w, h, _ptr) {
      const area = w * h / 1e6;
      const speck = glow(32, GLOW, [[0, 1], [0.12, 0.85], [0.35, 0.2], [1, 0]]);
      const jelly = glow(128, JELLY, [[0, 0.22], [0.5, 0.08], [1, 0]]);
      const snow = glow(8, "200,220,225", [[0, 0.9], [1, 0]]);
      const rays = document.createElement("canvas");
      rays.width = Math.ceil(w / 2);
      rays.height = Math.ceil(h / 2);
      const r = rays.getContext("2d");
      r.scale(0.5, 0.5);
      for (let i = 0; i < 7; i++) {
        const x = w * (0.1 + Math.random() * 0.8), wd = 20 + Math.random() * 70;
        const g = r.createLinearGradient(0, 0, 0, h * 0.8);
        g.addColorStop(0, "rgba(140,220,230,0.07)");
        g.addColorStop(1, "rgba(140,220,230,0)");
        r.fillStyle = g;
        r.beginPath();
        r.moveTo(x, 0);
        r.lineTo(x + wd, 0);
        r.lineTo(x + wd * 2.4 - w * 0.12, h * 0.8);
        r.lineTo(x - w * 0.12, h * 0.8);
        r.fill();
      }
      const ps = Array.from({ length: Math.min(600, Math.round(area * PLANKTON_PER_MPX)) }, () => ({
        x: Math.random() * w,
        y: Math.random() * h,
        z: Math.random(),
        s: Math.random() * 99,
        f: 0
      }));
      const big = Array.from({ length: Math.max(3, Math.round(area * 8)) }, () => ({ x: Math.random() * w, y: Math.random() * h, z: Math.random(), s: Math.random() * 99 }));
      const fall = Array.from({ length: Math.min(200, Math.round(area * 120)) }, () => ({ x: Math.random() * w, y: Math.random() * h, z: Math.random() }));
      return {
        update(dt, t) {
          for (const p of ps) {
            const k = (0.3 + p.z) * CURRENT;
            p.x += (Math.sin(t * 0.2 + p.y * 4e-3 + p.s) * 9 + 3) * k * dt;
            p.y += Math.cos(t * 0.15 + p.x * 3e-3 + p.s) * 6 * k * dt;
            p.f = Math.max(0, p.f - dt * 0.9);
            if (Math.random() < FLASH_CHANCE * dt) p.f = 1;
            if (p.x > w + 20) p.x = -20;
            else if (p.x < -20) p.x = w + 20;
            if (p.y > h + 20) p.y = -20;
            else if (p.y < -20) p.y = h + 20;
          }
          for (const b of big) {
            b.x += (4 + Math.sin(t * 0.1 + b.s) * 3) * CURRENT * dt;
            b.y += Math.sin(t * 0.25 + b.s) * 5 * dt;
            if (b.x > w + 150) b.x = -150;
          }
          for (const m of fall) {
            m.y += (6 + m.z * 10) * dt;
            m.x += Math.sin(t * 0.4 + m.y * 0.02) * 4 * dt;
            if (m.y > h + 5) {
              m.y = -5;
              m.x = Math.random() * w;
            }
          }
        },
        draw(c, t) {
          c.clearRect(0, 0, w, h);
          c.globalAlpha = 0.75 + 0.25 * Math.sin(t * 0.3);
          c.drawImage(rays, Math.sin(t * 0.12) * 18, 0, w, h);
          for (const m of fall) {
            c.globalAlpha = 0.15 + m.z * 0.3;
            const s = 1.5 + m.z * 2;
            c.drawImage(snow, m.x, m.y, s, s);
          }
          c.globalCompositeOperation = "lighter";
          for (const b of big) {
            const s = 120 + b.z * 200 + Math.sin(t * 0.6 + b.s) * 20;
            c.globalAlpha = 0.6 + 0.4 * Math.sin(t * 0.4 + b.s);
            c.drawImage(jelly, b.x - s / 2, b.y - s / 2, s, s);
          }
          for (const p of ps) {
            const pulse = 0.5 + 0.5 * Math.sin(t * 0.8 + p.s);
            const s = (3 + p.z * 7) * (1 + p.f * 1.8);
            c.globalAlpha = Math.min(1, (0.1 + 0.25 * pulse) * (0.4 + p.z) + p.f * 0.9);
            c.drawImage(speck, p.x - s / 2, p.y - s / 2, s, s);
          }
          c.globalAlpha = 1;
          c.globalCompositeOperation = "source-over";
        }
      };
    }

    document.querySelectorAll(".plk-hero").forEach(function (root) {
      var canvas = root.querySelector(".plk-canvas");
      if (!canvas || root.hasAttribute("data-plk-on")) return;
      root.setAttribute("data-plk-on", "");
      mount(canvas, root);
    });
  })();
</script>

With reduced motion: A still frame of glowing water with specks, faint rays and a few large glows in place.

Running cost: Three sprite types, up to about 800 draws per frame plus one cached ray image; moderate, and paused off screen.

Out-of-Focus Bokeh Lights

String lights seen through a wide-open lens. Each light is a disc with a slightly brighter rim, the way real lens bokeh looks, rather than a fuzzy dot. Distant lights are small and crisp, close ones are large and softer, and the whole frame slowly breathes in and out of focus as the lights drift. Suits weddings, events, hospitality and photography.

<!-- Out-of-Focus Bokeh Lights: plain HTML, CSS and JavaScript. Paste it anywhere; the class names all start with "bkh-" so they will not clash. -->
<section class="bkh-hero">
  <canvas class="bkh-canvas" aria-hidden="true"></canvas>
  <div class="bkh-scrim" aria-hidden="true"></div>
  <div class="bkh-inner">
    <div class="bkh-copy">
      <p class="bkh-eyebrow">Lamplight Wedding Films</p>
      <h2 class="bkh-title">The first dance, the toasts and the light after sunset.</h2>
      <p class="bkh-body">Two filmmakers, natural sound and a finished film you will want to watch again every anniversary.</p>
      <a class="bkh-cta" href="#">Check our availability</a>
    </div>
  </div>
</section>

<style>
  /* Colours to change: --bkh-bg (the backdrop), --bkh-text and --bkh-sub (copy), --bkh-btn and --bkh-btn-text (button).
     Particle colour, density and speed are the constants at the top of the script below. */
  .bkh-hero {
    --bkh-bg: radial-gradient(80% 60% at 50% 120%, #3a2a20 0%, rgba(58,42,32,0) 70%), linear-gradient(180deg, #0c131d 0%, #141c28 55%, #1d1c22 100%);
    --bkh-text: #fdf3e7;
    --bkh-sub: #dccab6;
    --bkh-btn: #f3c68b;
    --bkh-btn-text: #1d140c;
    position: relative;
    isolation: isolate;
    display: flex;
    align-items: center;
    min-height: 560px;
    padding: 80px 24px;
    overflow: hidden;
    background: var(--bkh-bg);
    box-sizing: border-box;
  }
  .bkh-canvas, .bkh-scrim { position: absolute; inset: 0; width: 100%; height: 100%; z-index: -1; pointer-events: none; }
  .bkh-scrim { background: radial-gradient(45% 55% at 50% 50%, rgba(12,19,29,0.6) 0%, rgba(12,19,29,0) 80%); }
  .bkh-inner { width: 100%; max-width: 72rem; margin: 0 auto; }
  .bkh-copy { max-width: 42rem; margin: 0 auto; text-align: center; }
  .bkh-eyebrow { margin: 0; font-size: 0.875rem; font-weight: 600; letter-spacing: 0.14em; text-transform: uppercase; color: #f0c992; }
  .bkh-title { margin: 16px 0 0; font-size: clamp(2.25rem, 1.7rem + 2.2vw, 3rem); line-height: 1.08; font-weight: 600; letter-spacing: -0.025em; color: var(--bkh-text); }
  .bkh-body { margin: 20px 0 0; font-size: 1.125rem; line-height: 1.65; color: var(--bkh-sub); }
  .bkh-cta { display: inline-flex; align-items: center; height: 48px; margin-top: 32px; padding: 0 24px; border-radius: 999px; background: var(--bkh-btn); color: var(--bkh-btn-text); font-size: 0.875rem; font-weight: 600; text-decoration: none; }
  .bkh-cta:hover { opacity: 0.9; }
  .bkh-cta:focus-visible { outline: 2px solid #fdf3e7; outline-offset: 3px; }
  @media (min-width: 640px) { .bkh-hero { padding: 80px 48px; } }
  /* With prefers-reduced-motion: reduce, the script paints one settled frame and never starts its loop. */
</style>

<script>
  (function () {
    // Values worth changing:
    const LIGHTS_PER_MPX = 48; // out-of-focus lights per million pixels
    const COLOURS = ["255,186,104", "255,226,182", "238,142,118", "150,196,214"]; // light colours, as r,g,b
    const DRIFT = 1; // how fast the lights float
    const FOCUS_BREATH = 1; // how much the lens seems to breathe in and out of focus
    const WARMUP = 1; // frames simulated before the first paint

    function glow(size, rgb, stops) {
      const c = document.createElement("canvas");
      c.width = c.height = size;
      const g = c.getContext("2d");
      const r = size / 2;
      const grd = g.createRadialGradient(r, r, 0, r, r, r);
      for (const [o, a] of stops) grd.addColorStop(o, `rgba(${rgb},${a})`);
      g.fillStyle = grd;
      g.fillRect(0, 0, size, size);
      return c;
    }
    function mount(canvas, root) {
      const ctx = canvas.getContext("2d");
      if (!ctx) return () => {
      };
      const still = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
      const ptr = { x: -1e4, y: -1e4, on: false };
      let w = 0, h = 0, t = 0, last = 0, raf = 0, seen = true;
      let scene = null;
      const fit = () => {
        const r = canvas.getBoundingClientRect();
        if (scene && Math.abs(r.width - w) < 1 && Math.abs(r.height - h) < 1) return;
        const dpr = Math.min(window.devicePixelRatio || 1, 1.5);
        w = Math.max(1, r.width);
        h = Math.max(1, r.height);
        canvas.width = Math.round(w * dpr);
        canvas.height = Math.round(h * dpr);
        ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
        scene = createScene(w, h, ptr);
        for (let i = 0; i < WARMUP; i++) {
          t += 1 / 30;
          scene.update(1 / 30, t);
        }
        scene.draw(ctx, t);
      };
      const frame = (now) => {
        const dt = Math.min(0.05, Math.max(0, (now - last) / 1e3));
        last = now;
        t += dt;
        if (scene) {
          scene.update(dt, t);
          scene.draw(ctx, t);
        }
        raf = requestAnimationFrame(frame);
      };
      const run = () => {
        cancelAnimationFrame(raf);
        raf = 0;
        if (!still && seen && !document.hidden) {
          last = performance.now();
          raf = requestAnimationFrame(frame);
        }
      };
      const ro = new ResizeObserver(fit);
      ro.observe(canvas);
      const io = new IntersectionObserver((es) => {
        seen = es[es.length - 1].isIntersecting;
        run();
      });
      io.observe(canvas);
      document.addEventListener("visibilitychange", run);
      fit();
      run();
      return () => {
        cancelAnimationFrame(raf);
        ro.disconnect();
        io.disconnect();
        document.removeEventListener("visibilitychange", run);
      };
    }
    function createScene(w, h, _ptr) {
      const area = w * h / 1e6;
      const disc = (rgb, edge) => {
        const c = document.createElement("canvas");
        c.width = c.height = 128;
        const g = c.getContext("2d");
        const grd = g.createRadialGradient(64, 64, 0, 64, 64, 62);
        grd.addColorStop(0, `rgba(${rgb},0.30)`);
        grd.addColorStop(0.78, `rgba(${rgb},0.36)`);
        grd.addColorStop(0.93 - edge, `rgba(${rgb},0.62)`);
        grd.addColorStop(1, `rgba(${rgb},0)`);
        g.fillStyle = grd;
        g.beginPath();
        g.arc(64, 64, 62, 0, 7);
        g.fill();
        return c;
      };
      const sharp = COLOURS.map((rgb) => disc(rgb, 0));
      const soft = COLOURS.map((rgb) => glow(128, rgb, [[0, 0.3], [0.6, 0.22], [1, 0]]));
      const ps = Array.from({ length: Math.max(14, Math.min(90, Math.round(area * LIGHTS_PER_MPX))) }, () => {
        const z = Math.random();
        return { x: Math.random() * w, y: h * (0.15 + Math.random() * 0.9), z, k: Math.floor(Math.random() * COLOURS.length), s: Math.random() * 99, a: 0.35 + Math.random() * 0.55 };
      }).sort((a, b) => a.z - b.z);
      return {
        update(dt, t) {
          for (const p of ps) {
            p.x += (3 + p.z * 9) * DRIFT * dt;
            p.y += Math.sin(t * 0.2 + p.s) * 3 * DRIFT * dt;
            if (p.x > w + 120) {
              p.x = -120;
              p.y = h * (0.15 + Math.random() * 0.9);
            }
          }
        },
        draw(c, t) {
          c.clearRect(0, 0, w, h);
          c.globalCompositeOperation = "lighter";
          const focus = Math.sin(t * 0.18) * FOCUS_BREATH;
          for (const p of ps) {
            const s = (24 + p.z * 120) * (1 + focus * 0.1 * p.z);
            c.globalAlpha = p.a * (0.55 + 0.45 * Math.sin(t * 0.5 + p.s)) * (1 - p.z * 0.35);
            c.drawImage(p.z > 0.7 ? soft[p.k] : sharp[p.k], p.x - s / 2, p.y - s / 2, s, s);
          }
          c.globalAlpha = 1;
          c.globalCompositeOperation = "source-over";
        }
      };
    }

    document.querySelectorAll(".bkh-hero").forEach(function (root) {
      var canvas = root.querySelector(".bkh-canvas");
      if (!canvas || root.hasAttribute("data-bkh-on")) return;
      root.setAttribute("data-bkh-on", "");
      mount(canvas, root);
    });
  })();
</script>

With reduced motion: The lights hold still in one soft, warm composition with no drift or focus change.

Running cost: Under 90 large additive sprite draws per frame from cached discs; cheap on CPU, with some fill cost on very wide screens.

Parallax Starfield with Slow Drift

A night sky that pans slowly sideways. Stars are tinted from blue-white to warm orange and move at speeds set by their depth, so near stars pass faster than far ones. Behind them a faint band of the galaxy, made of clouds and dense dust, drifts even more slowly and tiles without a seam. Small stars twinkle; the brightest carry a soft halo.

<!-- Parallax Starfield with Slow Drift: plain HTML, CSS and JavaScript. Paste it anywhere; the class names all start with "sfp-" so they will not clash. -->
<section class="sfp-hero">
  <canvas class="sfp-canvas" aria-hidden="true"></canvas>
  <div class="sfp-scrim" aria-hidden="true"></div>
  <div class="sfp-inner">
    <div class="sfp-copy">
      <p class="sfp-eyebrow">Blackrock Dark Sky Tours</p>
      <h2 class="sfp-title">Leave the city lights two hours behind.</h2>
      <p class="sfp-body">Telescope evenings on a protected ridge, with hot drinks, reclining chairs and a guide who can name what you are looking at.</p>
      <a class="sfp-cta" href="#">See open nights</a>
    </div>
  </div>
</section>

<style>
  /* Colours to change: --sfp-bg (the backdrop), --sfp-text and --sfp-sub (copy), --sfp-btn and --sfp-btn-text (button).
     Particle colour, density and speed are the constants at the top of the script below. */
  .sfp-hero {
    --sfp-bg: linear-gradient(180deg, #03050b 0%, #070b17 60%, #0c1222 100%);
    --sfp-text: #eef2ff;
    --sfp-sub: #bac3dc;
    --sfp-btn: #f1e7cf;
    --sfp-btn-text: #0b1020;
    position: relative;
    isolation: isolate;
    display: flex;
    align-items: center;
    min-height: 560px;
    padding: 80px 24px;
    overflow: hidden;
    background: var(--sfp-bg);
    box-sizing: border-box;
  }
  .sfp-canvas, .sfp-scrim { position: absolute; inset: 0; width: 100%; height: 100%; z-index: -1; pointer-events: none; }
  .sfp-scrim { background: radial-gradient(50% 70% at 20% 50%, rgba(4,6,12,0.55) 0%, rgba(4,6,12,0) 80%); }
  .sfp-inner { width: 100%; max-width: 72rem; margin: 0 auto; }
  .sfp-copy { max-width: 36rem; }
  .sfp-eyebrow { margin: 0; font-size: 0.875rem; font-weight: 600; letter-spacing: 0.14em; text-transform: uppercase; color: #e9dcb8; }
  .sfp-title { margin: 16px 0 0; font-size: clamp(2.25rem, 1.7rem + 2.2vw, 3rem); line-height: 1.08; font-weight: 600; letter-spacing: -0.025em; color: var(--sfp-text); }
  .sfp-body { margin: 20px 0 0; font-size: 1.125rem; line-height: 1.65; color: var(--sfp-sub); }
  .sfp-cta { display: inline-flex; align-items: center; height: 48px; margin-top: 32px; padding: 0 24px; border-radius: 999px; background: var(--sfp-btn); color: var(--sfp-btn-text); font-size: 0.875rem; font-weight: 600; text-decoration: none; }
  .sfp-cta:hover { opacity: 0.9; }
  .sfp-cta:focus-visible { outline: 2px solid #eef2ff; outline-offset: 3px; }
  @media (min-width: 640px) { .sfp-hero { padding: 80px 48px; } }
  /* With prefers-reduced-motion: reduce, the script paints one settled frame and never starts its loop. */
</style>

<script>
  (function () {
    // Values worth changing:
    const STARS_PER_MPX = 700; // moving stars per million pixels
    const TINTS = ["206,220,255", "255,246,232", "255,216,172", "186,206,255"]; // star colours, hot to cool
    const DRIFT = 1; // sideways drift speed: 0 to hold still, 3 for a slow pan
    const TWINKLE = 1; // twinkle strength, 0 to 1
    const WARMUP = 1; // frames simulated before the first paint

    function glow(size, rgb, stops) {
      const c = document.createElement("canvas");
      c.width = c.height = size;
      const g = c.getContext("2d");
      const r = size / 2;
      const grd = g.createRadialGradient(r, r, 0, r, r, r);
      for (const [o, a] of stops) grd.addColorStop(o, `rgba(${rgb},${a})`);
      g.fillStyle = grd;
      g.fillRect(0, 0, size, size);
      return c;
    }
    function mount(canvas, root) {
      const ctx = canvas.getContext("2d");
      if (!ctx) return () => {
      };
      const still = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
      const ptr = { x: -1e4, y: -1e4, on: false };
      let w = 0, h = 0, t = 0, last = 0, raf = 0, seen = true;
      let scene = null;
      const fit = () => {
        const r = canvas.getBoundingClientRect();
        if (scene && Math.abs(r.width - w) < 1 && Math.abs(r.height - h) < 1) return;
        const dpr = Math.min(window.devicePixelRatio || 1, 1.5);
        w = Math.max(1, r.width);
        h = Math.max(1, r.height);
        canvas.width = Math.round(w * dpr);
        canvas.height = Math.round(h * dpr);
        ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
        scene = createScene(w, h, ptr);
        for (let i = 0; i < WARMUP; i++) {
          t += 1 / 30;
          scene.update(1 / 30, t);
        }
        scene.draw(ctx, t);
      };
      const frame = (now) => {
        const dt = Math.min(0.05, Math.max(0, (now - last) / 1e3));
        last = now;
        t += dt;
        if (scene) {
          scene.update(dt, t);
          scene.draw(ctx, t);
        }
        raf = requestAnimationFrame(frame);
      };
      const run = () => {
        cancelAnimationFrame(raf);
        raf = 0;
        if (!still && seen && !document.hidden) {
          last = performance.now();
          raf = requestAnimationFrame(frame);
        }
      };
      const ro = new ResizeObserver(fit);
      ro.observe(canvas);
      const io = new IntersectionObserver((es) => {
        seen = es[es.length - 1].isIntersecting;
        run();
      });
      io.observe(canvas);
      document.addEventListener("visibilitychange", run);
      fit();
      run();
      return () => {
        cancelAnimationFrame(raf);
        ro.disconnect();
        io.disconnect();
        document.removeEventListener("visibilitychange", run);
      };
    }
    function createScene(w, h, _ptr) {
      const area = w * h / 1e6;
      const sprites = TINTS.map((rgb) => glow(24, rgb, [[0, 1], [0.2, 0.85], [0.45, 0.15], [1, 0]]));
      const halos = TINTS.map((rgb) => glow(64, rgb, [[0, 0.35], [0.3, 0.08], [1, 0]]));
      const band = document.createElement("canvas");
      band.width = Math.ceil(w);
      band.height = Math.ceil(h);
      const g = band.getContext("2d");
      const centre = (x) => h * 0.42 + Math.sin(x / w * Math.PI * 2) * h * 0.16;
      const cloud = glow(128, "150,160,200", [[0, 0.09], [0.5, 0.03], [1, 0]]);
      const warmCloud = glow(128, "200,160,140", [[0, 0.06], [1, 0]]);
      for (let i = 0; i < 20 + w * 0.04; i++) {
        const x = Math.random() * w, s = 120 + Math.random() * 260;
        const y = centre(x) + (Math.random() - 0.5) * h * 0.25;
        for (const dx of [-w, 0, w]) g.drawImage(i % 4 ? cloud : warmCloud, x + dx - s / 2, y - s / 2, s, s * 0.6);
      }
      for (let i = 0; i < area * 2600; i++) {
        const x = Math.random() * w;
        const y = centre(x) + (Math.random() + Math.random() + Math.random() - 1.5) * h * 0.2;
        g.globalAlpha = 0.15 + Math.random() * 0.45;
        g.drawImage(sprites[i % 4], x - 1.2, y - 1.2, 2.4, 2.4);
      }
      const ps = Array.from({ length: Math.min(1100, Math.round(area * STARS_PER_MPX)) }, () => {
        const z = Math.random() ** 2.2;
        return { x: Math.random() * w, y: Math.random() * h, z, k: Math.floor(Math.random() * 4), s: Math.random() * 99, big: z > 0.93 };
      });
      let bx = 0;
      return {
        update(dt) {
          bx = (bx + 1.2 * DRIFT * dt) % w;
          for (const p of ps) {
            p.x -= (2 + p.z * 16) * DRIFT * dt;
            if (p.x < -10) {
              p.x = w + 10;
              p.y = Math.random() * h;
            }
          }
        },
        draw(c, t) {
          c.clearRect(0, 0, w, h);
          c.globalAlpha = 1;
          c.drawImage(band, -bx, 0, w, h);
          c.drawImage(band, w - bx, 0, w, h);
          c.globalCompositeOperation = "lighter";
          for (const p of ps) {
            const tw = 1 - TWINKLE * (0.5 - 0.5 * Math.sin(t * (1.5 + p.s % 3) + p.s)) * (1 - p.z) * 0.8;
            const s = 1.6 + p.z * 4.5;
            c.globalAlpha = (0.35 + p.z * 0.65) * tw;
            c.drawImage(sprites[p.k], p.x - s / 2, p.y - s / 2, s, s);
            if (p.big) c.drawImage(halos[p.k], p.x - 14, p.y - 14, 28, 28);
          }
          c.globalAlpha = 1;
          c.globalCompositeOperation = "source-over";
        }
      };
    }

    document.querySelectorAll(".sfp-hero").forEach(function (root) {
      var canvas = root.querySelector(".sfp-canvas");
      if (!canvas || root.hasAttribute("data-sfp-on")) return;
      root.setAttribute("data-sfp-on", "");
      mount(canvas, root);
    });
  })();
</script>

With reduced motion: A still sky with the galaxy band and every star fixed in place.

Running cost: One cached galaxy image drawn twice plus up to 1,100 star sprites per frame; light to moderate, paused off screen.

Pointer-Repelled Dot Field

A loose grid of graphite dots on warm paper. Move a pointer or finger through it and the dots are pushed aside, warming to a burnt orange while they are displaced, then spring back to their places and cool again. When nobody is touching it, an invisible guide wanders through the field so the hero is never static.

<!-- Pointer-Repelled Dot Field: plain HTML, CSS and JavaScript. Paste it anywhere; the class names all start with "prs-" so they will not clash. -->
<section class="prs-hero">
  <canvas class="prs-canvas" aria-hidden="true"></canvas>
  <div class="prs-scrim" aria-hidden="true"></div>
  <div class="prs-inner">
    <div class="prs-copy">
      <p class="prs-eyebrow">Plinth Architecture Studio</p>
      <h2 class="prs-title">Rooms that move with the people inside them.</h2>
      <p class="prs-body">Additions, renovations and new homes planned around how your household really lives, from first sketch to handover.</p>
      <a class="prs-cta" href="#">Start a project</a>
    </div>
  </div>
</section>

<style>
  /* Colours to change: --prs-bg (the backdrop), --prs-text and --prs-sub (copy), --prs-btn and --prs-btn-text (button).
     Particle colour, density and speed are the constants at the top of the script below. */
  .prs-hero {
    --prs-bg: linear-gradient(180deg, #efeeea 0%, #e7e5df 100%);
    --prs-text: #1c1b19;
    --prs-sub: #47453f;
    --prs-btn: #1c1b19;
    --prs-btn-text: #f5f4f0;
    position: relative;
    isolation: isolate;
    display: flex;
    align-items: center;
    min-height: 560px;
    padding: 80px 24px;
    overflow: hidden;
    background: var(--prs-bg);
    box-sizing: border-box;
  }
  .prs-canvas, .prs-scrim { position: absolute; inset: 0; width: 100%; height: 100%; z-index: -1; pointer-events: none; }
  .prs-scrim { background: radial-gradient(48% 70% at 20% 50%, rgba(239,238,234,0.95) 0%, rgba(239,238,234,0) 78%); }
  .prs-inner { width: 100%; max-width: 72rem; margin: 0 auto; }
  .prs-copy { max-width: 36rem; }
  .prs-eyebrow { margin: 0; font-size: 0.875rem; font-weight: 600; letter-spacing: 0.14em; text-transform: uppercase; color: #9a3a10; }
  .prs-title { margin: 16px 0 0; font-size: clamp(2.25rem, 1.7rem + 2.2vw, 3rem); line-height: 1.08; font-weight: 600; letter-spacing: -0.025em; color: var(--prs-text); }
  .prs-body { margin: 20px 0 0; font-size: 1.125rem; line-height: 1.65; color: var(--prs-sub); }
  .prs-cta { display: inline-flex; align-items: center; height: 48px; margin-top: 32px; padding: 0 24px; border-radius: 6px; background: var(--prs-btn); color: var(--prs-btn-text); font-size: 0.875rem; font-weight: 600; text-decoration: none; }
  .prs-cta:hover { opacity: 0.9; }
  .prs-cta:focus-visible { outline: 2px solid #1c1b19; outline-offset: 3px; }
  @media (min-width: 640px) { .prs-hero { padding: 80px 48px; } }
  /* With prefers-reduced-motion: reduce, the script paints one settled frame and never starts its loop. */
</style>

<script>
  (function () {
    // Values worth changing:
    const SPACING = 20; // pixels between resting dots
    const RADIUS = 120; // how far the pointer pushes, in pixels
    const INK = "40,39,36"; // resting dot colour, as r,g,b
    const HEAT = "194,72,20"; // colour of dots while they are pushed
    const SPRING = 16; // how quickly dots settle home: 8 is lazy, 30 is snappy
    const WARMUP = 0; // frames simulated before the first paint (0 keeps the field at rest)

    function glow(size, rgb, stops) {
      const c = document.createElement("canvas");
      c.width = c.height = size;
      const g = c.getContext("2d");
      const r = size / 2;
      const grd = g.createRadialGradient(r, r, 0, r, r, r);
      for (const [o, a] of stops) grd.addColorStop(o, `rgba(${rgb},${a})`);
      g.fillStyle = grd;
      g.fillRect(0, 0, size, size);
      return c;
    }
    function mount(canvas, root) {
      const ctx = canvas.getContext("2d");
      if (!ctx) return () => {
      };
      const still = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
      const ptr = { x: -1e4, y: -1e4, on: false };
      let w = 0, h = 0, t = 0, last = 0, raf = 0, seen = true;
      let scene = null;
      const fit = () => {
        const r = canvas.getBoundingClientRect();
        if (scene && Math.abs(r.width - w) < 1 && Math.abs(r.height - h) < 1) return;
        const dpr = Math.min(window.devicePixelRatio || 1, 1.5);
        w = Math.max(1, r.width);
        h = Math.max(1, r.height);
        canvas.width = Math.round(w * dpr);
        canvas.height = Math.round(h * dpr);
        ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
        scene = createScene(w, h, ptr);
        for (let i = 0; i < WARMUP; i++) {
          t += 1 / 30;
          scene.update(1 / 30, t);
        }
        scene.draw(ctx, t);
      };
      const frame = (now) => {
        const dt = Math.min(0.05, Math.max(0, (now - last) / 1e3));
        last = now;
        t += dt;
        if (scene) {
          scene.update(dt, t);
          scene.draw(ctx, t);
        }
        raf = requestAnimationFrame(frame);
      };
      const run = () => {
        cancelAnimationFrame(raf);
        raf = 0;
        if (!still && seen && !document.hidden) {
          last = performance.now();
          raf = requestAnimationFrame(frame);
        }
      };
      const move = (e) => {
        const r = canvas.getBoundingClientRect();
        ptr.x = e.clientX - r.left;
        ptr.y = e.clientY - r.top;
        ptr.on = true;
      };
      const leave = () => {
        ptr.on = false;
      };
      const ro = new ResizeObserver(fit);
      ro.observe(canvas);
      const io = new IntersectionObserver((es) => {
        seen = es[es.length - 1].isIntersecting;
        run();
      });
      io.observe(canvas);
      document.addEventListener("visibilitychange", run);
      root.addEventListener("pointermove", move);
      root.addEventListener("pointerleave", leave);
      fit();
      run();
      return () => {
        cancelAnimationFrame(raf);
        ro.disconnect();
        io.disconnect();
        document.removeEventListener("visibilitychange", run);
        root.removeEventListener("pointermove", move);
        root.removeEventListener("pointerleave", leave);
      };
    }
    function createScene(w, h, ptr) {
      const ink = glow(16, INK, [[0, 1], [0.4, 1], [0.55, 0]]);
      const heat = glow(16, HEAT, [[0, 1], [0.4, 1], [0.55, 0]]);
      const ps = [];
      for (let y = SPACING / 2; y < h; y += SPACING) {
        for (let x = SPACING / 2; x < w; x += SPACING) {
          const hx = x + (Math.random() - 0.5) * SPACING * 0.18, hy = y + (Math.random() - 0.5) * SPACING * 0.18;
          ps.push({ hx, hy, x: hx, y: hy, vx: 0, vy: 0, z: Math.random() });
        }
      }
      const src = { x: 0, y: 0 };
      return {
        update(dt, t) {
          if (ptr.on) {
            src.x = ptr.x;
            src.y = ptr.y;
          } else {
            src.x = w * (0.62 + 0.3 * Math.sin(t * 0.29));
            src.y = h * (0.5 + 0.36 * Math.sin(t * 0.47 + 1));
          }
          const damp = Math.exp(-7 * dt), R2 = RADIUS * RADIUS;
          for (const p of ps) {
            const dx = p.x - src.x, dy = p.y - src.y, d2 = dx * dx + dy * dy;
            if (d2 < R2 && d2 > 0.01) {
              const d = Math.sqrt(d2), f = (1 - d / RADIUS) ** 2 * 2600 * (0.5 + p.z);
              p.vx += dx / d * f * dt;
              p.vy += dy / d * f * dt;
            }
            p.vx = (p.vx + (p.hx - p.x) * SPRING * dt) * damp;
            p.vy = (p.vy + (p.hy - p.y) * SPRING * dt) * damp;
            p.x += p.vx * dt;
            p.y += p.vy * dt;
          }
        },
        draw(c) {
          c.clearRect(0, 0, w, h);
          for (const p of ps) {
            const off = Math.min(1, (Math.abs(p.x - p.hx) + Math.abs(p.y - p.hy)) / 16);
            const s = 3.2 + p.z * 2.8;
            c.globalAlpha = 0.3 + p.z * 0.35;
            c.drawImage(ink, p.x - s / 2, p.y - s / 2, s, s);
            if (off > 0.05) {
              c.globalAlpha = off;
              c.drawImage(heat, p.x - s / 2, p.y - s / 2, s, s);
            }
          }
          c.globalAlpha = 1;
        }
      };
    }

    document.querySelectorAll(".prs-hero").forEach(function (root) {
      var canvas = root.querySelector(".prs-canvas");
      if (!canvas || root.hasAttribute("data-prs-on")) return;
      root.setAttribute("data-prs-on", "");
      mount(canvas, root);
    });
  })();
</script>

With reduced motion: The dot grid is shown at rest with no wandering guide and no reaction to the pointer.

Running cost: Spring physics for around 2,000 to 3,000 dots on a wide screen, one sprite each; the heaviest piece here, still near a couple of milliseconds.

Rain on a Night Window

The view through a wet window at night. Blurred street lights glow behind the glass, thin rain streaks fall outside at a slight slant, and beads of water sit on the pane itself. Now and then a heavy bead lets go and runs down, leaving a fading trail. Fits bars, music venues, cinemas and moody editorial pages.

<!-- Rain on a Night Window: plain HTML, CSS and JavaScript. Paste it anywhere; the class names all start with "rwn-" so they will not clash. -->
<section class="rwn-hero">
  <canvas class="rwn-canvas" aria-hidden="true"></canvas>
  <div class="rwn-inner">
    <div class="rwn-copy">
      <p class="rwn-eyebrow">Halden Street Records</p>
      <h2 class="rwn-title">A listening room for rainy nights.</h2>
      <p class="rwn-body">Vinyl on a proper system, a short cocktail list and seats facing the window. Open from six until the last record ends.</p>
      <a class="rwn-cta" href="#">See this week's sets</a>
    </div>
  </div>
</section>

<style>
  /* Colours to change: --rwn-bg (the backdrop), --rwn-text and --rwn-sub (copy), --rwn-btn and --rwn-btn-text (button).
     Particle colour, density and speed are the constants at the top of the script below. */
  .rwn-hero {
    --rwn-bg: linear-gradient(180deg, #0a0f15 0%, #111925 60%, #18212c 100%);
    --rwn-text: #eef2f6;
    --rwn-sub: #bcc6d1;
    --rwn-btn: #e9b872;
    --rwn-btn-text: #1a1208;
    position: relative;
    isolation: isolate;
    display: flex;
    align-items: center;
    min-height: 560px;
    padding: 80px 24px;
    overflow: hidden;
    background: var(--rwn-bg);
    box-sizing: border-box;
  }
  .rwn-canvas { position: absolute; inset: 0; width: 100%; height: 100%; z-index: -1; pointer-events: none; }
  .rwn-inner { width: 100%; max-width: 72rem; margin: 0 auto; }
  .rwn-copy { max-width: 36rem; }
  .rwn-eyebrow { margin: 0; font-size: 0.875rem; font-weight: 600; letter-spacing: 0.14em; text-transform: uppercase; color: #e9bf80; }
  .rwn-title { margin: 16px 0 0; font-size: clamp(2.25rem, 1.7rem + 2.2vw, 3rem); line-height: 1.08; font-weight: 600; letter-spacing: -0.025em; color: var(--rwn-text); }
  .rwn-body { margin: 20px 0 0; font-size: 1.125rem; line-height: 1.65; color: var(--rwn-sub); }
  .rwn-cta { display: inline-flex; align-items: center; height: 48px; margin-top: 32px; padding: 0 24px; border-radius: 999px; background: var(--rwn-btn); color: var(--rwn-btn-text); font-size: 0.875rem; font-weight: 600; text-decoration: none; }
  .rwn-cta:hover { opacity: 0.9; }
  .rwn-cta:focus-visible { outline: 2px solid #eef2f6; outline-offset: 3px; }
  @media (min-width: 640px) { .rwn-hero { padding: 80px 48px; } }
  /* With prefers-reduced-motion: reduce, the script paints one settled frame and never starts its loop. */
</style>

<script>
  (function () {
    // Values worth changing:
    const STREAKS_PER_MPX = 240; // falling rain outside, per million pixels
    const BEADS_PER_MPX = 300; // drops sitting on the glass
    const CITY = ["255,178,92", "255,214,150", "120,186,214", "226,96,82"]; // blurred lights behind the glass
    const SLANT = 0.14; // rain angle: 0 is straight down
    const WARMUP = 20; // frames simulated before the first paint

    function glow(size, rgb, stops) {
      const c = document.createElement("canvas");
      c.width = c.height = size;
      const g = c.getContext("2d");
      const r = size / 2;
      const grd = g.createRadialGradient(r, r, 0, r, r, r);
      for (const [o, a] of stops) grd.addColorStop(o, `rgba(${rgb},${a})`);
      g.fillStyle = grd;
      g.fillRect(0, 0, size, size);
      return c;
    }
    function mount(canvas, root) {
      const ctx = canvas.getContext("2d");
      if (!ctx) return () => {
      };
      const still = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
      const ptr = { x: -1e4, y: -1e4, on: false };
      let w = 0, h = 0, t = 0, last = 0, raf = 0, seen = true;
      let scene = null;
      const fit = () => {
        const r = canvas.getBoundingClientRect();
        if (scene && Math.abs(r.width - w) < 1 && Math.abs(r.height - h) < 1) return;
        const dpr = Math.min(window.devicePixelRatio || 1, 1.5);
        w = Math.max(1, r.width);
        h = Math.max(1, r.height);
        canvas.width = Math.round(w * dpr);
        canvas.height = Math.round(h * dpr);
        ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
        scene = createScene(w, h, ptr);
        for (let i = 0; i < WARMUP; i++) {
          t += 1 / 30;
          scene.update(1 / 30, t);
        }
        scene.draw(ctx, t);
      };
      const frame = (now) => {
        const dt = Math.min(0.05, Math.max(0, (now - last) / 1e3));
        last = now;
        t += dt;
        if (scene) {
          scene.update(dt, t);
          scene.draw(ctx, t);
        }
        raf = requestAnimationFrame(frame);
      };
      const run = () => {
        cancelAnimationFrame(raf);
        raf = 0;
        if (!still && seen && !document.hidden) {
          last = performance.now();
          raf = requestAnimationFrame(frame);
        }
      };
      const ro = new ResizeObserver(fit);
      ro.observe(canvas);
      const io = new IntersectionObserver((es) => {
        seen = es[es.length - 1].isIntersecting;
        run();
      });
      io.observe(canvas);
      document.addEventListener("visibilitychange", run);
      fit();
      run();
      return () => {
        cancelAnimationFrame(raf);
        ro.disconnect();
        io.disconnect();
        document.removeEventListener("visibilitychange", run);
      };
    }
    function createScene(w, h, _ptr) {
      const area = w * h / 1e6;
      const city = document.createElement("canvas");
      city.width = Math.ceil(w / 2);
      city.height = Math.ceil(h / 2);
      const g = city.getContext("2d");
      g.scale(0.5, 0.5);
      const blobs = CITY.map((rgb) => glow(128, rgb, [[0, 0.5], [0.35, 0.22], [1, 0]]));
      for (let i = 0; i < 40 * Math.max(1, area * 1.5); i++) {
        const s = 30 + Math.random() * 120;
        g.globalAlpha = 0.25 + Math.random() * 0.6;
        g.drawImage(blobs[Math.floor(Math.random() * blobs.length)], Math.random() * w - s / 2, h * (0.35 + Math.random() * 0.7) - s / 2, s, s);
      }
      const bead = document.createElement("canvas");
      bead.width = bead.height = 32;
      const q = bead.getContext("2d");
      q.fillStyle = "rgba(10,16,24,0.35)";
      q.beginPath();
      q.arc(16, 16, 13, 0, 7);
      q.fill();
      q.strokeStyle = "rgba(255,226,180,0.55)";
      q.lineWidth = 2;
      q.beginPath();
      q.arc(16, 16, 12, 0.3, 2.8);
      q.stroke();
      q.fillStyle = "rgba(255,255,255,0.8)";
      q.beginPath();
      q.arc(11, 10, 2.2, 0, 7);
      q.fill();
      const streaks = Array.from({ length: Math.min(500, Math.round(area * STREAKS_PER_MPX)) }, () => ({
        x: Math.random() * w,
        y: Math.random() * h,
        z: Math.random()
      }));
      const beads = Array.from({ length: Math.min(600, Math.round(area * BEADS_PER_MPX)) }, () => ({
        x: Math.random() * w,
        y: Math.random() * h,
        r: 1 + Math.random() ** 3 * 5,
        vy: 0,
        top: 0
      }));
      return {
        update(dt) {
          for (const s of streaks) {
            const v = (700 + s.z * 600) * dt;
            s.y += v;
            s.x -= v * SLANT;
            if (s.y > h + 40) {
              s.y = -40 - Math.random() * 80;
              s.x = Math.random() * (w + h * SLANT);
            }
          }
          for (const b of beads) {
            if (b.vy === 0 && b.r > 3.5 && Math.random() < 0.06 * dt) {
              b.vy = 30;
              b.top = b.y;
            }
            if (b.vy > 0) {
              b.vy = Math.min(260, b.vy + 220 * dt * (Math.random() < 0.2 ? 0 : 1));
              b.y += b.vy * dt;
              b.x += (Math.random() - 0.5) * 30 * dt;
              if (b.y > h + 20) {
                b.y = Math.random() * h;
                b.x = Math.random() * w;
                b.vy = 0;
                b.r = 1 + Math.random() ** 3 * 5;
              }
            }
          }
        },
        draw(c) {
          c.clearRect(0, 0, w, h);
          c.globalAlpha = 1;
          c.drawImage(city, 0, 0, w, h);
          c.strokeStyle = "rgb(190,210,228)";
          c.lineCap = "round";
          for (const s of streaks) {
            const len = 14 + s.z * 30;
            c.globalAlpha = 0.06 + s.z * 0.2;
            c.lineWidth = 0.6 + s.z * 0.8;
            c.beginPath();
            c.moveTo(s.x, s.y);
            c.lineTo(s.x + len * SLANT, s.y - len);
            c.stroke();
          }
          for (const b of beads) {
            if (b.vy > 0) {
              const tg = c.createLinearGradient(0, b.top, 0, b.y);
              tg.addColorStop(0, "rgba(200,220,236,0)");
              tg.addColorStop(1, "rgba(200,220,236,0.28)");
              c.globalAlpha = 1;
              c.strokeStyle = tg;
              c.lineWidth = b.r * 0.9;
              c.beginPath();
              c.moveTo(b.x, b.top);
              c.lineTo(b.x, b.y);
              c.stroke();
            }
            const s = b.r * 2.4;
            c.globalAlpha = 0.9;
            c.drawImage(bead, b.x - s / 2, b.y - s / 2, s, s);
          }
          c.globalAlpha = 1;
        }
      };
    }

    document.querySelectorAll(".rwn-hero").forEach(function (root) {
      var canvas = root.querySelector(".rwn-canvas");
      if (!canvas || root.hasAttribute("data-rwn-on")) return;
      root.setAttribute("data-rwn-on", "");
      mount(canvas, root);
    });
  })();
</script>

With reduced motion: A still pane with drops resting on it and rain streaks frozen against the blurred city.

Running cost: One cached city image, up to 500 short lines and 600 small bead sprites per frame; moderate, and stopped off screen.

Rising Embers over a Dark Hearth

Sparks lift off a glowing bed of coals at the bottom of a dark room, swirl on rising heat and cool as they climb: pale yellow at first, then orange, then a dull red before they wink out. Each ember flickers, and the coal glow breathes underneath. Made for restaurants, bakeries, forges and anything built around fire.

<!-- Rising Embers over a Dark Hearth: plain HTML, CSS and JavaScript. Paste it anywhere; the class names all start with "emb-" so they will not clash. -->
<section class="emb-hero">
  <canvas class="emb-canvas" aria-hidden="true"></canvas>
  <div class="emb-inner">
    <div class="emb-copy">
      <p class="emb-eyebrow">Ashgrove Wood-Fired Kitchen</p>
      <h2 class="emb-title">Everything on the menu meets the fire first.</h2>
      <p class="emb-body">Bread from the oak oven, fish cooked over embers and vegetables pulled straight from the coals. Open Wednesday to Sunday.</p>
      <a class="emb-cta" href="#">Book a table</a>
    </div>
  </div>
</section>

<style>
  /* Colours to change: --emb-bg (the backdrop), --emb-text and --emb-sub (copy), --emb-btn and --emb-btn-text (button).
     Particle colour, density and speed are the constants at the top of the script below. */
  .emb-hero {
    --emb-bg: radial-gradient(70% 45% at 50% 110%, #5a2210 0%, rgba(90,34,16,0) 70%), linear-gradient(180deg, #0c0806 0%, #160c08 70%, #23110a 100%);
    --emb-text: #fbeee0;
    --emb-sub: #e0c8b0;
    --emb-btn: #f0a35e;
    --emb-btn-text: #1a0d07;
    position: relative;
    isolation: isolate;
    display: flex;
    align-items: center;
    min-height: 560px;
    padding: 80px 24px;
    overflow: hidden;
    background: var(--emb-bg);
    box-sizing: border-box;
  }
  .emb-canvas { position: absolute; inset: 0; width: 100%; height: 100%; z-index: -1; pointer-events: none; }
  .emb-inner { width: 100%; max-width: 72rem; margin: 0 auto; }
  .emb-copy { max-width: 36rem; }
  .emb-eyebrow { margin: 0; font-size: 0.875rem; font-weight: 600; letter-spacing: 0.14em; text-transform: uppercase; color: #f2b98a; }
  .emb-title { margin: 16px 0 0; font-size: clamp(2.25rem, 1.7rem + 2.2vw, 3rem); line-height: 1.08; font-weight: 600; letter-spacing: -0.025em; color: var(--emb-text); }
  .emb-body { margin: 20px 0 0; font-size: 1.125rem; line-height: 1.65; color: var(--emb-sub); }
  .emb-cta { display: inline-flex; align-items: center; height: 48px; margin-top: 32px; padding: 0 24px; border-radius: 999px; background: var(--emb-btn); color: var(--emb-btn-text); font-size: 0.875rem; font-weight: 600; text-decoration: none; }
  .emb-cta:hover { opacity: 0.9; }
  .emb-cta:focus-visible { outline: 2px solid #fbeee0; outline-offset: 3px; }
  @media (min-width: 640px) { .emb-hero { padding: 80px 48px; } }
  /* With prefers-reduced-motion: reduce, the script paints one settled frame and never starts its loop. */
</style>

<script>
  (function () {
    // Values worth changing:
    const EMBERS_PER_MPX = 220; // embers in the air per million pixels
    const HOT = "255,236,190"; // fresh ember colour
    const WARM = "255,150,62"; // cooling ember colour
    const COOL = "196,58,24"; // nearly spent ember colour
    const HEAT = 1; // rise speed: 0.6 for a dying fire, 1.6 for a roaring one
    const WARMUP = 180; // frames simulated before the first paint

    function glow(size, rgb, stops) {
      const c = document.createElement("canvas");
      c.width = c.height = size;
      const g = c.getContext("2d");
      const r = size / 2;
      const grd = g.createRadialGradient(r, r, 0, r, r, r);
      for (const [o, a] of stops) grd.addColorStop(o, `rgba(${rgb},${a})`);
      g.fillStyle = grd;
      g.fillRect(0, 0, size, size);
      return c;
    }
    function mount(canvas, root) {
      const ctx = canvas.getContext("2d");
      if (!ctx) return () => {
      };
      const still = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
      const ptr = { x: -1e4, y: -1e4, on: false };
      let w = 0, h = 0, t = 0, last = 0, raf = 0, seen = true;
      let scene = null;
      const fit = () => {
        const r = canvas.getBoundingClientRect();
        if (scene && Math.abs(r.width - w) < 1 && Math.abs(r.height - h) < 1) return;
        const dpr = Math.min(window.devicePixelRatio || 1, 1.5);
        w = Math.max(1, r.width);
        h = Math.max(1, r.height);
        canvas.width = Math.round(w * dpr);
        canvas.height = Math.round(h * dpr);
        ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
        scene = createScene(w, h, ptr);
        for (let i = 0; i < WARMUP; i++) {
          t += 1 / 30;
          scene.update(1 / 30, t);
        }
        scene.draw(ctx, t);
      };
      const frame = (now) => {
        const dt = Math.min(0.05, Math.max(0, (now - last) / 1e3));
        last = now;
        t += dt;
        if (scene) {
          scene.update(dt, t);
          scene.draw(ctx, t);
        }
        raf = requestAnimationFrame(frame);
      };
      const run = () => {
        cancelAnimationFrame(raf);
        raf = 0;
        if (!still && seen && !document.hidden) {
          last = performance.now();
          raf = requestAnimationFrame(frame);
        }
      };
      const ro = new ResizeObserver(fit);
      ro.observe(canvas);
      const io = new IntersectionObserver((es) => {
        seen = es[es.length - 1].isIntersecting;
        run();
      });
      io.observe(canvas);
      document.addEventListener("visibilitychange", run);
      fit();
      run();
      return () => {
        cancelAnimationFrame(raf);
        ro.disconnect();
        io.disconnect();
        document.removeEventListener("visibilitychange", run);
      };
    }
    function createScene(w, h, _ptr) {
      const n = Math.max(40, Math.min(420, Math.round(w * h / 1e6 * EMBERS_PER_MPX)));
      const tints = [HOT, WARM, COOL].map((rgb) => glow(32, rgb, [[0, 1], [0.15, 0.9], [0.4, 0.25], [1, 0]]));
      const bed = glow(256, WARM, [[0, 0.32], [0.4, 0.12], [1, 0]]);
      const spawn = (p) => {
        p.x = w * (0.5 + (Math.random() - 0.5) * 0.8 * (0.4 + Math.random() * 0.6));
        p.y = h + 10;
        p.z = Math.random();
        p.vy = -(35 + Math.random() * 70) * (0.5 + p.z * 0.7);
        p.life = 2.5 + Math.random() * 4;
        p.age = 0;
        p.s = Math.random() * 99;
      };
      const ps = Array.from({ length: n }, () => {
        const p = { x: 0, y: 0, vy: 0, life: 1, age: 0, z: 0, s: 0 };
        spawn(p);
        p.age = Math.random() * p.life;
        p.y = h + p.vy * p.age;
        return p;
      });
      return {
        update(dt, t) {
          for (const p of ps) {
            p.age += dt;
            if (p.age > p.life || p.y < -20) {
              spawn(p);
              continue;
            }
            p.y += p.vy * HEAT * dt;
            p.x += (Math.sin(t * 1.1 + p.y * 0.012 + p.s) * 22 + Math.sin(t * 3.1 + p.s * 2) * 8) * (0.4 + p.z) * dt;
          }
        },
        draw(c, t) {
          c.clearRect(0, 0, w, h);
          c.globalCompositeOperation = "lighter";
          const breathe = 0.8 + 0.12 * Math.sin(t * 1.7) + 0.08 * Math.sin(t * 4.3);
          c.globalAlpha = breathe;
          c.drawImage(bed, w * 0.05, h * 0.62, w * 0.9, h * 0.76);
          for (const p of ps) {
            const f = p.age / p.life;
            const tint = tints[f < 0.25 ? 0 : f < 0.6 ? 1 : 2];
            const flick = 0.65 + 0.35 * Math.sin(t * 17 + p.s * 7);
            const s = (4 + p.z * 12) * (1 - f * 0.5);
            c.globalAlpha = Math.min(1, (1 - f) * 1.4) * flick * (0.35 + p.z * 0.65);
            c.drawImage(tint, p.x - s / 2, p.y - s * 0.8, s, s * 1.6);
          }
          c.globalAlpha = 1;
          c.globalCompositeOperation = "source-over";
        }
      };
    }

    document.querySelectorAll(".emb-hero").forEach(function (root) {
      var canvas = root.querySelector(".emb-canvas");
      if (!canvas || root.hasAttribute("data-emb-on")) return;
      root.setAttribute("data-emb-on", "");
      mount(canvas, root);
    });
  })();
</script>

With reduced motion: A still scene of glowing coals with embers caught mid-air at different heights.

Running cost: Up to 420 additive sprite draws plus one glow image per frame, all from three pre-rendered tints; paused off screen.

Wind-Blown Sand over Dunes

Layered dunes under a pale sky, with sand grains streaming low across the nearest ridge. Gusts travel across the frame as moving bands, so grains speed up, stretch into streaks and slow down again as each band passes. Grains hug the ground and thin out with height, as real drifting sand does. A warm, light option for travel and outdoor brands.

<!-- Wind-Blown Sand over Dunes: plain HTML, CSS and JavaScript. Paste it anywhere; the class names all start with "snd-" so they will not clash. -->
<section class="snd-hero">
  <canvas class="snd-canvas" aria-hidden="true"></canvas>
  <div class="snd-inner">
    <div class="snd-copy">
      <p class="snd-eyebrow">Dune Line Expeditions</p>
      <h2 class="snd-title">Three nights in the dunes, guided by people who grew up there.</h2>
      <p class="snd-body">Small groups, shaded camps and water carried for you. Routes shift with the wind every season.</p>
      <a class="snd-cta" href="#">View crossing dates</a>
    </div>
  </div>
</section>

<style>
  /* Colours to change: --snd-bg (the backdrop), --snd-text and --snd-sub (copy), --snd-btn and --snd-btn-text (button).
     Particle colour, density and speed are the constants at the top of the script below. */
  .snd-hero {
    --snd-bg: radial-gradient(60% 50% at 80% 0%, #fbf3e2 0%, rgba(251,243,226,0) 70%), linear-gradient(180deg, #f3e7cf 0%, #efdfc0 55%, #e7d0a6 100%);
    --snd-text: #2e2112;
    --snd-sub: #5a4429;
    --snd-btn: #2e2112;
    --snd-btn-text: #f6ead2;
    position: relative;
    isolation: isolate;
    display: flex;
    align-items: center;
    min-height: 560px;
    padding: 80px 24px;
    overflow: hidden;
    background: var(--snd-bg);
    box-sizing: border-box;
  }
  .snd-canvas { position: absolute; inset: 0; width: 100%; height: 100%; z-index: -1; pointer-events: none; }
  .snd-inner { width: 100%; max-width: 72rem; margin: 0 auto; }
  .snd-copy { max-width: 36rem; }
  .snd-eyebrow { margin: 0; font-size: 0.875rem; font-weight: 600; letter-spacing: 0.14em; text-transform: uppercase; color: #7a4a17; }
  .snd-title { margin: 16px 0 0; font-size: clamp(2.25rem, 1.7rem + 2.2vw, 3rem); line-height: 1.08; font-weight: 600; letter-spacing: -0.025em; color: var(--snd-text); }
  .snd-body { margin: 20px 0 0; font-size: 1.125rem; line-height: 1.65; color: var(--snd-sub); }
  .snd-cta { display: inline-flex; align-items: center; height: 48px; margin-top: 32px; padding: 0 24px; border-radius: 6px; background: var(--snd-btn); color: var(--snd-btn-text); font-size: 0.875rem; font-weight: 600; text-decoration: none; }
  .snd-cta:hover { opacity: 0.9; }
  .snd-cta:focus-visible { outline: 2px solid #2e2112; outline-offset: 3px; }
  @media (min-width: 640px) { .snd-hero { padding: 80px 48px; } }
  /* With prefers-reduced-motion: reduce, the script paints one settled frame and never starts its loop. */
</style>

<script>
  (function () {
    // Values worth changing:
    const GRAINS_PER_MPX = 1000; // blowing grains per million pixels
    const GRAIN = "128,88,44"; // grain colour, as r,g,b
    const DUNES = ["#e6c99a", "#d9b47c", "#c99c62"]; // far, middle and near dune colours
    const WIND = 1; // wind strength: 0.5 for a breeze, 2 for a sandstorm
    const WARMUP = 60; // frames simulated before the first paint

    function glow(size, rgb, stops) {
      const c = document.createElement("canvas");
      c.width = c.height = size;
      const g = c.getContext("2d");
      const r = size / 2;
      const grd = g.createRadialGradient(r, r, 0, r, r, r);
      for (const [o, a] of stops) grd.addColorStop(o, `rgba(${rgb},${a})`);
      g.fillStyle = grd;
      g.fillRect(0, 0, size, size);
      return c;
    }
    function mount(canvas, root) {
      const ctx = canvas.getContext("2d");
      if (!ctx) return () => {
      };
      const still = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
      const ptr = { x: -1e4, y: -1e4, on: false };
      let w = 0, h = 0, t = 0, last = 0, raf = 0, seen = true;
      let scene = null;
      const fit = () => {
        const r = canvas.getBoundingClientRect();
        if (scene && Math.abs(r.width - w) < 1 && Math.abs(r.height - h) < 1) return;
        const dpr = Math.min(window.devicePixelRatio || 1, 1.5);
        w = Math.max(1, r.width);
        h = Math.max(1, r.height);
        canvas.width = Math.round(w * dpr);
        canvas.height = Math.round(h * dpr);
        ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
        scene = createScene(w, h, ptr);
        for (let i = 0; i < WARMUP; i++) {
          t += 1 / 30;
          scene.update(1 / 30, t);
        }
        scene.draw(ctx, t);
      };
      const frame = (now) => {
        const dt = Math.min(0.05, Math.max(0, (now - last) / 1e3));
        last = now;
        t += dt;
        if (scene) {
          scene.update(dt, t);
          scene.draw(ctx, t);
        }
        raf = requestAnimationFrame(frame);
      };
      const run = () => {
        cancelAnimationFrame(raf);
        raf = 0;
        if (!still && seen && !document.hidden) {
          last = performance.now();
          raf = requestAnimationFrame(frame);
        }
      };
      const ro = new ResizeObserver(fit);
      ro.observe(canvas);
      const io = new IntersectionObserver((es) => {
        seen = es[es.length - 1].isIntersecting;
        run();
      });
      io.observe(canvas);
      document.addEventListener("visibilitychange", run);
      fit();
      run();
      return () => {
        cancelAnimationFrame(raf);
        ro.disconnect();
        io.disconnect();
        document.removeEventListener("visibilitychange", run);
      };
    }
    function createScene(w, h, _ptr) {
      const area = w * h / 1e6;
      const ridge = (x, i) => h * (0.62 + i * 0.1) + Math.sin(x / w * 5.1 + i * 2) * h * 0.05 + Math.sin(x / w * 11.7 + i) * h * 0.018;
      const dunes = document.createElement("canvas");
      dunes.width = Math.ceil(w);
      dunes.height = Math.ceil(h);
      const g = dunes.getContext("2d");
      DUNES.forEach((col, i) => {
        g.fillStyle = col;
        g.beginPath();
        g.moveTo(0, h);
        for (let x = 0; x <= w + 8; x += 8) g.lineTo(x, ridge(x, i));
        g.lineTo(w, h);
        g.fill();
        const shade = g.createLinearGradient(0, h * (0.6 + i * 0.1), 0, h);
        shade.addColorStop(0, "rgba(255,244,220,0.35)");
        shade.addColorStop(1, "rgba(120,80,40,0.12)");
        g.fillStyle = shade;
        g.fill();
      });
      const ps = Array.from({ length: Math.min(1400, Math.round(area * GRAINS_PER_MPX)) }, () => ({
        x: Math.random() * w,
        up: Math.random() ** 3 * h * 0.22,
        z: Math.random(),
        s: Math.random() * 99
      }));
      let t0 = 0;
      const wind = (x) => (0.55 + 0.45 * Math.sin(x / w * 4 - t0 * 0.9)) * (0.7 + 0.3 * Math.sin(t0 * 0.21));
      return {
        update(dt, t) {
          t0 = t;
          for (const p of ps) {
            const near = Math.max(0, 1 - p.up / (h * 0.22));
            p.x += (60 + 260 * near) * (0.4 + p.z) * wind(p.x) * WIND * dt;
            p.up += Math.sin(t * 3 + p.s) * 10 * dt;
            if (p.up < 0) p.up = 0;
            if (p.x > w + 20) {
              p.x = -20;
              p.up = Math.random() ** 3 * h * 0.22;
            }
          }
        },
        draw(c) {
          c.clearRect(0, 0, w, h);
          c.globalAlpha = 1;
          c.drawImage(dunes, 0, 0, w, h);
          c.fillStyle = `rgb(${GRAIN})`;
          for (const p of ps) {
            const y = ridge(p.x, 2) - p.up - 2;
            const speed = wind(p.x);
            const len = 1.2 + p.z * 1.8 + speed * p.z * 10;
            c.globalAlpha = (0.18 + p.z * 0.5) * (0.5 + speed * 0.5);
            c.fillRect(p.x - len, y, len, 0.8 + p.z * 1.4);
          }
          c.globalAlpha = 1;
        }
      };
    }

    document.querySelectorAll(".snd-hero").forEach(function (root) {
      var canvas = root.querySelector(".snd-canvas");
      if (!canvas || root.hasAttribute("data-snd-on")) return;
      root.setAttribute("data-snd-on", "");
      mount(canvas, root);
    });
  })();
</script>

With reduced motion: The dunes stand still with sand grains settled into a frozen drift along the near ridge.

Running cost: One cached dune image and up to 1,400 thin rectangles per frame; light, and the loop sleeps off screen.

Performance notes

Drawing a blurred circle for every particle on every frame is slow. These effects draw each soft sprite once to a small offscreen canvas and then stamp it with drawImage, which is far cheaper. Motion is multiplied by the time since the last frame, so the speed stays the same on 60 Hz and 120 Hz screens.

Canvas work happens on the main thread, so a frame budget matters. Each effect keeps its update and draw well under a few milliseconds on an ordinary laptop, caps the device pixel ratio at 2, and stops requestAnimationFrame when the canvas scrolls out of view. Browsers already slow frame callbacks in hidden tabs.

Questions

Do I need a particles library?

No. A basic particle system is an array of objects with a position and a velocity, an update step and a draw step inside requestAnimationFrame. Writing it yourself keeps the code small and lets you shape the motion exactly.

How many particles can I draw?

It depends on how each one is drawn. Stamping a cached sprite, a few hundred to a couple of thousand is comfortable on a laptop. Connecting lines between particles grows much faster with count, so network styles stay at a lower number.

Is a canvas background accessible?

Mark the canvas aria-hidden so screen readers skip it, keep it behind the content with pointer-events: none, and stop the motion for people who prefer reduced motion. The content on top stays ordinary, readable HTML.

Why does my canvas look blurry on a phone?

The canvas has fewer pixels than the screen shows. Set its width and height to its displayed size times devicePixelRatio, then scale the drawing context by the same number. Capping that ratio at 2 keeps very dense screens from doing extra work.

More collections: Animated Gradient Backgrounds, Animated Backgrounds for Websites, 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