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.
Every animation on the page redraws with the shape you pick.
<!-- 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'))