Pulsar

Orbit Stagger

GSAP

A pulsing purple core with three dots orbiting at staggered speeds, all returning to rest together.

Tier
GSAP
Dependencies
gsap
Duration
6 s, loops
Categories
Icons and micro-interactions
Use cases
Loading and waiting, AI and processing
Industries
SaaS and software, Cross-industry
Style
Duotone · none stroke
Set
nebula
Tags
orbitstaggerloopbrandcircle
Themeable
No. Fixed palette.
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="orbit-stagger-title" class="pulsar pulsar-orbit-stagger" data-pulsar="orbit-stagger" role="img" viewBox="0 0 100 100"><title id="orbit-stagger-title">Orbit stagger</title><defs><radialGradient id="orbit-stagger-core-grad" cx="50%" cy="50%" r="50%"><stop offset="0" stop-color="#a27ffe"></stop><stop offset=".55" stop-color="#7a46ff"></stop><stop offset="1" stop-color="#5430af"></stop></radialGradient></defs><g id="orbit-stagger-orbit-1"><circle id="orbit-stagger-ring-1" cx="50" cy="50" r="16" fill="none" stroke="#5430af" stroke-width=".75" opacity=".55"></circle><circle id="orbit-stagger-dot-1" cx="50" cy="34" r="2.2" fill="#a27ffe"></circle></g><g id="orbit-stagger-orbit-2"><circle id="orbit-stagger-ring-2" cx="50" cy="50" r="26" fill="none" stroke="#5430af" stroke-width=".75" opacity=".45"></circle><circle id="orbit-stagger-dot-2" cx="76" cy="50" r="2.6" fill="#7a46ff"></circle></g><g id="orbit-stagger-orbit-3"><circle id="orbit-stagger-ring-3" cx="50" cy="50" r="36" fill="none" stroke="#5430af" stroke-width=".75" opacity=".35"></circle><circle id="orbit-stagger-dot-3" cx="24.54" cy="75.46" r="2" fill="#5430af"></circle></g><circle id="orbit-stagger-halo" cx="50" cy="50" r="10" fill="#a27ffe" opacity=".22"></circle><circle id="orbit-stagger-core" cx="50" cy="50" r="6" fill="url(#orbit-stagger-core-grad)"></circle></svg>

animation.js

;(function () {
  var el = document.querySelector('[data-pulsar="orbit-stagger"]')
  if (!el || typeof gsap === 'undefined') return

  var reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches

  var orbits = [
    el.querySelector('#orbit-stagger-orbit-1'),
    el.querySelector('#orbit-stagger-orbit-2'),
    el.querySelector('#orbit-stagger-orbit-3'),
  ]
  var core = el.querySelector('#orbit-stagger-core')
  var halo = el.querySelector('#orbit-stagger-halo')

  // One cycle is 6 s. Each orbit group holds its ring and its dot, so
  // '50% 50%' of the group is the ring centre. Inner ring turns three
  // times, middle twice, outer once, so all return to rest at exactly 6 s.
  var tl = gsap.timeline({ repeat: -1, paused: reduced })

  var turns = [3, 2, 1]
  for (var i = 0; i < orbits.length; i++) {
    if (!orbits[i]) continue
    tl.to(
      orbits[i],
      { rotation: 360 * turns[i], transformOrigin: '50% 50%', ease: 'none', duration: 6 },
      0,
    )
  }

  if (core) {
    tl.to(
      core,
      {
        scale: 1.12,
        transformOrigin: '50% 50%',
        ease: 'sine.inOut',
        duration: 3,
        yoyo: true,
        repeat: 1,
      },
      0,
    )
  }
  if (halo) {
    tl.to(
      halo,
      {
        scale: 1.3,
        opacity: 0.1,
        transformOrigin: '50% 50%',
        ease: 'sine.inOut',
        duration: 3,
        yoyo: true,
        repeat: 1,
      },
      0,
    )
  }

  el.pulsar = {
    play: function () {
      if (!reduced) tl.play()
    },
    pause: function () {
      tl.pause()
    },
    seek: function (ms) {
      if (!reduced) tl.seek(ms / 1000)
    },
    destroy: function () {
      tl.kill()
    },
  }
})()

GSAP tier: this animation expects a global gsap. Load GSAP before the script.