Speed Lines
An element zooms in from the side with comic speed lines streaking behind it, leaning into the dash. It skids to a stop, rocking back upright, and the lines snap in after it. Loop it and it winds up and zooms off the other side.
Express
How to use it
- Paste the snippet just before
</head>. - Add
data-speed-linesto the element that moves: a badge, an icon, a label. - It travels across its parent, so give the parent
overflow: hidden.
Options
Add any of these attributes to the same element.
data-loop | Rest, zoom off the far side, come back and repeat. |
data-direction | Which way it travels: right or left. Default right. |
data-hold | How long it rests before zooming off, when looping. Default 1600 ms. |
data-duration | Time for the dash in. Default 600 ms. |
data-count | Number of lines. Default 6. |
data-length | Length of the longest line. Default 90 px. |
data-color | Line colour. Default the text colour. |
data-lean | How far it leans while moving. Default 14°. |
The lines flicker to new lengths as it moves, like hand-drawn streaks. It pauses while off screen, and with reduced motion it simply sits still.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Speed Lines: an element zooms in from the side with comic speed lines
// streaking behind it, leaning into the dash. It skids to a stop and the lines
// snap in after it. Loop it and it winds up and zooms off the other side,
// then comes round again.
// Usage: <div style="overflow: hidden"><span data-speed-lines>Express</span></div>
// The element travels across its parent, so give the parent overflow: hidden.
// Optional: data-loop (zoom off, come back, repeat), data-direction (right
// or left: which way it travels, default right), data-hold (ms it rests
// before zooming off, when looping, default 1600), data-count (lines,
// default 6), data-length (longest line, px, default 90), data-color
// (line colour, default the text colour), data-lean (degrees it leans
// while moving, default 14), data-duration (ms for the dash in, default 600).
var DEFAULTS = { hold: 1600, count: 6, length: 90, lean: 14, duration: 600 };
var css = '.spdl-lines{position:absolute;top:0;bottom:0;width:0;pointer-events:none}' +
'.spdl-line{position:absolute;height:3px;border-radius:3px;margin-top:-1.5px}';
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, f) { return v !== undefined && v !== '' && !isNaN(+v) ? +v : f; }
function clamp(t) { return t < 0 ? 0 : t > 1 ? 1 : t; }
function out(t, k) { return 1 - Math.pow(1 - t, k); }
function setup(el) {
var d = el.dataset;
var dir = d.direction === 'left' ? -1 : 1;
var count = Math.max(1, num(d.count, DEFAULTS.count));
var maxLen = num(d.length, DEFAULTS.length);
var color = d.color || 'currentColor';
if (getComputedStyle(el).position === 'static') el.style.position = 'relative';
if (getComputedStyle(el).display === 'inline') el.style.display = 'inline-block';
// The lines hang off the trailing side, so they travel with the element.
var box = document.createElement('span');
box.className = 'spdl-lines';
box.setAttribute('aria-hidden', 'true');
box.style[dir > 0 ? 'right' : 'left'] = '100%';
var lines = [];
for (var i = 0; i < count; i++) {
var ln = document.createElement('span');
ln.className = 'spdl-line';
var top = 10 + (i + 0.5) / count * 80 + (Math.random() - 0.5) * (60 / count);
ln.style.top = top.toFixed(1) + '%';
ln.style.height = (i % 3 === 1 ? 2 : 3) + 'px';
ln.style[dir > 0 ? 'right' : 'left'] = (6 + Math.random() * 12).toFixed(1) + 'px';
ln.style.background = 'linear-gradient(' + (dir > 0 ? 'to left' : 'to right') + ', ' + color + ', transparent)';
ln.style.transformOrigin = dir > 0 ? 'right center' : 'left center';
box.appendChild(ln);
lines.push({ el: ln, len: 0 });
}
el.appendChild(box);
return { el: el, dir: dir, lines: lines, maxLen: maxLen };
}
// Where the element must sit to be just out of its parent on each side.
function measure(o) {
var el = o.el, p = el.parentElement;
var keep = el.style.transform;
el.style.transform = 'none';
var r = el.getBoundingClientRect(), pr = p.getBoundingClientRect();
el.style.transform = keep;
var tail = o.maxLen + 30;
if (o.dir > 0) {
o.offIn = -(r.right - pr.left + 10);
o.offOut = pr.right - r.left + tail;
} else {
o.offIn = pr.right - r.left + 10;
o.offOut = -(r.right - pr.left + tail);
}
}
function run(o) {
var d = o.el.dataset;
var loop = d.loop !== undefined && d.loop !== 'false';
var E = num(d.duration, DEFAULTS.duration), S = 520, H = num(d.hold, DEFAULTS.hold), W = 240, X = 420, G = 300;
var lean = num(d.lean, DEFAULTS.lean) * o.dir;
var visible = false, raf = 0, last = 0, clock = 0, jitter = 0;
measure(o);
// Lines flicker to new lengths every few frames, like hand-drawn streaks.
function lines(k, now) {
var fresh = now - jitter > 70;
if (fresh) jitter = now;
o.lines.forEach(function (l) {
if (fresh || !l.len) l.len = o.maxLen * (0.45 + Math.random() * 0.55);
l.el.style.width = l.len.toFixed(1) + 'px';
l.el.style.transform = 'scaleX(' + k.toFixed(3) + ')';
l.el.style.opacity = k > 0.01 ? 1 : 0;
});
}
function pose(x, skew) {
o.el.style.transform = 'translateX(' + x.toFixed(1) + 'px) skewX(' + (-skew).toFixed(2) + 'deg)';
}
function frame(now) {
raf = 0;
if (!visible) return;
clock += Math.min(50, now - (last || now));
last = now;
var cycle = E + S + H + W + X + G;
var t = loop ? clock % cycle : Math.min(clock, E + S);
var p;
if (t < E) {
p = t / E;
pose(o.offIn * (1 - out(p, 4)), lean * out(clamp(p * 3), 2));
lines(clamp(p * 5), now);
} else if ((t -= E) < S) {
// Skid: the lean springs back past upright and settles; the lines catch up.
p = t / S;
pose(0, lean * Math.exp(-4 * p) * Math.cos(Math.PI * 2.4 * p));
lines(1 - out(clamp(p * 1.7), 2), now);
} else if ((t -= S) < H) {
pose(0, 0);
lines(0, now);
} else if ((t -= H) < W) {
// Wind up: a little step back, leaning away.
p = out(t / W, 2);
pose(-12 * o.dir * p, -lean * 0.55 * p);
lines(0, now);
} else if ((t -= W) < X) {
p = t / X;
pose(-12 * o.dir + (o.offOut + 12 * o.dir) * Math.pow(p, 3), lean * (1.25 * out(clamp(p * 2.5), 2) - 0.55 * (1 - clamp(p * 2.5))));
lines(clamp(p * 4), now);
} else {
pose(o.offIn, 0);
lines(0, now);
}
if (loop || clock < E + S) raf = requestAnimationFrame(frame);
}
pose(o.offIn, 0);
lines(0, 0);
new IntersectionObserver(function (entries) {
visible = entries[0].isIntersecting;
last = 0;
if (visible && !raf) raf = requestAnimationFrame(frame);
}).observe(o.el.parentElement);
if (window.ResizeObserver) new ResizeObserver(function () { measure(o); }).observe(o.el.parentElement);
}
function init() {
if (reduced()) return;
document.querySelectorAll('[data-speed-lines]').forEach(function (el) {
if (el._spdl) return;
el._spdl = true;
run(setup(el));
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div style="overflow: hidden">
<span data-speed-lines data-loop data-color="#8b5cf6">Express</span>
</div>