Nebula Drift
JSTwelve 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()
})()
