Scroll Path Travel
A paper plane flies along a winding route as you scroll, banking round the curves and leaving a trail, and each stop lights up as it passes. Scroll back and it flies home again.
Scroll inside ↓
How to use it
- Paste the snippet just before
</head>. - Add
data-scroll-path-travelto a container holding an<svg>with the route as a<path data-path>. - Add the traveller inside the SVG as
<g data-traveller>, drawn pointing right around 0,0 (or use any HTML element, like an image). - Optionally add stops as
<g data-at="0.5">(0 is the start, 1 the end). They’re placed on the route and get the classsptv-ononce the traveller passes.
Options
Add any of these attributes to the same element.
data-start | Where in the view the container’s top starts the trip, from 0 (top) to 1 (bottom). Default 0.8. |
data-end | Where in the view the container’s bottom ends the trip. Default 0.3. |
data-rotate="false" | Keep the traveller upright instead of turning with the route. |
data-trail | The trail’s colour. Default the SVG’s text colour; false for no trail. |
data-smooth | How quickly it catches up with the scroll, from 0 to 1. Default 0.14; 1 follows it exactly. |
Works inside scrolling boxes too: it follows the nearest scrolling parent.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Scroll Path Travel: something, like a paper plane, travels along a
// winding SVG path as you scroll, turning to follow the curves and leaving
// a trail behind it. Scroll back and it flies back. Stops along the way
// light up as it passes them.
// Usage: <div data-scroll-path-travel>
// <svg viewBox="0 0 300 600">
// <path data-path d="…" fill="none" stroke="#ddd"/>
// <g data-traveller>…</g> (drawn pointing right, centred on 0,0)
// <g data-at="0.5">…</g> (optional stops, drawn round 0,0:
// placed halfway along, lit once passed)
// </svg>
// </div>
// The traveller can also be an HTML element (an <img>, an emoji) anywhere
// inside the container; it is moved to sit on the path.
// Optional: data-start / data-end (where in the view the trip starts and
// ends: the trip starts when the container's top reaches data-start and
// ends when its bottom reaches data-end, 0 = top, 1 = bottom of the view;
// defaults 0.8 and 0.3), data-rotate="false" (keep the traveller upright),
// data-trail (the trail's colour, default the SVG's text colour;
// "false" for no trail), data-smooth (how quickly it catches up
// with the scroll, 0 to 1, default 0.14; 1 follows it exactly).
// Stops with data-at (0 to 1) get the class sptv-on once it passes them.
// Works inside scrolling boxes too: it follows the nearest scrolling parent.
var DEFAULTS = { start: 0.8, end: 0.3, smooth: 0.14 };
var css =
'.sptv-trail{fill:none!important;pointer-events:none}' +
'.sptv-html{position:absolute;left:0;top:0;pointer-events:none;will-change:transform}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function num(v, fallback) { return v !== undefined && v !== '' && !isNaN(+v) ? +v : fallback; }
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') return p;
}
return null;
}
function setup(el) {
var d = el.dataset;
var startAt = num(d.start, DEFAULTS.start);
var endAt = num(d.end, DEFAULTS.end);
var smooth = Math.max(0.01, Math.min(1, num(d.smooth, DEFAULTS.smooth)));
var turn = d.rotate !== 'false';
var svg = el.querySelector('svg');
var path = el.querySelector('[data-path]') || (svg && svg.querySelector('path'));
var traveller = el.querySelector('[data-traveller]');
if (!svg || !path || !traveller) return;
var inSvg = traveller instanceof SVGElement;
var stops = Array.prototype.slice.call(el.querySelectorAll('[data-at]'));
var box = scroller(el);
if (reduced()) smooth = 1;
var len = path.getTotalLength();
// SVG stops sit on the path at their data-at.
stops.forEach(function (s) {
if (!(s instanceof SVGElement)) return;
var sp = path.getPointAtLength(Math.max(0, Math.min(1, num(s.getAttribute('data-at'), 0))) * len);
s.setAttribute('transform', 'translate(' + sp.x.toFixed(2) + ' ' + sp.y.toFixed(2) + ')');
});
// A copy of the path, drawn over it only as far as the traveller has got.
var trail = null;
if (d.trail !== 'false') {
trail = path.cloneNode(false);
trail.removeAttribute('data-path');
trail.removeAttribute('id');
trail.classList.add('sptv-trail');
trail.setAttribute('aria-hidden', 'true');
var cs = getComputedStyle(path);
trail.style.stroke = d.trail || 'currentColor';
trail.style.strokeWidth = cs.strokeWidth;
trail.style.strokeLinecap = 'round';
trail.style.strokeDasharray = len + ' ' + len;
trail.style.strokeDashoffset = len;
path.parentNode.insertBefore(trail, path.nextSibling);
}
if (!inSvg) {
if (getComputedStyle(el).position === 'static') el.style.position = 'relative';
traveller.classList.add('sptv-html');
}
var target = 0, shown = -1, running = false, visible = true;
function progress() {
var r = el.getBoundingClientRect();
var top = box ? box.getBoundingClientRect().top + box.clientTop : 0;
var viewH = box ? box.clientHeight : window.innerHeight;
var from = top + viewH * startAt; // container top here: the trip starts
var to = top + viewH * endAt - r.height; // container bottom here: the trip ends
return Math.max(0, Math.min(1, (from - r.top) / (from - to || 1)));
}
function paint() {
var l = shown * len;
var pt = path.getPointAtLength(l);
var a = 0;
if (turn) {
var p0 = path.getPointAtLength(Math.max(0, l - 1));
var p1 = path.getPointAtLength(Math.min(len, l + 1));
a = Math.atan2(p1.y - p0.y, p1.x - p0.x) * 180 / Math.PI;
}
if (inSvg) {
traveller.setAttribute('transform', 'translate(' + pt.x.toFixed(2) + ' ' + pt.y.toFixed(2) + ') rotate(' + a.toFixed(2) + ')');
} else {
// Map the point from SVG units to the container's pixels.
var m = path.getScreenCTM();
var er = el.getBoundingClientRect();
var sp = svg.createSVGPoint();
sp.x = pt.x; sp.y = pt.y;
sp = sp.matrixTransform(m);
var x = sp.x - er.left - el.clientLeft - traveller.offsetWidth / 2;
var y = sp.y - er.top - el.clientTop - traveller.offsetHeight / 2;
traveller.style.transform = 'translate(' + x.toFixed(1) + 'px,' + y.toFixed(1) + 'px) rotate(' + a.toFixed(2) + 'deg)';
}
if (trail) trail.style.strokeDashoffset = (len - l).toFixed(2);
for (var i = 0; i < stops.length; i++) {
stops[i].classList.toggle('sptv-on', shown >= num(stops[i].getAttribute('data-at'), 2) - 0.002);
}
}
function tick() {
shown += (target - shown) * smooth;
if (Math.abs(target - shown) < 0.0005) shown = target;
paint();
if (shown !== target && visible) requestAnimationFrame(tick);
else running = false;
}
function onScroll() {
target = progress();
if (shown < 0 || !visible) { shown = target; paint(); return; }
if (running) return;
running = true;
requestAnimationFrame(tick);
}
if ('IntersectionObserver' in window) {
new IntersectionObserver(function (entries) {
visible = entries[0].isIntersecting;
if (visible) onScroll();
}, { root: box }).observe(el);
}
onScroll();
(box || window).addEventListener('scroll', onScroll, { passive: true });
window.addEventListener('resize', function () { shown = -1; onScroll(); });
}
function init() {
document.querySelectorAll('[data-scroll-path-travel]').forEach(function (el) {
if (el._sptv) return;
el._sptv = true;
setup(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-scroll-path-travel data-trail="#a855f7">
<svg viewBox="0 0 300 640">
<path data-path d="M60 30 C60 130 240 100 240 210 S60 310 60 420"
fill="none" stroke="#e9d5ff" stroke-width="5" stroke-dasharray="2 12"/>
<g data-at="1"><circle r="10"/></g>
<g data-traveller><path d="M-17 -13 L19 0 L-17 13 L-9 0Z"/></g>
</svg>
</div>