Pulse Ring

transform

Copies of the first part swell outward and fade, half a beat apart, forever. The copies sit behind the original, which never moves.

Demo shape

Every animation on the page redraws with the shape you pick.

Align
Ease
Duration
s
0.13
Stagger
s
00.2
Delay
s
02
<!-- 1. Markup -->
<svg viewBox="0 0 120 120" class="k-pulse-ring" aria-hidden="true">
  <circle cx="60" cy="60" r="34" fill="none" stroke="currentColor" stroke-width="8" vector-effect="non-scaling-stroke" />
  <line x1="60" y1="12" x2="60" y2="38" stroke="currentColor" stroke-width="8" stroke-linecap="round" vector-effect="non-scaling-stroke" />
  <line x1="60" y1="82" x2="60" y2="108" stroke="currentColor" stroke-width="8" stroke-linecap="round" vector-effect="non-scaling-stroke" />
  <circle cx="60" cy="60" r="10" fill="currentColor" />
</svg>

// 2. JS
function pulseRing(svg) {
  if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return () => {}

  const [ring] = paths(svg)
  if (!ring) return () => {}
  // Two copies behind the original, which never moves.
  const copies = [0, 1].map(() => {
    const copy = ring.cloneNode(true)
    ring.before(copy)
    return copy
  })
  const ms = 1.6 * 1000
  const animations = copies.map((copy, i) => {
    // Without fill-box each copy would swell out from the SVG's top-left corner.
    copy.style.transformBox = 'fill-box'
    copy.style.transformOrigin = 'center'
    return copy.animate([{ transform: 'scale(1)', opacity: 0.6 }, { transform: 'scale(1.8)', opacity: 0 }], {
      duration: ms,
      delay: (ms / 2) * i,
      iterations: Infinity,
      easing: 'linear(0.0000,0.0506,0.0999,0.1479,0.1946,0.2400,0.2840,0.3268,0.3682,0.4083,0.4471,0.4845,0.5207,0.5556,0.5891,0.6213,0.6522,0.6818,0.7101,0.7370,0.7627,0.7870,0.8100,0.8317,0.8521,0.8711,0.8889,0.9053,0.9204,0.9343,0.9467,0.9579,0.9678,0.9763,0.9836,0.9895,0.9941,0.9974,0.9993,1.0000)',
    })
  })

  return () => {
    animations.forEach((a) => a.cancel())
    copies.forEach((copy) => copy.remove())
  }
}

function paths(svg) {
  return [...svg.querySelectorAll('path, line, polyline, polygon, circle, ellipse, rect')].filter(
    (el) => !el.closest('defs, clipPath, mask'),
  )
}


pulseRing(document.querySelector('.k-pulse-ring'))