← Charts

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

  1. Paste the snippet just before </head>.
  2. 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-maxThe scale. Default 0–100.
data-labelSmall text under the reading, and the gauge’s name for screen readers.
data-suffixAfter the reading, e.g. %.
data-colorsThe scale’s colours, low to high, comma-separated.
data-ticksNumber of segments in the scale. Default 32.
data-durationTime to swing and settle. Default 1800 ms.
data-loopWander 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.

1 · Snippet
<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>
2 · Your HTML
<div data-gauge-needle="72" data-label="Speed"
     data-suffix=" mph" data-max="120"
     style="width: 240px"></div>