← Charts

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

  1. Paste the snippet just before </head>.
  2. 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-colorLine colour. Default green going up, red going down.
data-labelWhat it shows, for screen readers, e.g. Revenue, last 7 days.
data-fillfalse drops the soft fill under the line.
data-durationTime to draw. Default 1200 ms.
data-loopRedraw 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.

1 · Snippet
<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>
2 · Your HTML
<p>Revenue
  <span data-sparkline-draw="4,6,5,8,7,10,12"
        data-label="Revenue, last 7 days"></span>
  +12%</p>