Gauge Needle
A dial whose needle swings up to its value, overshoots a touch and settles, lighting the scale behind it while the reading counts up. Change the value and it swings again from where it is.
How to use it
- Paste the snippet just before
</head>. - Add
data-gauge-needle="72"(the value) to an empty element and give it a width.
Options
Add any of these attributes to the same element.
data-min / data-max | The scale. Default 0–100. |
data-label | Small text under the reading, and the gauge’s name for screen readers. |
data-suffix | After the reading, e.g. %. |
data-colors | The scale’s colours, low to high, comma-separated. |
data-ticks | Number of segments in the scale. Default 32. |
data-duration | Time to swing and settle. Default 1800 ms. |
data-loop | Wander to new readings every few seconds. |
It’s a role="meter", so screen readers announce the reading. To update it, set data-gauge-needle to the new value.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Gauge Needle: a dial whose needle swings up to its value, overshoots a
// little and settles, lighting the scale behind it as it goes while the
// reading counts up underneath. Change the value later and it swings
// again from where it is.
// Usage: <div data-gauge-needle="72" data-label="Speed" style="width:240px"></div>
// Optional: data-min / data-max (the scale, default 0–100), data-suffix
// (after the reading, e.g. " mph" or "%"), data-label (small text under
// the reading, and the gauge's name for screen readers), data-colors (the
// scale's colours from low to high, comma-separated, default
// "#0ea5e9,#a855f7,#ec4899"), data-ticks (number of scale segments,
// default 32), data-duration (ms, default 1800), data-loop (wander to new
// readings every few seconds, for demos).
// It's a role="meter", so screen readers announce the reading; to update
// it, set data-gauge-needle to the new value.
var DEFAULTS = { min: 0, max: 100, colors: '#0ea5e9,#a855f7,#ec4899', ticks: 32, duration: 1800, every: 3200 };
var START = -110, END = 110;
var css =
'.gnd{position:relative;display:inline-block;width:240px;color:inherit}' +
'.gnd svg{display:block;width:100%;height:auto;overflow:visible}' +
'.gnd-num{font:800 30px system-ui,-apple-system,"Segoe UI",sans-serif;letter-spacing:-.03em;fill:currentColor;font-variant-numeric:tabular-nums}' +
'.gnd-label{font:600 10px system-ui,-apple-system,"Segoe UI",sans-serif;fill:currentColor;opacity:.6;letter-spacing:.06em;text-transform:uppercase}' +
'.gnd-scale{font:600 8px system-ui,-apple-system,"Segoe UI",sans-serif;fill:currentColor;opacity:.5}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
var NS = 'http://www.w3.org/2000/svg';
function rad(a) { return (a * Math.PI) / 180; }
function pt(r, a) { return [100 + r * Math.sin(rad(a)), 100 - r * Math.cos(rad(a))]; }
function hex(c) { var n = parseInt(c.replace('#', ''), 16); return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; }
function mix(stops, t) {
var s = Math.min(stops.length - 2, Math.floor(t * (stops.length - 1)));
var f = t * (stops.length - 1) - s, a = stops[s], b = stops[s + 1];
return 'rgb(' + [0, 1, 2].map(function (i) { return Math.round(a[i] + (b[i] - a[i]) * f); }).join(',') + ')';
}
function nice(n) { return Math.abs(n) >= 100 || n === Math.round(n) ? Math.round(n) : Math.round(n * 10) / 10; }
function build(el) {
var d = el.dataset;
var min = d.min !== undefined ? +d.min : DEFAULTS.min;
var max = d.max !== undefined ? +d.max : DEFAULTS.max;
var n = Math.max(6, +d.ticks || DEFAULTS.ticks);
var stops = (d.colors || DEFAULTS.colors).split(',').map(function (c) { return hex(c.trim()); });
if (stops.length < 2) stops.push(stops[0]);
el.classList.add('gnd');
el.setAttribute('role', 'meter');
el.setAttribute('aria-valuemin', min);
el.setAttribute('aria-valuemax', max);
if (d.label && !el.getAttribute('aria-label')) el.setAttribute('aria-label', d.label);
var svg = document.createElementNS(NS, 'svg');
svg.setAttribute('viewBox', '0 0 200 158');
svg.setAttribute('aria-hidden', 'true');
var html = '';
// The scale: short arcs with a gap between, each with its own colour.
var per = (END - START) / n, segs = [];
for (var i = 0; i < n; i++) {
var a0 = START + i * per + 0.9, a1 = START + (i + 1) * per - 0.9;
var p0 = pt(84, a0), p1 = pt(84, a1);
segs.push(mix(stops, n > 1 ? i / (n - 1) : 0));
html += '<path class="gnd-seg" d="M' + p0 + 'A84 84 0 0 1 ' + p1 + '" fill="none" stroke-width="13" stroke="#e8e3f0"/>';
}
// Five numbers round the inside.
for (var k = 0; k <= 4; k++) {
var at = pt(64, START + (k / 4) * (END - START));
html += '<text class="gnd-scale" x="' + at[0] + '" y="' + at[1] + '" text-anchor="middle" dominant-baseline="central">' + nice(min + (k / 4) * (max - min)) + '</text>';
}
html +=
'<g class="gnd-needle"><path d="M100 26 L104 100 L96 100 Z" fill="#15131f"/><circle cx="100" cy="100" r="9" fill="#15131f"/><circle cx="100" cy="100" r="3.2" fill="#fff"/></g>' +
'<text class="gnd-num" x="100" y="137" text-anchor="middle"></text>' +
(d.label ? '<text class="gnd-label" x="100" y="154" text-anchor="middle"></text>' : '');
svg.innerHTML = html;
el.textContent = '';
el.appendChild(svg);
if (d.label) svg.querySelector('.gnd-label').textContent = d.label;
return {
el: el, min: min, max: max, colors: segs, segs: Array.from(svg.querySelectorAll('.gnd-seg')),
needle: svg.querySelector('.gnd-needle'), num: svg.querySelector('.gnd-num'),
state: { v: min, shown: min }, lit: -1, value: min, started: false
};
}
function read(c) {
var v = parseFloat(c.el.getAttribute('data-gauge-needle'));
return Math.max(c.min, Math.min(c.max, isNaN(v) ? c.min : v));
}
function render(c) {
var span = c.max - c.min || 1;
var t = (c.state.v - c.min) / span;
// The needle may swing a little past either end, but no further.
var a = START + Math.max(-0.03, Math.min(1.03, t)) * (END - START);
c.needle.setAttribute('transform', 'rotate(' + a + ' 100 100)');
var lit = Math.round(Math.max(0, Math.min(1, t)) * c.segs.length);
if (lit !== c.lit) {
c.segs.forEach(function (s, i) { s.setAttribute('stroke', i < lit ? c.colors[i] : '#e8e3f0'); });
c.lit = lit;
}
// Whole numbers while counting, unless the value itself has a decimal.
c.num.textContent = c.state.shown.toFixed(c.dp || 0) + (c.el.dataset.suffix || '');
}
function swing(c) {
var to = read(c);
c.value = to;
c.dp = to === Math.round(to) ? 0 : 1;
var suffix = c.el.dataset.suffix || '';
c.el.setAttribute('aria-valuenow', to);
c.el.setAttribute('aria-valuetext', nice(to) + suffix);
var rm = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
anime.utils.remove(c.state);
if (rm) { c.state.v = c.state.shown = to; render(c); return; }
var duration = +c.el.dataset.duration || DEFAULTS.duration;
// The needle overshoots and settles; the reading just counts.
anime.animate(c.state, {
v: { to: to, ease: 'outElastic(1, .55)' }, shown: { to: to, duration: duration * 0.55, ease: 'out(3)' },
duration: duration, onUpdate: function () { render(c); }
});
}
function wander(c) {
var span = c.max - c.min;
setInterval(function () {
if (c.el.closest('[hidden]')) return;
var next = c.value + (Math.random() - 0.45) * span * 0.6;
next = Math.max(c.min + span * 0.08, Math.min(c.max - span * 0.04, next));
c.el.setAttribute('data-gauge-needle', Math.round(next));
}, DEFAULTS.every);
}
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);
var c = e.target._gnd;
c.started = true;
swing(c);
var d = c.el.dataset;
if (d.loop !== undefined && d.loop !== 'false' && !reduced) wander(c);
});
}, { threshold: 0.4 });
document.querySelectorAll('[data-gauge-needle]').forEach(function (el) {
if (el._gnd) return;
var c = (el._gnd = build(el));
render(c);
// New value written to the attribute: swing to it.
new MutationObserver(function () { if (c.started) swing(c); }).observe(el, { attributes: true, attributeFilter: ['data-gauge-needle'] });
if (reduced) { c.started = true; swing(c); return; }
el.setAttribute('aria-valuenow', read(c));
el.setAttribute('aria-valuetext', nice(read(c)) + (el.dataset.suffix || ''));
io.observe(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-gauge-needle="72" data-label="Speed"
data-suffix=" mph" data-max="120"
style="width: 240px"></div>