Copied!
Animated SVG backgrounds

Dot wave pattern

An SVG grid of small dots that sway in a slow travelling wave while diagonal bands of colour drift across, with an optional pointer that scatters them.

← Library

Element · Vanilla JS + SVGUpdated November 6, 2026

SVG backgroundsElementVanilla JS + SVG

Live preview needs JavaScript. The code below works without any dependencies.

Prompt for your AI assistant

Paste this into Claude, Cursor or ChatGPT. The settings update as you move the controls above.

Build "Dot wave pattern" for my site using the Enrichr Library element at https://enrichr.dev/elements/dot-wave-pattern.html.

Read the machine-readable spec and source at https://enrichr.dev/elements/dot-wave-pattern.json (markup, CSS, JavaScript, parameters).
Requirements: vanilla JavaScript and CSS, no new dependencies; keep the behaviour of the source unchanged; respect prefers-reduced-motion; work on touch screens; keep it accessible.

Settings:
- spacing: 40
- radius: 3
- speed: 1
- amplitude: 15
- frequency: 0.05
- interactive: 0
- force: 50
- primary: #e6ff55
- secondary: #fbf8f1
- background: #0c1e18

Verify: reduced motion shows no movement, it works at 375px wide, there are no console errors, and scrolling stays smooth.

The same spec as JSON, for tools that read it directly: /elements/dot-wave-pattern.json.

Code

Mount it on an element that contains the markup. It returns an object with update(options) and destroy().

HTML

<div class="ef-dwv"><svg class="ef-dwv__svg" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="A grid of small dots swaying in a slow wave, with bands of colour drifting across it"></svg></div>

CSS

/* Enrichr Library · https://enrichr.dev */
.ef-dwv { position: relative; width: 100%; min-height: 24rem; overflow: hidden; border-radius: 1rem; background: #0c1e18; touch-action: pan-y; }
.ef-dwv .ef-dwv__svg { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }

JavaScript

// Enrichr Library · https://enrichr.dev
// Dot wave pattern: an SVG grid of small circles that sway in a slow travelling wave while bands of colour drift across
// them. Every dot moves sideways by sin(row x frequency + time x 0.05) and up and down by cos(column x frequency + time x 0.05),
// times the wave amplitude, so rows and columns ripple out of step and the grid seems to breathe. Colour runs from the
// secondary colour to the primary and back along the diagonals, on a slower clock (time x 0.02). In interactive mode the
// pointer pushes nearby dots away and makes them swing wider, and every dot eases back to its place at 5% of the gap per
// frame (60 fps timing, made frame-rate independent). The grid covers the box plus two spare rows and columns each side.
// Reduced motion draws one still frame and ignores the pointer. The loop pauses off screen and in hidden tabs.
// mount(root, { spacing (px), radius (dot radius, px), speed, amplitude (px), frequency, interactive (1 on, 0 off),
//               force (pointer reaction), primary, secondary, background })
function mount(root, o) {
  var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
  var NS = "http://www.w3.org/2000/svg";
  var wrap = root.querySelector(".ef-dwv");
  var svg = root.querySelector(".ef-dwv__svg");
  var opts = {}, raf = 0, visible = true, io = null, ro = null, destroyed = false, last = 0, time = 0;
  var W = 0, H = 0, dots = [], rows = 0, cols = 0, sinRow = [], cosCol = [], fills = [];
  var mx = -1000, my = -1000, c1 = [230, 255, 85], c2 = [251, 248, 241];
  var MAX_DOTS = 6000;

  function num(v, d) { v = Number(v); return isFinite(v) ? v : d; }
  function hex(h, d) { var m = /^#?([0-9a-f]{6})$/i.exec(h || ""); var v = parseInt(m ? m[1] : d, 16); return [v >> 16, (v >> 8) & 255, v & 255]; }
  // same maths as the original: a straight mix per channel, rounded, written as #rrggbb
  function mix(a, b, f) {
    var r = Math.round(a[0] + f * (b[0] - a[0])), g = Math.round(a[1] + f * (b[1] - a[1])), bl = Math.round(a[2] + f * (b[2] - a[2]));
    return "#" + ((1 << 24) + (r << 16) + (g << 8) + bl).toString(16).slice(1);
  }

  function build() {
    svg.textContent = "";
    dots = [];
    W = wrap.clientWidth; H = wrap.clientHeight;
    if (W < 1 || H < 1) return;
    var s = opts.spacing;
    // never more than about 6,000 circles: widen the spacing on very large boxes or very small spacing values
    while ((Math.ceil(W / s) + 4) * (Math.ceil(H / s) + 4) > MAX_DOTS) s *= 1.05;
    cols = Math.ceil(W / s) + 4; rows = Math.ceil(H / s) + 4;
    var startX = -s * 2, startY = -s * 2, frag = document.createDocumentFragment();
    for (var i = 0; i < cols; i++) {
      for (var j = 0; j < rows; j++) {
        var cx = startX + i * s, cy = startY + j * s;
        var c = document.createElementNS(NS, "circle");
        c.setAttribute("cx", cx); c.setAttribute("cy", cy); c.setAttribute("r", opts.radius);
        frag.appendChild(c);
        dots.push({ el: c, baseX: cx, baseY: cy, originX: cx, originY: cy, col: i, row: j, fill: "" });
      }
    }
    svg.appendChild(frag);
  }

  function frame(f) {
    var amp = opts.amplitude, freq = opts.frequency, t = time * 0.05, i;
    var live = !reduce && opts.interactive, force = opts.force, push = force / 25, ease = 1 - Math.pow(0.95, f);
    for (i = 0; i < rows; i++) sinRow[i] = Math.sin(i * freq + t);
    for (i = 0; i < cols; i++) cosCol[i] = Math.cos(i * freq + t);
    // colour depends only on row + column, so work it out once per diagonal
    for (i = 0; i < rows + cols; i++) {
      var m = (Math.sin(i * 0.1 + time * 0.02) + 1) / 2;
      fills[i] = m > 0.5 ? mix(c1, c2, (m - 0.5) * 2) : mix(c2, c1, m * 2);
    }
    for (i = 0; i < dots.length; i++) {
      var d = dots[i], a = amp;
      if (live) {
        var dx = d.baseX - mx, dy = d.baseY - my, dist = Math.sqrt(dx * dx + dy * dy);
        if (dist < 200 && dist > 0) {
          var inf = 1 - dist / 200;
          a = amp + inf * force;
          d.baseX += (dx / dist) * inf * push * f;
          d.baseY += (dy / dist) * inf * push * f;
        }
      }
      d.baseX += (d.originX - d.baseX) * ease;
      d.baseY += (d.originY - d.baseY) * ease;
      d.el.setAttribute("cx", (d.baseX + sinRow[d.row] * a).toFixed(2));
      d.el.setAttribute("cy", (d.baseY + cosCol[d.col] * a).toFixed(2));
      var fl = fills[d.row + d.col];
      if (fl !== d.fill) { d.fill = fl; d.el.setAttribute("fill", fl); }
    }
  }

  function loop(now) {
    raf = requestAnimationFrame(loop);
    var dt = Math.min(0.05, (now - last) / 1000 || 0); last = now;
    if (!visible || document.hidden) return;
    var f = dt * 60; // the original counts in frames at 60 fps
    time += opts.speed * f;
    frame(f);
  }

  function onMove(e) { var r = wrap.getBoundingClientRect(); mx = e.clientX - r.left; my = e.clientY - r.top; }
  function onLeave() { mx = -1000; my = -1000; }

  function update(partial) {
    var prev = opts;
    opts = Object.assign({ spacing: 40, radius: 3, speed: 1, amplitude: 15, frequency: 0.05, interactive: 0, force: 50, primary: "#e6ff55", secondary: "#fbf8f1", background: "#0c1e18" }, opts, partial || {});
    opts.spacing = Math.max(4, num(opts.spacing, 40)); opts.radius = Math.max(0.5, num(opts.radius, 3));
    opts.speed = Math.max(0, num(opts.speed, 1)); opts.amplitude = num(opts.amplitude, 15); opts.frequency = num(opts.frequency, 0.05);
    opts.interactive = String(opts.interactive) === "1" || opts.interactive === true; opts.force = num(opts.force, 50);
    c1 = hex(opts.primary, "e6ff55"); c2 = hex(opts.secondary, "fbf8f1");
    wrap.style.background = opts.background;
    if (destroyed) return;
    if (!dots.length || prev.spacing !== opts.spacing) build();
    else if (prev.radius !== opts.radius) for (var i = 0; i < dots.length; i++) dots[i].el.setAttribute("r", opts.radius);
    frame(0);
  }

  update(o);
  wrap.addEventListener("pointermove", onMove);
  wrap.addEventListener("pointerleave", onLeave);
  wrap.addEventListener("pointercancel", onLeave);
  if (!reduce) {
    if ("IntersectionObserver" in window) { io = new IntersectionObserver(function (e) { visible = e[0].isIntersecting; }); io.observe(wrap); }
    last = performance.now(); raf = requestAnimationFrame(loop);
  }
  if ("ResizeObserver" in window) {
    ro = new ResizeObserver(function () {
      if (destroyed || (wrap.clientWidth === W && wrap.clientHeight === H)) return;
      build(); frame(0);
    });
    ro.observe(wrap);
  }
  return {
    update: update,
    destroy: function () {
      destroyed = true; cancelAnimationFrame(raf);
      wrap.removeEventListener("pointermove", onMove);
      wrap.removeEventListener("pointerleave", onLeave);
      wrap.removeEventListener("pointercancel", onLeave);
      if (io) io.disconnect();
      if (ro) ro.disconnect();
    }
  };
}

// Usage
var fx = mount(document.querySelector("#my-root"), {"spacing":40,"radius":3,"speed":1,"amplitude":15,"frequency":0.05,"interactive":0,"force":50,"primary":"#e6ff55","secondary":"#fbf8f1","background":"#0c1e18"});

How it works

The SVG holds one circle per grid point. The grid covers the box plus two spare rows and columns on every side, so the edges never gap when the wave moves the dots. A clock counts frames at 60 a second, times the speed. Each dot moves sideways by the sine of (row times frequency plus clock times 0.05) and up and down by the cosine of (column times frequency plus clock times 0.05), times the amplitude, so rows and columns swing out of step. Colour follows the sine of (0.1 times row plus column, plus clock times 0.02): at the midpoint a dot is the primary colour and at either end the secondary, so diagonal bands of colour drift slowly across. With the pointer reaction on, dots within 200 pixels of the pointer are pushed away by the force divided by 25 each frame, more the closer they are, and their swing grows by up to the force. Every dot eases back 5% of the way to its place each frame.

Accessibility and performance

The SVG is decorative and has a text label. With prefers-reduced-motion it draws one still frame and ignores the pointer. The movement is slow and small at the default settings. The pointer reaction is a visual extra; nothing needs the keyboard.

Sines and colours are worked out once per row, column and diagonal rather than per dot, and a dot's fill is only rewritten when its colour changes. At the default spacing a 1280 by 720 box has under 900 circles. The grid is capped near 6,000 circles, widening the spacing past that. The loop pauses off screen or in a hidden tab.

SectionAnimated SVG backgrounds
TechniquesPointer interaction
StackVanilla JS, SVG
Works withReact, Next.js, Vanilla
DependenciesNone
Statustested
On CodePenOpen the live pen on our CodePen account; fork it to experiment.
Inspired byAnimated SVG Dot Pattern by Enrichr on CodePen. Original code, written from scratch.

Find this technique on a real site

Want to see this technique in the wild? Check any site with the free tools, then build your own version from this page.

Check which animation library a site uses · Extract a site's design tokens · Browse site breakdowns

Licence and provenance

Free to use in personal and client projects during the preview. Final licence terms will be published here before any paid tier starts; ask before reselling the code as a collection.

This is original code. The technique is a common one that we studied by measuring award-winning sites with Enrichr; no third-party code, shaders, fonts or images are used.

Rule sweep listA line sweeps across a menu row on hover or focus and exits through the opposite side. Depth parallax cardLayered shapes shift by different amounts as the pointer moves, drifting on touch screens. 3D ring carouselCards on a CSS 3D ring that rotates one step at a time, with buttons, arrow keys and autoplay.

FAQ

How is it different from Dot matrix pulse?

Dot matrix pulse is a canvas of glowing dots that swell and fade in place. This one is plain SVG circles that keep their size and sway in a wave, with colour bands drifting across, and it can react to the pointer.

Can I put content on top of it?

Yes. Give .ef-dwv a height and place your content over it with position: absolute, or stack both in one grid cell. With the pointer reaction on, content on top takes the pointer events, so keep the dots visible around it.

How do I turn on the pointer reaction?

Set Pointer reaction to On. Dots near the pointer then scatter and swing wider, and drift back when it leaves. Reaction force sets how hard they are pushed.

Why SVG and not canvas?

The original is SVG, and at normal spacing a few hundred circles move smoothly as plain attributes. For very dense grids, spacing under about 15 pixels on a large screen, a canvas would be lighter.