Trim Path
A stroke segment races along a path, its head pulling ahead so it grows, then its tail catching up so it shrinks, like After Effects’ trim paths. On a closed shape it keeps going round; on an open line it sweeps from end to end.
How to use it
- Paste the snippet just before
</head>. - Add
data-trim-pathto an inline<svg>drawn with strokes. Every shape in it gets its own segment. - Round line caps (
stroke-linecap="round") make the segment look its best.
Options
Add any of these attributes to the same element.
data-duration | Time for one sweep. Default 1600 ms. |
data-lag | How far the tail trails the head, from 0 to 0.9 of a sweep. Default 0.4. |
data-stagger | Gap between shapes. Default 220 ms. |
data-track | Opacity of the faint path underneath; 0 hides it. Default 0.14. |
data-once | One sweep that grows into the whole path and stays. |
Handy as a loading spinner on a circle or an infinity loop. It pauses while off screen, and with reduced motion the paths simply show.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Trim Path: a stroke segment races along an SVG path, its head pulling
// ahead so it grows, then its tail catching up so it shrinks, like After
// Effects' trim paths. On a closed shape it keeps going round; on an open
// line it sweeps from one end to the other.
// Usage:
// <svg data-trim-path viewBox="0 0 120 120" fill="none" stroke="#8b5cf6" stroke-width="6" stroke-linecap="round">
// <circle cx="60" cy="60" r="44"/>
// </svg>
// Optional: data-duration (ms per sweep, default 1600), data-lag (how far
// the tail trails the head, 0 to 0.9 of a sweep, default 0.4), data-stagger
// (ms between shapes, default 220), data-track (opacity of the faint path
// underneath, default 0.14; 0 hides it), data-once (one sweep, then it
// stays drawn).
var DEFAULTS = { duration: 1600, lag: 0.4, stagger: 220, track: 0.14 };
var SHAPES = 'path, line, polyline, polygon, circle, ellipse, rect';
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
// Ease out for the head and ease in for the tail: that's what makes it stretch.
function easeOut(t) { return 1 - Math.pow(1 - t, 3); }
function easeIn(t) { return t * t * t; }
function clamp(t) { return t < 0 ? 0 : t > 1 ? 1 : t; }
function closed(s) {
var tag = s.tagName.toLowerCase();
if (tag === 'circle' || tag === 'ellipse' || tag === 'rect' || tag === 'polygon') return true;
return tag === 'path' && /z\s*$/i.test(s.getAttribute('d') || '');
}
function setup(svg) {
var d = svg.dataset;
var track = d.track !== undefined ? +d.track : DEFAULTS.track;
var shapes = Array.prototype.filter.call(svg.querySelectorAll(SHAPES), function (s) {
return !s.closest('defs, clipPath, mask, .trp-track') && typeof s.getTotalLength === 'function';
});
if (!shapes.length) return null;
var items = shapes.map(function (s) {
if (track > 0) {
var t = s.cloneNode(false);
t.removeAttribute('id');
t.setAttribute('class', 'trp-track');
t.setAttribute('aria-hidden', 'true');
t.style.opacity = track;
s.parentNode.insertBefore(t, s);
}
return { el: s, L: s.getTotalLength(), loop: closed(s), turn: 0 };
});
return { svg: svg, items: items };
}
// Show the stretch of the path between head and tail (fractions 0..1),
// turned round by `turn` on closed shapes.
function place(it, tail, head) {
var L = it.L, len = (head - tail) * L;
var s = it.el.style;
if (len < 0.5) { s.opacity = 0; return; }
s.opacity = 1;
// A dash pattern exactly one lap long wraps neatly round a closed shape.
s.strokeDasharray = len + ' ' + (it.loop ? L - len : L * 2);
s.strokeDashoffset = -((tail + it.turn) % 1) * L;
}
function run(o) {
var d = o.svg.dataset;
var dur = +d.duration || DEFAULTS.duration;
var lag = Math.max(0, Math.min(0.9, d.lag !== undefined ? +d.lag : DEFAULTS.lag));
var stagger = d.stagger !== undefined ? +d.stagger : DEFAULTS.stagger;
var once = d.once !== undefined && d.once !== 'false';
var visible = false, raf = 0, clock = 0, last = 0;
// One cycle: the head sweeps over the first part, the tail over the last.
var span = 1 + lag;
var frame = function (now) {
raf = 0;
if (!visible) return;
clock += Math.min(50, now - (last || now));
last = now;
var running = false;
o.items.forEach(function (it, i) {
var t = (clock - i * stagger) / (dur * span);
if (t < 0) { place(it, 0, 0); running = true; return; }
var lap = Math.floor(t), f = t - lap;
if (once && lap > 0) {
// Grow to the full path and stay there.
it.el.style.strokeDasharray = 'none';
it.el.style.opacity = 1;
return;
}
running = true;
// Closed shapes start each lap a little further round, so it never repeats on the same spot.
if (it.loop) it.turn = (lap * 0.31) % 1;
var head = easeOut(clamp(f * span));
var tail = once ? 0 : easeIn(clamp(f * span - lag));
place(it, tail, head);
});
if (running) raf = requestAnimationFrame(frame);
};
o.items.forEach(function (it) { place(it, 0, 0); });
new IntersectionObserver(function (entries) {
visible = entries[0].isIntersecting;
last = 0;
if (visible && !raf) raf = requestAnimationFrame(frame);
}).observe(o.svg);
}
function init() {
document.querySelectorAll('[data-trim-path]').forEach(function (svg) {
if (svg._trp) return;
var o = setup(svg);
svg._trp = o || true;
if (!o || reduced()) return;
run(o);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><svg data-trim-path viewBox="0 0 120 120" width="64" fill="none"
stroke="#8b5cf6" stroke-width="8" stroke-linecap="round">
<circle cx="60" cy="60" r="44"/>
</svg>