Scroll-drawn Path
An SVG line draws itself as you scroll, its tip keeping pace with the screen, and rubs itself out again if you scroll back. A glowing dot rides the tip and milestones pop in as the line reaches them. Made for timelines and roadmaps.
Scroll inside ↓
How to use it
- Paste the snippet just before
</head>. - Add
data-scroll-drawn-pathto an<svg>. Its paths, lines and circles draw as you scroll. - Add
data-at="0.5"to any marker or label that should pop in once the line gets that far (0 to 1).
Options
Add any of these attributes to the same element.
data-at | On a marker inside: how far along the line it appears, 0 to 1. |
data-pen="false" | No glowing dot on the tip. |
data-pen-color | Colour of the dot. Default: the line’s colour. |
data-offset | Where the tip sits in the view, from 0 (top) to 1 (bottom). Default 0.6. |
Best with a line that runs down the drawing, as the tip follows the scroll from top to bottom. Works inside scrolling boxes too.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Scroll-drawn Path: an SVG line draws itself as you scroll, its tip keeping
// pace with the middle of the screen, and rubs itself out again if you
// scroll back. A glowing dot rides the tip, and markers along the way pop
// in as the line reaches them. Made for timelines and roadmaps.
// Usage: <svg data-scroll-drawn-path viewBox="0 0 200 600">
// <path d="M100 0 C 20 150, 180 300, 100 600" stroke="#a855f7" fill="none"/>
// <circle data-at="0.5" cx="100" cy="300" r="8"/> (pops in halfway)
// </svg>
// Every <path>, <line>, <polyline> and <circle> without data-at is drawn.
// Optional: data-at on any element inside: the point (0–1) along the line
// where it pops in. data-pen="false" (no dot on the tip), data-pen-color
// (default: the line's colour), data-offset (where the tip sits in the
// view, 0 top to 1 bottom, default 0.6).
// Works inside scrolling boxes too: it follows the nearest scrolling parent.
var DEFAULTS = { offset: 0.6 };
function scroller(el) {
for (var p = el.parentElement; p && p !== document.body; p = p.parentElement) {
var o = getComputedStyle(p).overflowY;
if ((o === 'auto' || o === 'scroll') && p.scrollHeight > p.clientHeight) return p;
}
return null;
}
function setup(svg) {
var d = svg.dataset;
var offset = d.offset !== undefined ? +d.offset : DEFAULTS.offset;
var lines = Array.prototype.slice.call(svg.querySelectorAll('path, line, polyline, circle')).filter(function (n) {
return !n.hasAttribute('data-at') && !n.closest('[data-at]');
});
if (!lines.length) return;
var marks = Array.prototype.slice.call(svg.querySelectorAll('[data-at]')).map(function (n) {
n.style.transformBox = 'fill-box';
n.style.transformOrigin = '50% 50%';
anime.utils.set(n, { opacity: 0, scale: 0.4 });
return { el: n, at: +n.dataset.at || 0, on: false };
});
// The longest line is the one the tip follows and the markers count along.
var main = lines[0], len = 0, lengths = [];
function measure() {
lengths = lines.map(function (n) { return n.getTotalLength(); });
len = 0;
lines.forEach(function (n, i) {
if (lengths[i] > len) { len = lengths[i]; main = n; }
n.style.strokeDasharray = lengths[i] + ' ' + (lengths[i] + 1);
});
}
measure();
var pen = null;
if (d.pen !== 'false') {
pen = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
var w = parseFloat(getComputedStyle(main).strokeWidth) || 2;
var color = d.penColor || getComputedStyle(main).stroke;
if (!color || color === 'none' || color.indexOf('url(') === 0) color = 'currentColor';
pen.setAttribute('r', Math.max(4, w * 1.4));
pen.setAttribute('fill', color);
pen.style.filter = 'drop-shadow(0 0 ' + w * 2 + 'px ' + color + ')';
pen.style.transition = 'opacity .2s';
svg.appendChild(pen);
}
var box = scroller(svg);
var last = -1, queued = false;
function update() {
queued = false;
if (!len) measure();
if (!len) return;
var r = svg.getBoundingClientRect();
var top = 0, h = window.innerHeight;
if (box) { var b = box.getBoundingClientRect(); top = b.top + box.clientTop; h = box.clientHeight; }
// The tip follows a line across the view (60% of the way down by
// default): drawn up to wherever that line crosses the drawing.
var p = (top + h * offset - r.top) / r.height;
p = Math.max(0, Math.min(1, p));
if (Math.abs(p - last) < 0.0005) return;
last = p;
lines.forEach(function (n, i) { n.style.strokeDashoffset = lengths[i] * (1 - p); });
if (pen) {
var pt = main.getPointAtLength(len * p);
pen.setAttribute('cx', pt.x);
pen.setAttribute('cy', pt.y);
pen.style.opacity = p > 0.001 && p < 0.999 ? 1 : 0;
}
marks.forEach(function (m) {
var on = p >= m.at;
if (on === m.on) return;
m.on = on;
anime.animate(m.el, on
? { opacity: 1, scale: [0.4, 1], duration: 600, ease: 'outElastic(1, .6)' }
: { opacity: 0, scale: 0.4, duration: 250, ease: 'in(2)' });
});
}
function onScroll() {
if (queued) return;
queued = true;
requestAnimationFrame(update);
}
(box || window).addEventListener('scroll', onScroll, { passive: true });
window.addEventListener('resize', onScroll);
update();
}
function init() {
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
document.querySelectorAll('[data-scroll-drawn-path]').forEach(function (el) {
if (el._sdp) return;
el._sdp = true;
setup(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><svg data-scroll-drawn-path viewBox="0 0 200 600" fill="none">
<path d="M100 0 C20 150 180 300 100 600" stroke="#a855f7" stroke-width="4"/>
<circle data-at="0.5" cx="100" cy="300" r="8" fill="#a855f7"/>
</svg>