Chart Crosshair Tooltip
Move across a line chart and a crosshair follows, gliding from point to point with a dot riding each line. A tooltip shows the month and every series’ figure, and flips to the other side near the edge. On a phone, drag across the chart.
VisitsSign-ups
How to use it
- Paste the snippet just before
</head>. - Add
data-chart-crosshair-tooltipto a container with a height, with one child per line: its text is the name anddata-valuesits figures.
Options
Add any of these attributes to the same element.
data-labels | Comma-separated labels along the bottom, also shown in the tooltip. |
data-colors | Comma-separated colours, one per line. Default purple, pink, blue… |
data-prefix / data-suffix | Text around the figures in the tooltip. |
data-autoplay | Sweep across by itself until a real pointer arrives, and again a moment after it leaves. |
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Chart Crosshair Tooltip: a line chart with a crosshair that follows the
// pointer. It glides from point to point, a dot rides each line, and a
// tooltip shows the values, flipping sides near the edge. On a phone, drag
// across the chart. With data-autoplay a pretend pointer sweeps across
// until someone uses it.
// Usage:
// <div data-chart-crosshair-tooltip data-labels="Jan,Feb,Mar,Apr" style="height:240px">
// <span data-values="12,18,15,26">Visits</span>
// <span data-values="8,10,14,13">Sign-ups</span>
// </div>
// Each child is one line: its text is the name, data-values its values.
// Optional: data-labels (comma-separated x labels), data-colors (one per
// line, default purple, pink, blue…), data-prefix / data-suffix (around
// the values), data-autoplay (sweep by itself until a real pointer
// arrives, and again a moment after it leaves).
var PALETTE = ['#a855f7', '#ec4899', '#0ea5e9', '#f59e0b', '#10b981', '#6366f1'];
var NS = 'http://www.w3.org/2000/svg';
var css =
'.cxt{position:relative;font-size:11px;font-variant-numeric:tabular-nums;touch-action:pan-y;user-select:none;-webkit-user-select:none}' +
'.cxt svg{position:absolute;inset:0;width:100%;height:100%;overflow:visible}' +
'.cxt-grid{stroke:currentColor;stroke-opacity:.08}' +
'.cxt-axis{stroke:currentColor;stroke-opacity:.3}' +
'.cxt-tick{fill:currentColor;fill-opacity:.5}' +
'.cxt-line{fill:none;stroke-width:2.5;stroke-linejoin:round;stroke-linecap:round}' +
'.cxt-v{stroke:currentColor;stroke-opacity:.45;stroke-width:1}' +
'.cxt-h{stroke:currentColor;stroke-opacity:.3;stroke-width:1;stroke-dasharray:3 3}' +
'.cxt-dot{stroke:#fff;stroke-width:2.5}' +
'.cxt-cross{transition:opacity .25s}' +
'.cxt-tip{position:absolute;top:0;left:0;pointer-events:none;background:#1f1630;color:#fff;border-radius:8px;padding:6px 9px;' +
'box-shadow:0 6px 18px rgba(0,0,0,.18);white-space:nowrap;line-height:1.45;font-size:12px;transition:opacity .25s;will-change:transform}' +
'.cxt-tip b{display:block;font-size:11px;opacity:.7;font-weight:600}' +
'.cxt-tip i{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:6px;vertical-align:0}' +
'.cxt-tip span{display:block}' +
'.cxt-tip strong{margin-left:6px}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function mk(tag, attrs, parent) {
var n = document.createElementNS(NS, tag);
for (var k in attrs) n.setAttribute(k, attrs[k]);
if (parent) parent.appendChild(n);
return n;
}
function nice(hi) {
var raw = hi / 4, p = Math.pow(10, Math.floor(Math.log10(raw)));
return [1, 2, 2.5, 5, 10].map(function (m) { return m * p; }).filter(function (s) { return s >= raw; })[0];
}
function setup(el) {
var d = el.dataset;
var colors = d.colors ? d.colors.split(',') : PALETTE;
var series = [];
Array.prototype.forEach.call(el.children, function (ch) {
var vs = (ch.getAttribute('data-values') || '').split(',').map(parseFloat).filter(function (v) { return !isNaN(v); });
if (vs.length < 2) return;
series.push({ name: ch.textContent.trim(), vs: vs, color: ch.getAttribute('data-color') || colors[series.length % colors.length] });
});
if (!series.length) return null;
var n = Math.min.apply(null, series.map(function (s) { return s.vs.length; }));
var hi = Math.max.apply(null, series.map(function (s) { return Math.max.apply(null, s.vs); })) || 1;
var step = nice(hi);
var pre = d.prefix || '', suf = d.suffix || '';
var c = {
el: el, series: series, n: n, labels: d.labels ? d.labels.split(',') : [], step: step,
max: Math.ceil(hi * 1.08 / step) * step, fmt: function (v) { return pre + (+v.toFixed(2)) + suf; },
idx: 0, cx: null, show: 0, user: false, autoplay: d.autoplay !== undefined && d.autoplay !== 'false'
};
el.classList.add('cxt');
el.setAttribute('role', 'img');
el.setAttribute('aria-label', 'Line chart. ' + series.map(function (s) {
return s.name + ': ' + s.vs.slice(0, n).map(function (v, i) { return (c.labels[i] ? c.labels[i] + ' ' : '') + c.fmt(v); }).join(', ');
}).join('. ') + '.');
el._cxt = c;
layout(c);
function local(e) { var r = el.getBoundingClientRect(); return e.clientX - r.left; }
function point(e) {
c.user = true;
c.lastUser = performance.now();
c.px = local(e);
c.show = 1;
wake(c);
}
el.addEventListener('pointermove', point);
el.addEventListener('pointerdown', point);
function leave() {
c.lastUser = performance.now();
c.user = false;
wake(c);
}
el.addEventListener('pointerleave', leave);
el.addEventListener('pointercancel', leave);
return c;
}
function layout(c) {
var W = c.el.clientWidth, H = c.el.clientHeight;
if (!W || !H) return;
c.W = W; c.H = H;
var left = String(c.max).length * 6.4 + 10, right = W - 14, top = 10, base = H - (c.labels.length ? 20 : 6);
c.left = left; c.right = right; c.top = top; c.base = base;
c.x = function (i) { return left + i / (c.n - 1) * (right - left); };
c.y = function (v) { return base - v / c.max * (base - top); };
var svg = mk('svg', { viewBox: '0 0 ' + W + ' ' + H, 'aria-hidden': 'true' });
for (var t = 0; t <= c.max + 1e-9; t += c.step) {
if (t) mk('line', { class: 'cxt-grid', x1: left, x2: right, y1: c.y(t), y2: c.y(t) }, svg);
mk('text', { class: 'cxt-tick', x: left - 6, y: c.y(t), dy: '.35em', 'text-anchor': 'end' }, svg).textContent = +t.toFixed(2);
}
mk('line', { class: 'cxt-axis', x1: left, x2: right, y1: base + .5, y2: base + .5 }, svg);
if (c.labels.length) {
var slot = (right - left) / (c.n - 1);
var longest = Math.max.apply(null, c.labels.map(function (l) { return l.length; }));
var every = Math.max(1, Math.ceil((longest * 6.4 + 8) / slot));
c.labels.slice(0, c.n).forEach(function (lab, i) {
if (i % every) return;
mk('text', { class: 'cxt-tick', x: c.x(i), y: H - 4, 'text-anchor': 'middle' }, svg).textContent = lab;
});
}
c.series.forEach(function (s) {
s.path = mk('path', {
class: 'cxt-line', stroke: s.color,
d: 'M' + s.vs.slice(0, c.n).map(function (v, i) { return c.x(i).toFixed(1) + ',' + c.y(v).toFixed(1); }).join('L')
}, svg);
});
c.cross = mk('g', { class: 'cxt-cross' }, svg);
c.vline = mk('line', { class: 'cxt-v', y1: top - 4, y2: base }, c.cross);
c.hline = mk('line', { class: 'cxt-h', x1: left }, c.cross);
c.series.forEach(function (s) { s.dot = mk('circle', { class: 'cxt-dot', r: 5, fill: s.color }, c.cross); });
c.tip = document.createElement('div');
c.tip.className = 'cxt-tip';
c.tip.setAttribute('aria-hidden', 'true');
c.el.innerHTML = '';
c.el.appendChild(svg);
c.el.appendChild(c.tip);
c.svg = svg;
c.tipIdx = -1;
if (c.cx === null) c.cx = c.x(0);
c.cx = Math.max(left, Math.min(right, c.cx));
render(c);
}
// y of series s at fractional index f, so dots ride the line between points.
function yAt(c, s, f) {
var i = Math.max(0, Math.min(c.n - 2, Math.floor(f))), k = f - i;
return c.y(s.vs[i] + (s.vs[i + 1] - s.vs[i]) * k);
}
function render(c) {
if (!c.svg) return;
var f = (c.cx - c.left) / (c.right - c.left) * (c.n - 1);
c.cross.style.opacity = c.show;
c.tip.style.opacity = c.show;
c.vline.setAttribute('x1', c.cx); c.vline.setAttribute('x2', c.cx);
var y0 = yAt(c, c.series[0], f);
c.hline.setAttribute('x2', c.cx); c.hline.setAttribute('y1', y0); c.hline.setAttribute('y2', y0);
c.series.forEach(function (s) { s.dot.setAttribute('cx', c.cx); s.dot.setAttribute('cy', yAt(c, s, f)); });
if (c.tipIdx !== c.idx) {
c.tipIdx = c.idx;
var html = c.labels[c.idx] ? '<b>' + c.labels[c.idx] + '</b>' : '';
c.series.forEach(function (s) {
html += '<span><i style="background:' + s.color + '"></i>' + s.name + '<strong>' + c.fmt(s.vs[c.idx]) + '</strong></span>';
});
c.tip.innerHTML = html;
}
var tw = c.tip.offsetWidth, th = c.tip.offsetHeight;
var tx = c.cx + 14;
if (tx + tw > c.W) tx = c.cx - 14 - tw;
tx = Math.max(0, tx);
var ty = Math.max(0, Math.min(c.base - th, y0 - th - 12));
c.tip.style.transform = 'translate(' + tx.toFixed(1) + 'px,' + ty.toFixed(1) + 'px)';
}
function frame(c, now) {
c.raf = 0;
if (!c.visible || !c.svg) return;
var resume = !c.user && c.autoplay && (!c.lastUser || now - c.lastUser > 1500);
var target;
if (resume) {
if (!c.t0) c.t0 = now;
var p = 0.5 - 0.5 * Math.cos((now - c.t0) / 2600 * Math.PI);
target = c.left + p * (c.right - c.left);
c.show += (1 - c.show) * 0.1;
} else if (c.user) {
target = c.px;
} else {
target = c.px != null ? c.px : c.cx;
c.show *= 0.85;
}
// Snap to the nearest point, then glide the crosshair there.
var f = (target - c.left) / (c.right - c.left) * (c.n - 1);
c.idx = Math.max(0, Math.min(c.n - 1, Math.round(f)));
var goal = c.x(c.idx);
c.cx += (goal - c.cx) * (reduced() ? 1 : 0.2);
render(c);
var settled = Math.abs(goal - c.cx) < 0.2 && (c.show < 0.01 || c.show > 0.99);
if (!settled || resume || (c.autoplay && !c.user)) c.raf = requestAnimationFrame(function (t) { frame(c, t); });
}
function wake(c) {
if (!c.raf && c.visible) c.raf = requestAnimationFrame(function (t) { frame(c, t); });
}
function init() {
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (e) {
var c = e.target._cxt;
c.visible = e.isIntersecting;
if (c.visible) {
if (!c.drawn) { c.drawn = true; drawIn(c); }
wake(c);
}
});
}, { threshold: 0.2 });
var ro = new ResizeObserver(function (entries) {
entries.forEach(function (e) {
var c = e.target._cxt;
if (c && (e.target.clientWidth !== c.W || e.target.clientHeight !== c.H)) layout(c);
});
});
document.querySelectorAll('[data-chart-crosshair-tooltip]').forEach(function (el) {
if (el._cxt) return;
var c = setup(el);
if (!c) return;
ro.observe(el);
if (reduced() && c.autoplay) { c.show = 1; c.idx = Math.floor(c.n * 0.6); c.cx = c.x(c.idx); c.autoplay = false; render(c); }
io.observe(el);
});
}
// The lines draw in once, the first time the chart is seen.
function drawIn(c) {
if (reduced()) return;
c.series.forEach(function (s, i) {
var len = s.path.getTotalLength();
s.path.style.strokeDasharray = len;
s.path.style.strokeDashoffset = len;
anime.animate(s.path, {
strokeDashoffset: 0, duration: 1200, delay: i * 150, ease: 'inOut(2)',
onComplete: function () { s.path.style.strokeDasharray = ''; s.path.style.strokeDashoffset = ''; }
});
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-chart-crosshair-tooltip data-labels="Mon,Tue,Wed,Thu,Fri"
style="height: 240px">
<span data-values="42,58,51,77,69">Visits</span>
<span data-values="8,12,10,15,14">Sign-ups</span>
</div>