Draw Sequence

draw

One pen through every stroke in document order: each starts where the last one finished, and its share of the time matches its length, so the pen keeps one speed across the whole drawing.

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-draw-sequence" 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 drawSequence(svg) {
  const _reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
  const _duration = _reducedMotion ? 0.01 : 1.6
  const _stagger = _reducedMotion ? 0 : 0.06
  const _delay = _reducedMotion ? 0 : 0

  const shapes = paths(svg).filter((s) => s.getAttribute('stroke'))
  const lengths = shapes.map((s) => strokeLength(s))
  const total = lengths.reduce((a, b) => a + b, 0) || 1
  const easing = 'linear'

  // Each stroke's share of the duration is proportional to its length, so the
  // pen moves at one speed, and each waits for the previous one to finish.
  let start = _delay * 1000
  const animations = shapes.map((shape, i) => {
    const duration = (_duration * 1000 * lengths[i]) / total
    const pad = capPad(shape)
    const hidden = lengths[i] + pad
    shape.style.strokeDasharray = `${lengths[i]} ${hidden + pad}`
    const animation = shape.animate([{ strokeDashoffset: hidden }, { strokeDashoffset: 0 }], {
      duration,
      delay: start,
      easing,
      fill: 'both',
    })
    start += duration + _stagger * 1000
    // Once drawn, drop the dash so a later resize can't reopen a gap.
    animation.finished.then(() => (shape.style.strokeDasharray = 'none')).catch(() => {})
    return animation
  })

  return () => {
    animations.forEach((a) => a.cancel())
    shapes.forEach((shape) => {
      shape.style.strokeDasharray = ''
    })
  }
}

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

function strokeLength(el) {
  const m = el.getScreenCTM()
  const nonScaling = getComputedStyle(el).vectorEffect === 'non-scaling-stroke'
  return el.getTotalLength() * (nonScaling && m ? Math.hypot(m.a, m.b) : 1)
}

function capPad(el) {
  const s = getComputedStyle(el)
  return s.strokeLinecap === 'butt' ? 0 : (parseFloat(s.strokeWidth) || 0) / 2 + 1
}


drawSequence(document.querySelector('.k-draw-sequence'))