← Charts

Area Chart Fill

A smooth line draws itself from left to right and the area beneath it fills in right behind the pen, while a little label rides the tip showing the value under it.

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-area-chart-fill="…values…" to an element with a height.

Options

Add any of these attributes to the same element.

data-labelsComma-separated labels shown under the chart.
data-colorLine and fill colour. Default #a855f7.
data-prefix / data-suffixText around the riding value label, e.g. $ or k.
data-tipfalse hides the riding value label.
data-durationTime to draw the line. Default 2000 ms.
data-loopFade out and fill again every few seconds.

The curve passes through every value without overshooting it. Screen readers hear all the values; with reduced motion the finished chart simply shows.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Area Chart Fill: a smooth line draws itself from left to right and the
    // area beneath it fills in right behind the tip, while a little label
    // rides along showing the value under the pen.
    // Usage: <div data-area-chart-fill="12,18,15,26,22,34,40" style="height:220px"></div>
    // Optional: data-labels="Mon,Tue,…" (shown under the chart), data-color
    // (default #a855f7), data-prefix / data-suffix (around the value label,
    // e.g. "$" or "k"), data-tip="false" (no riding label), data-duration
    // (ms to draw, default 2000), data-loop (empty and refill every few seconds).
    var DEFAULTS = { color: '#a855f7', duration: 2000, hold: 2400 };
    var uid = 0;

    var css =
      '.acf{position:relative;display:flex;flex-direction:column;font-variant-numeric:tabular-nums}' +
      '.acf-plot{position:relative;flex:1;min-height:0;margin-top:26px}' +
      '.acf-plot svg{position:absolute;inset:0;width:100%;height:100%;overflow:visible}' +
      '.acf-dot{position:absolute;left:0;top:100%;width:12px;height:12px;margin:-6px 0 0 -6px;border-radius:50%;background:#fff;border:3px solid var(--acf-color);box-sizing:border-box;box-shadow:0 0 0 5px color-mix(in srgb,var(--acf-color) 22%,transparent)}' +
      '.acf-tip{position:absolute;left:0;top:100%;padding:2px 8px;border-radius:99px;background:var(--acf-color);color:#fff;font-size:12px;font-weight:700;line-height:18px;white-space:nowrap;pointer-events:none;transform:translate(calc(var(--acf-p,0) * -100%),-150%)}' +
      '.acf-labels{display:flex;justify-content:space-between;font-size:12px;opacity:.6;margin-top:8px}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
    function esc(s) { return String(s).replace(/[&<>"]/g, function (c) { return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]; }); }

    // A smooth curve through the points that never overshoots them
    // (monotone cubic interpolation), as an SVG path.
    function smooth(pts) {
      var n = pts.length, i, m = [], dx = [], s = [];
      if (n < 3) return pts.map(function (p, i) { return (i ? 'L' : 'M') + p[0] + ' ' + p[1]; }).join(' ');
      for (i = 0; i < n - 1; i++) {
        dx[i] = pts[i + 1][0] - pts[i][0];
        s[i] = (pts[i + 1][1] - pts[i][1]) / dx[i];
      }
      m[0] = s[0]; m[n - 1] = s[n - 2];
      for (i = 1; i < n - 1; i++) m[i] = s[i - 1] * s[i] <= 0 ? 0 : (s[i - 1] + s[i]) / 2;
      var d = 'M' + pts[0][0] + ' ' + pts[0][1];
      for (i = 0; i < n - 1; i++) {
        var t = dx[i] / 3;
        d += ' C' + (pts[i][0] + t) + ' ' + (pts[i][1] + m[i] * t) + ' ' +
          (pts[i + 1][0] - t) + ' ' + (pts[i + 1][1] - m[i + 1] * t) + ' ' +
          pts[i + 1][0] + ' ' + pts[i + 1][1];
      }
      return d;
    }

    function setup(el) {
      var d = el.dataset;
      var values = el.getAttribute('data-area-chart-fill').split(',').map(Number);
      var labels = d.labels ? d.labels.split(',') : [];
      var color = d.color || DEFAULTS.color;
      var id = 'acf' + uid++;
      var n = values.length;
      var max = Math.max.apply(null, values), min = Math.min(0, Math.min.apply(null, values));
      var pts = values.map(function (v, i) {
        return [n > 1 ? (i / (n - 1)) * 100 : 50, 100 - ((v - min) / (max - min || 1)) * 90 - 4];
      });
      var line = smooth(pts);

      el.classList.add('acf');
      el.style.setProperty('--acf-color', color);
      el.setAttribute('role', 'img');
      el.setAttribute('aria-label', 'Area chart: ' + values.map(function (v, i) { return (labels[i] ? labels[i] + ' ' : '') + v; }).join(', '));
      el.innerHTML =
        '<div class="acf-plot" aria-hidden="true">' +
        '<svg viewBox="0 0 100 100" preserveAspectRatio="none">' +
        '<defs>' +
        '<linearGradient id="' + id + '-g" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="' + color + '" stop-opacity=".45"/><stop offset="1" stop-color="' + color + '" stop-opacity=".04"/></linearGradient>' +
        '<clipPath id="' + id + '-c"><rect class="acf-clip" x="-5" y="-10" width="0" height="120"/></clipPath>' +
        '</defs>' +
        [25, 50, 75].map(function (y) { return '<line x1="0" x2="100" y1="' + y + '" y2="' + y + '" stroke="currentColor" stroke-opacity=".1" vector-effect="non-scaling-stroke"/>'; }).join('') +
        '<line x1="0" x2="100" y1="100" y2="100" stroke="currentColor" stroke-opacity=".25" vector-effect="non-scaling-stroke"/>' +
        '<path class="acf-area" d="' + line + ' L100 100 L0 100 Z" fill="url(#' + id + '-g)" clip-path="url(#' + id + '-c)"/>' +
        '<path class="acf-line" d="' + line + '" clip-path="url(#' + id + '-c)" fill="none" stroke="' + color + '" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" vector-effect="non-scaling-stroke"/>' +
        '</svg>' +
        '<span class="acf-dot"></span>' +
        (d.tip === 'false' ? '' : '<span class="acf-tip"></span>') +
        '</div>' +
        (labels.length ? '<div class="acf-labels" aria-hidden="true">' + labels.map(function (l) { return '<span>' + esc(l) + '</span>'; }).join('') + '</div>' : '');

      var c = {
        el: el, values: values, pts: pts,
        line: el.querySelector('.acf-line'),
        area: el.querySelector('.acf-area'),
        clip: el.querySelector('.acf-clip'),
        dot: el.querySelector('.acf-dot'),
        tip: el.querySelector('.acf-tip'),
        prefix: d.prefix || '', suffix: d.suffix || ''
      };
      // Sample the curve so we can find its height at any x.
      var len = c.line.getTotalLength();
      c.samples = [];
      for (var i = 0; i <= 200; i++) {
        var pt = c.line.getPointAtLength((i / 200) * len);
        c.samples.push([pt.x, pt.y]);
      }
      el._acf = c;
      return c;
    }

    // The value under x (0–100), read straight off the data.
    function valueAt(c, x) {
      var n = c.values.length;
      if (n < 2) return c.values[0];
      var f = (x / 100) * (n - 1), i = Math.min(n - 2, Math.floor(f));
      return c.values[i] + (c.values[i + 1] - c.values[i]) * (f - i);
    }

    // The curve's height at x, from the samples.
    function yAt(c, x) {
      var s = c.samples, i = 1;
      while (i < s.length - 1 && s[i][0] < x) i++;
      var a = s[i - 1], b = s[i];
      return a[1] + (b[1] - a[1]) * ((x - a[0]) / (b[0] - a[0] || 1));
    }

    // Put everything at progress p (0–1): line and area revealed up to x, dot and label at the tip.
    function render(c, p) {
      var x = p * 100, y = yAt(c, x);
      c.clip.setAttribute('width', x + 5 + (p >= 1 ? 5 : 0));
      c.dot.style.left = x + '%';
      c.dot.style.top = y + '%';
      c.dot.style.opacity = p > 0 ? 1 : 0;
      if (c.tip) {
        var v = valueAt(c, x);
        var dec = c.values.some(function (x) { return x % 1; }) ? 1 : 0;
        c.tip.textContent = c.prefix + v.toFixed(dec) + c.suffix;
        c.tip.style.left = x + '%';
        c.tip.style.top = y + '%';
        c.tip.style.setProperty('--acf-p', p.toFixed(3));
        c.tip.style.opacity = p > 0 ? 1 : 0;
      }
    }

    function play(c) {
      var d = c.el.dataset;
      var duration = +d.duration || DEFAULTS.duration;
      var state = { p: 0 };
      anime.utils.set([c.area, c.line], { opacity: 1 });
      render(c, 0);
      anime.animate(state, {
        p: 1, duration: duration, ease: 'inOut(2)',
        onUpdate: function () { render(c, state.p); }
      });
      if (d.loop !== undefined && d.loop !== 'false') {
        setTimeout(function () {
          var fade = [c.area, c.line, c.dot];
          if (c.tip) fade.push(c.tip);
          anime.animate(fade, {
            opacity: 0, duration: 600, ease: 'out(2)',
            onComplete: function () { play(c); }
          });
        }, duration + DEFAULTS.hold);
      }
    }

    function init() {
      var io = new IntersectionObserver(function (entries) {
        entries.forEach(function (e) {
          if (!e.isIntersecting) return;
          io.unobserve(e.target);
          play(e.target._acf);
        });
      }, { threshold: 0.3 });
      document.querySelectorAll('[data-area-chart-fill]').forEach(function (el) {
        if (el._acf) return;
        var c = setup(el);
        if (reduced()) { render(c, 1); return; }
        render(c, 0);
        io.observe(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-area-chart-fill="14,22,18,30,26,38,34,46"
     data-labels="Jan,Feb,Mar,Apr,May,Jun,Jul,Aug"
     data-suffix="k"
     style="height: 240px"></div>