Infinity Loader
A stroke chases itself round a figure-of-eight. The head runs on ahead while the tail catches up, so the line stretches and shrinks as it loops through the crossing. Add a faint track or a two-colour gradient. Size it with font-size and colour it with color.
How to use it
- Paste the snippet just before
</head>. - Add an empty
<div data-infinity-loader>where the loader should appear.
Options
Add any of these attributes to the same element.
data-speed | Time for one lap of the figure-of-eight. Default 1600 ms. |
data-track | Show a faint figure-of-eight behind the stroke. Give it a colour, or leave it empty for a faded copy of the stroke’s. |
data-thickness | Line width, in em. Default 0.14. |
data-colors | Two comma-separated colours for a gradient stroke, or one to override color. |
It’s 2.8em wide and 1.4em tall. It only moves while it’s on screen. With reduced motion it shows a still stretch of line.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Infinity Loader: a stroke chases itself round a figure-of-eight. Its head
// runs ahead and its tail catches up, so the line grows and shrinks as it
// loops. Sized with font-size (it's 2.8em wide), coloured with color.
// It only moves while it's on screen.
// Usage: <div data-infinity-loader aria-label="Loading"></div>
// Optional: data-speed (ms for one lap, default 1600), data-track (a faint
// figure-of-eight behind the stroke; give it a colour, or leave it empty for
// a faded copy of the stroke's), data-thickness (line width in em, default
// 0.14), data-colors (two comma-separated colours for a gradient stroke).
var DEFAULTS = { speed: 1600, thickness: 0.14 };
var css =
'.ifl{display:inline-block;width:2.8em;height:1.4em;vertical-align:middle}' +
'.ifl svg{display:block;width:100%;height:100%;overflow:visible}' +
'.ifl path{fill:none;stroke:currentColor;stroke-linecap:round;stroke-linejoin:round}' +
'.ifl-track{opacity:.15}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
var NS = 'http://www.w3.org/2000/svg';
// A smooth figure-of-eight in a 100 x 50 box, crossing in the middle.
var D = 'M50,25 C60,12 90,10 90,25 C90,40 60,38 50,25 C40,12 10,10 10,25 C10,40 40,38 50,25';
var uid = 0;
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function path(cls, w) {
var p = document.createElementNS(NS, 'path');
p.setAttribute('class', cls);
p.setAttribute('d', D);
p.setAttribute('pathLength', '100');
p.setAttribute('stroke-width', w);
return p;
}
function build(el) {
var d = el.dataset;
// Line width: em to viewBox units (the box is 2.8em = 100 units wide).
var w = Math.max(0.04, Math.min(0.4, +d.thickness || DEFAULTS.thickness)) * 100 / 2.8;
el.classList.add('ifl');
if (!el.getAttribute('role')) el.setAttribute('role', 'progressbar');
if (!el.getAttribute('aria-label')) el.setAttribute('aria-label', 'Loading');
el.textContent = '';
var svg = document.createElementNS(NS, 'svg');
svg.setAttribute('viewBox', '0 0 100 50');
svg.setAttribute('aria-hidden', 'true');
if (d.track !== undefined && d.track !== 'false') {
var track = path('ifl-track', w);
if (d.track) { track.style.stroke = d.track; track.style.opacity = 1; }
svg.appendChild(track);
}
var line = path('ifl-line', w);
var colors = d.colors ? d.colors.split(',').map(function (c) { return c.trim(); }) : [];
if (colors.length > 1) {
var id = 'ifl-grad-' + (++uid);
var defs = document.createElementNS(NS, 'defs');
defs.innerHTML = '<linearGradient id="' + id + '" x1="0" x2="1" y1="0" y2="0">' +
'<stop offset="0" stop-color="' + colors[0] + '"/><stop offset="1" stop-color="' + colors[1] + '"/></linearGradient>';
svg.appendChild(defs);
line.style.stroke = 'url(#' + id + ')';
} else if (colors.length) {
line.style.stroke = colors[0];
}
svg.appendChild(line);
el.appendChild(svg);
function draw(t) {
// The head moves steadily; the length swells and shrinks on a slower
// beat than the lap, so it never quite repeats.
var p = t / speed;
var len = 18 + 28 * (0.5 - 0.5 * Math.cos(p * Math.PI * 2 / 1.5));
line.setAttribute('stroke-dasharray', len + ' ' + (100 - len));
line.setAttribute('stroke-dashoffset', (-p * 100 % 100) + len);
}
var speed = Math.max(300, +d.speed || DEFAULTS.speed);
if (reduced()) { line.setAttribute('stroke-dasharray', '34 66'); line.setAttribute('stroke-dashoffset', '0'); return null; }
draw(0);
var t = 0, last = 0;
return anime.createTimer({
loop: true,
duration: 1000,
autoplay: false,
onUpdate: function () {
var now = performance.now();
t += last ? Math.min(50, now - last) : 16;
last = now;
draw(t);
},
onPause: function () { last = 0; },
});
}
function init() {
document.querySelectorAll('[data-infinity-loader]').forEach(function (el) {
if (el._ifl) return;
el._ifl = true;
var timer = build(el);
if (!timer) return;
new IntersectionObserver(function (entries) {
entries[0].isIntersecting ? timer.play() : timer.pause();
}).observe(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-infinity-loader style="font-size: 24px; color: #6a0dad" aria-label="Loading"></div>
<!-- with a track and a gradient: -->
<div data-infinity-loader data-track data-colors="#a855f7,#ec4899"></div>