Sparkline Draw
A small trend line that sits right in a sentence or a stat card and draws itself from left to right, a dot riding its tip, then pings once at the latest value. Green when it’s going up, red when it’s going down.
Sign-ups are up 64% this quarter, while refunds keep falling.
Revenue+18%
Visitors+42%
Bounce rate−9%
How to use it
- Paste the snippet just before
</head>. - Add
data-sparkline-draw="…values…"to an empty<span>wherever the trend should go.
Options
Add any of these attributes to the same element.
data-color | Line colour. Default green going up, red going down. |
data-label | What it shows, for screen readers, e.g. Revenue, last 7 days. |
data-fill | false drops the soft fill under the line. |
data-duration | Time to draw. Default 1200 ms. |
data-loop | Redraw every few seconds. |
It’s sized in em (5×1.4), so it matches the text around it; set a width and height to change that. Screen readers hear the label, the values and the trend.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Sparkline Draw: a small trend line that sits inline with text and draws
// itself from left to right, a dot riding its tip and a soft fill rising
// behind it, then pings once at the latest value. Green when the trend is
// up, red when it's down.
// Usage: Revenue <span data-sparkline-draw="4,6,5,8,7,10,12"></span> +12%
// It's sized in em (5em × 1.4em), so it matches the text around it; set a
// width and height to change that.
// Optional: data-color (line colour; default green up, red down, purple
// flat), data-fill="false" (no fill under the line), data-label (what it
// shows, for screen readers, e.g. "Revenue, last 7 days"), data-duration
// (ms, default 1200), data-loop (redraw every few seconds).
var DEFAULTS = { up: '#16a34a', down: '#dc2626', flat: '#a855f7', duration: 1200, hold: 3000 };
var uid = 0;
var css =
'[data-sparkline-draw]{display:inline-block;position:relative;width:5em;height:1.4em;vertical-align:-0.3em}' +
'.spl svg{position:absolute;inset:0;width:100%;height:100%;overflow:visible}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
var NS = 'http://www.w3.org/2000/svg';
function build(el) {
var d = el.dataset;
var values = el.getAttribute('data-sparkline-draw').split(',').map(Number).filter(function (v) { return !isNaN(v); });
if (values.length < 2) values = [values[0] || 0, values[0] || 0];
var first = values[0], last = values[values.length - 1];
var trend = last > first ? 'up' : last < first ? 'down' : 'flat';
var color = d.color || DEFAULTS[trend];
var id = 'spl' + uid++;
el.classList.add('spl');
el.setAttribute('role', 'img');
var change = first ? Math.round(((last - first) / Math.abs(first)) * 100) : 0;
el.setAttribute('aria-label', (d.label || 'Trend') + ': ' + values.join(', ') + (trend === 'flat' ? ', level' : ', ' + trend + (change ? ' ' + Math.abs(change) + '%' : '')));
el.innerHTML =
'<svg aria-hidden="true">' +
'<defs><linearGradient id="' + id + 'g" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="' + color + '" stop-opacity=".3"/><stop offset="1" stop-color="' + color + '" stop-opacity="0"/></linearGradient>' +
'<clipPath id="' + id + 'c"><rect x="-4" y="-50%" height="200%" width="0"/></clipPath></defs>' +
'<g clip-path="url(#' + id + 'c)">' +
(d.fill === 'false' ? '' : '<path class="spl-area" fill="url(#' + id + 'g)"/>') +
'<path class="spl-line" fill="none" stroke="' + color + '" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"/></g>' +
'<circle class="spl-ping" r="2.6" fill="none" stroke="' + color + '" stroke-width="1.5" opacity="0"/>' +
'<circle class="spl-dot" r="2.6" fill="' + color + '" opacity="0"/>' +
'</svg>';
var c = {
el: el, values: values, pts: [], w: 0, state: { p: 0 },
clip: el.querySelector('clipPath rect'), line: el.querySelector('.spl-line'), area: el.querySelector('.spl-area'),
dot: el.querySelector('.spl-dot'), ping: el.querySelector('.spl-ping')
};
layout(c);
if (window.ResizeObserver) new ResizeObserver(function () { layout(c); render(c); }).observe(el);
return c;
}
// Points in the element's own pixels, so the line stays crisp at any size.
function layout(c) {
var w = c.el.clientWidth, h = c.el.clientHeight, pad = 3;
var v = c.values, n = v.length;
var lo = Math.min.apply(null, v), hi = Math.max.apply(null, v), span = hi - lo || 1;
c.w = w;
c.pts = v.map(function (x, i) { return [pad + (i / (n - 1)) * (w - pad * 2), hi === lo ? h / 2 : pad + (1 - (x - lo) / span) * (h - pad * 2)]; });
var line = c.pts.map(function (p, i) { return (i ? 'L' : 'M') + p[0].toFixed(2) + ' ' + p[1].toFixed(2); }).join('');
c.line.setAttribute('d', line);
if (c.area) c.area.setAttribute('d', line + 'L' + c.pts[n - 1][0] + ' ' + h + 'L' + c.pts[0][0] + ' ' + h + 'Z');
}
function tipAt(c, x) {
var p = c.pts;
for (var i = 1; i < p.length; i++) {
if (x <= p[i][0] || i === p.length - 1) {
var t = Math.max(0, Math.min(1, (x - p[i - 1][0]) / (p[i][0] - p[i - 1][0] || 1)));
return [x, p[i - 1][1] + (p[i][1] - p[i - 1][1]) * t];
}
}
return p[0];
}
function render(c) {
var p = c.state.p, a = c.pts[0][0], b = c.pts[c.pts.length - 1][0];
var x = a + (b - a) * p;
var tip = tipAt(c, x);
c.clip.setAttribute('width', x + 4);
c.dot.setAttribute('cx', tip[0]);
c.dot.setAttribute('cy', tip[1]);
c.ping.setAttribute('cx', tip[0]);
c.ping.setAttribute('cy', tip[1]);
c.dot.setAttribute('opacity', p > 0 ? 1 : 0);
}
function draw(c) {
var d = c.el.dataset;
anime.utils.set(c.ping, { opacity: 0 });
anime.animate(c.state, {
p: [0, 1], duration: +d.duration || DEFAULTS.duration, ease: 'inOut(2)', onUpdate: function () { render(c); },
onComplete: function () {
// A ping at the latest value.
anime.animate(c.ping, { r: [2.6, 9], opacity: [0.8, 0], duration: 900, ease: 'out(2)', loop: 1, loopDelay: 150 });
if (d.loop === undefined || d.loop === 'false') return;
setTimeout(function () {
anime.animate(c.state, { p: 0, duration: 500, ease: 'in(2)', onUpdate: function () { render(c); }, onComplete: function () { setTimeout(function () { draw(c); }, 250); } });
}, DEFAULTS.hold);
}
});
}
function init() {
var reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (e) {
if (!e.isIntersecting) return;
io.unobserve(e.target);
draw(e.target._spl);
});
}, { threshold: 0.6 });
document.querySelectorAll('[data-sparkline-draw]').forEach(function (el) {
if (el._spl) return;
var c = (el._spl = build(el));
if (reduced) { c.state.p = 1; render(c); return; }
render(c);
io.observe(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><p>Revenue
<span data-sparkline-draw="4,6,5,8,7,10,12"
data-label="Revenue, last 7 days"></span>
+12%</p>