Pulsar

Nebula Drift

JS

Twelve particles drifting on closed paths, joined by faint lines that brighten as they pass each other.

Tier
JS
Dependencies
None
Duration
12 s, loops
Categories
Backgrounds and ambient
Use cases
Hero and marketing, Ambient background
Industries
SaaS and software, Cross-industry
Style
Duotone · none stroke
Set
nebula
Tags
ambientparticlesloopspacenebula
Themeable
Yes. Inherits currentColor and reads --pulsar-accent.
Version
1.0.0, added 2026-09-08
Licence
To be confirmed. See the repository LICENSE.

Code

animation.svg

<svg xmlns="http://www.w3.org/2000/svg" aria-labelledby="nebula-drift-title" class="pulsar pulsar-nebula-drift" data-pulsar="nebula-drift" role="img" viewBox="0 0 320 180"><title id="nebula-drift-title">Nebula drift</title><g id="nebula-drift-lines" fill="none" stroke="currentColor" stroke-linecap="round" stroke-width=".6" class="nebula-drift-lines"></g><g id="nebula-drift-particles" fill="currentColor"><circle id="nebula-drift-p-1" cx="61.75" cy="54.15" r="2.2" class="nebula-drift-particle"></circle><circle id="nebula-drift-p-2" cx="121" cy="54.48" r="1.4" class="nebula-drift-particle"></circle><circle id="nebula-drift-p-3" cx="196.92" cy="51.71" r="1.8" class="nebula-drift-particle"></circle><circle id="nebula-drift-p-4" cx="240.73" cy="59.62" r="1.2" class="nebula-drift-particle"></circle><circle id="nebula-drift-p-5" cx="66.97" cy="97.57" r="1.6" class="nebula-drift-particle"></circle><circle id="nebula-drift-p-6" cx="156.29" cy="80.97" r="2.4" class="nebula-drift-particle"></circle><circle id="nebula-drift-p-7" cx="222.69" cy="88.88" r="1.3" class="nebula-drift-particle"></circle><circle id="nebula-drift-p-8" cx="271" cy="109.57" r="1.9" class="nebula-drift-particle"></circle><circle id="nebula-drift-p-9" cx="61.73" cy="122.61" r="1.4" class="nebula-drift-particle"></circle><circle id="nebula-drift-p-10" cx="121.34" cy="160.99" r="2" class="nebula-drift-particle"></circle><circle id="nebula-drift-p-11" cx="191.67" cy="145.36" r="1.5" class="nebula-drift-particle"></circle><circle id="nebula-drift-p-12" cx="271.49" cy="125.07" r="1.7" class="nebula-drift-particle"></circle></g></svg>

animation.css

.pulsar-nebula-drift .nebula-drift-particle {
  fill: currentColor;
  opacity: 0.9;
}

.pulsar-nebula-drift .nebula-drift-lines {
  fill: none;
  stroke: var(--pulsar-accent, currentColor);
  stroke-width: 0.6;
  stroke-linecap: round;
}

@media (prefers-reduced-motion: reduce) {
  .pulsar-nebula-drift,
  .pulsar-nebula-drift .nebula-drift-particle,
  .pulsar-nebula-drift .nebula-drift-lines {
    animation: none;
  }
}

animation.js

;(function () {
  var el = document.querySelector('[data-pulsar="nebula-drift"]')
  if (!el) return

  var DURATION = 12000
  var THRESHOLD = 66
  var TAU = Math.PI * 2

  // Per particle: [cx, cy, a1, b1, a2, b2, p1, q1, p2, q2, k]
  // x = cx + a1 * sin(w + p1) + a2 * sin(k * w + p2)
  // y = cy + b1 * sin(w + q1) + b2 * sin(k * w + q2)
  // w = TAU * t / DURATION, k an integer, so every path closes at DURATION.
  var P = [
    [48, 42, 18, 12, 7, 9, 0.4, 2.1, 1.3, 0.2, 2],
    [112, 36, 14, 16, 9, 6, 2.8, 0.9, 0.5, 1.7, 3],
    [176, 48, 20, 10, 6, 8, 1.1, 3.4, 2.6, 0.9, 2],
    [246, 40, 16, 14, 8, 7, 3.9, 1.5, 0.8, 2.2, 2],
    [70, 92, 12, 18, 8, 6, 5.2, 0.3, 1.9, 3.1, 3],
    [140, 84, 22, 12, 6, 9, 0.9, 4.4, 3.3, 1.2, 2],
    [208, 96, 15, 15, 9, 7, 2.3, 5.6, 0.4, 2.8, 3],
    [276, 88, 12, 20, 7, 6, 4.7, 2.0, 1.6, 0.6, 2],
    [44, 138, 16, 11, 9, 8, 1.8, 3.9, 2.9, 4.5, 2],
    [118, 146, 20, 10, 7, 9, 3.2, 1.1, 0.7, 2.4, 3],
    [190, 136, 14, 17, 8, 6, 5.9, 2.6, 2.1, 0.1, 2],
    [262, 142, 18, 13, 6, 9, 0.2, 4.8, 1.4, 3.6, 3],
  ]
  var N = P.length

  var dots = []
  for (var i = 0; i < N; i++) {
    dots.push(el.querySelector('#nebula-drift-p-' + (i + 1)))
  }
  var linesGroup = el.querySelector('#nebula-drift-lines')
  if (!linesGroup) return

  // One <line> per pair, created once and reused; hidden pairs get opacity 0.
  var lines = []
  for (var a = 0; a < N; a++) {
    for (var b = a + 1; b < N; b++) {
      var line = document.createElementNS(linesGroup.namespaceURI, 'line')
      line.setAttribute('stroke-opacity', '0')
      linesGroup.appendChild(line)
      lines.push(line)
    }
  }

  var xs = new Array(N)
  var ys = new Array(N)

  function render(ms) {
    var w = (TAU * ms) / DURATION
    for (var i = 0; i < N; i++) {
      var p = P[i]
      xs[i] = p[0] + p[2] * Math.sin(w + p[6]) + p[4] * Math.sin(p[10] * w + p[8])
      ys[i] = p[1] + p[3] * Math.sin(w + p[7]) + p[5] * Math.sin(p[10] * w + p[9])
      if (dots[i]) {
        dots[i].setAttribute('cx', xs[i].toFixed(2))
        dots[i].setAttribute('cy', ys[i].toFixed(2))
      }
    }
    var n = 0
    for (var a = 0; a < N; a++) {
      for (var b = a + 1; b < N; b++) {
        var line = lines[n++]
        var dx = xs[a] - xs[b]
        var dy = ys[a] - ys[b]
        var d = Math.sqrt(dx * dx + dy * dy)
        if (d < THRESHOLD) {
          var f = 1 - d / THRESHOLD
          line.setAttribute('x1', xs[a].toFixed(2))
          line.setAttribute('y1', ys[a].toFixed(2))
          line.setAttribute('x2', xs[b].toFixed(2))
          line.setAttribute('y2', ys[b].toFixed(2))
          line.setAttribute('stroke-opacity', (f * f * 0.7).toFixed(3))
        } else {
          line.setAttribute('stroke-opacity', '0')
        }
      }
    }
  }

  var reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches
  var raf = 0
  var last = 0
  var cycle = 0

  function frame(now) {
    cycle = (cycle + (now - last)) % DURATION
    last = now
    render(cycle)
    raf = window.requestAnimationFrame(frame)
  }

  function play() {
    if (raf || reduced) return
    last = window.performance.now()
    raf = window.requestAnimationFrame(frame)
  }

  function pause() {
    if (raf) window.cancelAnimationFrame(raf)
    raf = 0
  }

  function seek(ms) {
    pause()
    if (reduced) return
    cycle = ((ms % DURATION) + DURATION) % DURATION
    render(cycle)
  }

  function destroy() {
    pause()
    while (linesGroup.firstChild) linesGroup.removeChild(linesGroup.firstChild)
    lines.length = 0
    delete el.pulsar
  }

  el.pulsar = { play: play, pause: pause, seek: seek, destroy: destroy }

  render(0)
  play()
})()