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
- Paste the snippet just before
</head>. - Add
data-area-chart-fill="…values…"to an element with a height.
Options
Add any of these attributes to the same element.
data-labels | Comma-separated labels shown under the chart. |
data-color | Line and fill colour. Default #a855f7. |
data-prefix / data-suffix | Text around the riding value label, e.g. $ or k. |
data-tip | false hides the riding value label. |
data-duration | Time to draw the line. Default 2000 ms. |
data-loop | Fade 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.
<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 { '&': '&', '<': '<', '>': '>', '"': '"' }[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><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>