Trace Highlight

draw

A short bright dash runs the length of each stroke over a dimmed copy of the drawing, which comes back to full strength once the dash has passed. The dash is a fixed slice of the path, not a draw.

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

  const shapes = paths(svg).filter((s) => s.getAttribute('stroke'))
  const easing = 'linear(0.0000,0.0013,0.0053,0.0118,0.0210,0.0329,0.0473,0.0644,0.0842,0.1065,0.1315,0.1591,0.1893,0.2222,0.2577,0.2959,0.3366,0.3800,0.4260,0.4747,0.5253,0.5740,0.6200,0.6634,0.7041,0.7423,0.7778,0.8107,0.8409,0.8685,0.8935,0.9158,0.9356,0.9527,0.9671,0.9790,0.9882,0.9947,0.9987,1.0000)'
  // The dash runs on a copy laid over each stroke, so the drawing itself can
  // dim underneath it rather than disappear.
  const traces = shapes.map((shape) => {
    const trace = shape.cloneNode(true)
    shape.after(trace)
    return trace
  })

  const ms = _duration * 1000
  const lag = _stagger * 1000
  const travel = ms * 0.85
  // Dim for 12%, hold until the last dash has run, then come back up.
  const total = (traces.length - 1) * lag + ms
  const dimmed = (ms * 0.12) / total
  const restore = ((traces.length - 1) * lag + travel) / total

  const animations = [
    ...shapes.map((shape) =>
      shape.animate(
        [{ opacity: 1 }, { opacity: 0.25, offset: dimmed }, { opacity: 0.25, offset: restore }, { opacity: 1 }],
        { duration: total, delay: _delay * 1000, fill: 'both' },
      ),
    ),
    ...traces.map((trace, i) => {
      const length = strokeLength(trace)
      const pad = capPad(trace)
      const dash = length * 0.14
      trace.style.strokeDasharray = `${dash} ${length + 2 * pad}`
      return trace.animate([{ strokeDashoffset: dash + pad }, { strokeDashoffset: -(length + pad) }], {
        duration: travel,
        delay: _delay * 1000 + i * lag,
        easing,
        fill: 'both',
      })
    }),
  ]
  Promise.all(animations.map((a) => a.finished))
    .then(() => traces.forEach((trace) => trace.remove()))
    .catch(() => {})

  return () => {
    animations.forEach((a) => a.cancel())
    traces.forEach((trace) => trace.remove())
  }
}

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
}


traceHighlight(document.querySelector('.k-trace-highlight'))