← Numbers

Signal Bars

Signal-strength bars light up one by one, from the shortest up, until they show the strength, with the unlit bars left faint. On the way in they sweep up and down as if finding a network. Use bars or Wi-Fi arcs, and add words and colours for each strength.

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-signal-bars="3" (how many bars are lit) to an empty element. Size it with font-size; the bars take the text colour.

Options

Add any of these attributes to the same element.

data-barsHow many bars in all. Default 4.
data-shapewifi for Wi-Fi arcs instead of bars.
data-values="A|B|C"Several strengths to move between in turn.
data-holdHow long each strength stays. Default 2000 ms.
data-loopWith a single strength: search and light up again every few seconds.
data-labelsWords for each strength from 0 up, shown beside the bars, e.g. No signal|Weak|Fair|Good|Excellent.
data-colorsColours by strength, low to high, e.g. #ef4444,#f59e0b,#22c55e. Default the text colour.
1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Signal Bars: signal-strength bars light up one by one, from the shortest
    // up, until they show the strength. With several strengths it first sweeps
    // up and down as if searching, then settles; the unlit bars stay faint.
    // Usage: <span data-signal-bars="3"></span>   (bars lit, out of 4)
    // Optional: data-bars (how many bars, default 4), data-shape ("wifi" for
    // Wi-Fi arcs instead of bars), data-values (several strengths to move
    // between, "1|4|2"), data-hold (ms each stays, default 2000), data-loop
    // (with a single strength: search and light up again every few seconds),
    // data-labels (words for each strength, from 0 up, shown beside it, e.g.
    // "No signal|Weak|Fair|Good|Excellent"), data-colors (colours by strength,
    // low to high, e.g. "#ef4444,#f59e0b,#22c55e"; default the text colour).
    // Size it with font-size: the bars are 1.6em wide and 1.2em tall.
    var DEFAULTS = { bars: 4, hold: 2000, stagger: 110 };
    var NS = 'http://www.w3.org/2000/svg';

    var css =
      '.sgn{display:inline-flex;align-items:center;gap:.4em;vertical-align:middle;line-height:1}' +
      '.sgn svg{flex:none;width:1.6em;height:1.2em;display:block;overflow:visible}' +
      '.sgn-bar{fill:currentColor;transform-box:fill-box;transform-origin:50% 100%}' +
      '.sgn-arc{fill:none;stroke:currentColor;stroke-linecap:round;transform-box:fill-box;transform-origin:50% 100%}' +
      '.sgn-dot{fill:currentColor}' +
      '.sgn-label{font-size:.42em;font-weight:700;letter-spacing:.06em;text-transform:uppercase;white-space:nowrap}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    var DIM = 0.16;

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }

    function svg(tag, attrs, parent) {
      var n = document.createElementNS(NS, tag);
      for (var k in attrs) n.setAttribute(k, attrs[k]);
      parent.appendChild(n);
      return n;
    }

    function setup(el) {
      var d = el.dataset;
      var n = Math.max(2, Math.min(8, +d.bars || DEFAULTS.bars));
      var first = el.getAttribute('data-signal-bars');
      var values = (d.values ? d.values.split('|') : [first || n]).map(function (v) { return Math.max(0, Math.min(n, Math.round(+v) || 0)); });
      var s = {
        el: el, n: n, values: values, index: 0, hold: +d.hold || DEFAULTS.hold,
        labels: d.labels ? d.labels.split('|') : null, colors: d.colors ? d.colors.split(',') : null,
        loop: d.loop !== undefined && d.loop !== 'false', still: reduced(),
        lit: 0, visible: false, started: false, timers: [], parts: []
      };
      el.classList.add('sgn');
      el.textContent = '';
      var g = svg('svg', { viewBox: '0 0 32 24', 'aria-hidden': 'true' }, el);
      if (d.shape === 'wifi') {
        // A dot and n − 1 arcs, all centred on the dot.
        s.parts.push(svg('circle', { cx: 16, cy: 21, r: 2.4, 'class': 'sgn-dot' }, g));
        for (var i = 1; i < n; i++) {
          var r = 2.4 + i * (17.5 / (n - 1)), a = 0.78;          // ±45° either side of straight up
          var x1 = 16 - r * Math.sin(a), y1 = 21 - r * Math.cos(a), x2 = 16 + r * Math.sin(a);
          s.parts.push(svg('path', {
            d: 'M' + x1.toFixed(2) + ' ' + y1.toFixed(2) + 'A' + r + ' ' + r + ' 0 0 1 ' + x2.toFixed(2) + ' ' + y1.toFixed(2),
            'class': 'sgn-arc', 'stroke-width': Math.max(2.2, 10 / n)
          }, g));
        }
      } else {
        var gap = 2.4, w = (32 - gap * (n - 1)) / n;
        for (var j = 0; j < n; j++) {
          var h = 24 * (j + 1) / n;
          s.parts.push(svg('rect', { x: (j * (w + gap)).toFixed(2), y: (24 - h).toFixed(2), width: w.toFixed(2), height: h.toFixed(2), rx: Math.min(1.6, w / 4), 'class': 'sgn-bar' }, g));
        }
      }
      if (s.labels) {
        s.label = document.createElement('span');
        s.label.className = 'sgn-label';
        s.label.setAttribute('aria-hidden', 'true');
        el.appendChild(s.label);
      }
      el.setAttribute('role', 'img');
      s.parts.forEach(function (p) { p.style.opacity = DIM; });
      show(s, s.still ? values[0] : 0, true);
      describe(s, values[0]);
      return s;
    }

    function describe(s, v) {
      var word = s.labels && s.labels[v] ? s.labels[v] + ', ' : '';
      s.el.setAttribute('aria-label', 'Signal: ' + word + v + ' of ' + s.n + ' bars');
    }

    function colourFor(s, v) {
      if (!s.colors || !v) return '';
      var i = Math.min(s.colors.length - 1, Math.floor((v - 1) * s.colors.length / s.n));
      return s.colors[i].trim();
    }

    function clear(s) { s.timers.forEach(clearTimeout); s.timers = []; }
    function later(s, fn, ms) { s.timers.push(setTimeout(fn, ms)); }

    // Light (or dim) the bars one at a time until `v` are lit.
    function show(s, v, instant) {
      var from = s.lit;
      s.lit = v;
      if (s.colors) s.el.style.color = colourFor(s, v);
      if (s.label && !s.searching) s.label.textContent = s.labels[v] || '';
      s.parts.forEach(function (p, i) {
        var on = i < v, was = i < from;
        if (instant || s.still) { p.style.opacity = on ? 1 : DIM; return; }
        if (on === was) return;
        // Rising bars light from the bottom up; falling ones go out from the top down.
        var order = on ? i - from : from - 1 - i;
        anime.animate(p, on
          ? { opacity: [DIM, 1], scale: [0.6, 1.08, 1], duration: 380, delay: order * DEFAULTS.stagger, ease: 'out(3)' }
          : { opacity: DIM, scale: [1, 0.9, 1], duration: 260, delay: order * DEFAULTS.stagger * 0.6, ease: 'out(2)' });
      });
    }

    // A quick sweep up to full and back down, like a phone finding a network.
    function search(s, then) {
      var t = s.lit ? 300 : 120;
      s.searching = true;
      show(s, 0);
      for (var i = 1; i <= s.n; i++) (function (k) { later(s, function () { show(s, k); }, t + k * 90); })(i);
      later(s, function () { show(s, 0); }, t + s.n * 90 + 350);
      later(s, then, t + s.n * 90 + 350 + s.n * 70 + 300);
    }

    function step(s) {
      clear(s);
      if (!s.visible) return;
      var v = s.values[s.index];
      s.index = (s.index + 1) % s.values.length;
      describe(s, v);
      if (s.values.length > 1 && s.searched) {
        show(s, v);
        later(s, function () { step(s); }, s.hold);
      } else {
        search(s, function () {
          s.searched = true;
          s.searching = false;
          show(s, v);
          if (s.values.length > 1) later(s, function () { step(s); }, s.hold);
          else if (s.loop) later(s, function () { step(s); }, s.hold + s.n * DEFAULTS.stagger);
        });
      }
    }

    function init() {
      var io = new IntersectionObserver(function (entries) {
        entries.forEach(function (e) {
          var s = e.target._sgn;
          s.visible = e.isIntersecting;
          if (s.visible) {
            if (!s.started || s.values.length > 1 || s.loop) step(s);
            s.started = true;
          } else clear(s);
        });
      }, { threshold: 0.3 });
      document.querySelectorAll('[data-signal-bars]').forEach(function (el) {
        if (el._sgn) return;
        var s = (el._sgn = setup(el));
        if (!s.still) io.observe(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<span data-signal-bars="3" style="font-size: 32px"></span>

<!-- Wi-Fi, with words: -->
<span data-signal-bars="2" data-shape="wifi" data-labels="Off|Weak|Fair|Strong"></span>