← Numbers

Battery Level

A battery fills to its percentage while the reading counts up beside it. A charging bolt pops in and pulses, a soft shine runs through the charge, and the fill turns from red to amber to green as it climbs.

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-battery-level="76" (the percentage) to an empty element. Size it with font-size; the battery takes the text colour.

Options

Add any of these attributes to the same element.

data-fromWhere the fill starts. Default 0.
data-plainNo bolt or shine: just the level, not charging.
data-loopDrain back and charge again every few seconds.
data-holdHow long it rests full before looping. Default 2200 ms.
data-durationHow long a full charge takes. Default 1800 ms.
data-colorsLow (to 20%), middle (to 50%) and healthy colours. Default #ef4444,#f59e0b,#22c55e.
data-hide-valueNo percentage beside the battery.
1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Battery Level: a battery fills to its percentage while the reading counts
    // up beside it. A charging bolt pulses in the middle and a soft shine runs
    // through the charge. The fill turns red when low, amber in the middle and
    // green when healthy.
    // Usage: <div data-battery-level="76"></div>   (percent)
    // Optional: data-from (where the fill starts, default 0), data-plain (no
    // bolt or shine: just the level), data-loop (drain back and charge again
    // every few seconds), data-hold (ms it rests full before looping, default
    // 2200), data-duration (ms to fill, default 1800), data-colors (low, middle
    // and healthy colours, default "#ef4444,#f59e0b,#22c55e"), data-hide-value
    // (no reading beside it).
    // Size it with font-size: the battery is 5em wide.
    var DEFAULTS = { from: 0, hold: 2200, duration: 1800, colors: '#ef4444,#f59e0b,#22c55e' };

    var css =
      '.btl{display:inline-flex;align-items:center;gap:.6em;font-variant-numeric:tabular-nums}' +
      '.btl-cell{position:relative;flex:none;width:5em;height:2.5em;margin-right:.32em;border:.2em solid currentColor;border-radius:.62em;padding:.2em;box-sizing:border-box}' +
      '.btl-cell::after{content:"";position:absolute;right:-.5em;top:50%;width:.3em;height:.9em;margin-top:-.45em;border-radius:0 .2em .2em 0;background:currentColor}' +
      '.btl-fill{display:block;position:relative;height:100%;width:0;border-radius:.34em;overflow:hidden;transition:background-color .4s}' +
      // A shine that sweeps through the charge while charging.
      '.btl-fill::after{content:"";position:absolute;inset:0;width:60%;transform:translateX(var(--btl-x,-100%));' +
      'background:linear-gradient(90deg,transparent,rgba(255,255,255,.55),transparent);opacity:var(--btl-shine,0)}' +
      '.btl-bolt{position:absolute;left:50%;top:50%;width:1.3em;height:1.6em;margin:-.8em 0 0 -.65em;fill:#fff;stroke:currentColor;stroke-width:1.6;' +
      'stroke-linejoin:round;paint-order:stroke;filter:drop-shadow(0 .05em .1em rgba(0,0,0,.25))}' +
      '.btl-num{font-size:1.5em;font-weight:800;letter-spacing:-.03em;min-width:2.8em;text-align:left}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

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

    function setup(el) {
      var d = el.dataset;
      var b = {
        el: el, level: Math.max(0, Math.min(100, parseFloat(el.getAttribute('data-battery-level')) || 0)),
        from: d.from !== undefined ? +d.from : DEFAULTS.from, hold: +d.hold || DEFAULTS.hold,
        duration: +d.duration || DEFAULTS.duration, colors: (d.colors || DEFAULTS.colors).split(','),
        plain: d.plain !== undefined, loop: d.loop !== undefined && d.loop !== 'false',
        still: reduced(), visible: false, started: false, timer: 0, anims: [], value: 0
      };
      el.classList.add('btl');
      el.innerHTML = '<span class="btl-cell" aria-hidden="true"><span class="btl-fill"></span>' +
        (b.plain ? '' : '<svg class="btl-bolt" viewBox="0 0 16 20"><path d="M9.5 1L2 11.5h5.2L6 19l8-10.6H8.7L9.5 1z"/></svg>') +
        '</span>' + (d.hideValue !== undefined ? '' : '<span class="btl-num" aria-hidden="true"></span>');
      b.fill = el.querySelector('.btl-fill');
      b.bolt = el.querySelector('.btl-bolt');
      b.num = el.querySelector('.btl-num');
      el.setAttribute('role', 'img');
      el.setAttribute('aria-label', 'Battery ' + Math.round(b.level) + '%' + (b.plain ? '' : ', charging'));
      if (b.bolt) b.bolt.style.opacity = 0;
      draw(b, b.still ? b.level : b.from);
      if (b.still && b.bolt) b.bolt.style.opacity = 1;
      return b;
    }

    function colour(b, v) { return v <= 20 ? b.colors[0] : v <= 50 ? b.colors[1] || b.colors[0] : b.colors[2] || b.colors[1]; }

    function draw(b, v) {
      b.value = v;
      b.fill.style.width = v + '%';
      b.fill.style.backgroundColor = colour(b, v);
      if (b.num) b.num.textContent = Math.round(v) + '%';
    }

    function stop(b) {
      clearTimeout(b.timer);
      b.anims.forEach(function (a) { a.pause(); });
      b.anims = [];
    }

    function charge(b) {
      stop(b);
      if (!b.visible) return;
      var st = { v: b.value };
      b.anims.push(anime.animate(st, {
        v: b.level, duration: b.duration * Math.max(0.35, Math.abs(b.level - b.value) / 100), ease: 'inOut(2)',
        onUpdate: function () { draw(b, st.v); },
        onComplete: function () { if (b.loop) b.timer = setTimeout(function () { drain(b); }, b.hold); }
      }));
      if (!b.plain) {
        // Bolt pops in and keeps pulsing; a shine runs through the charge.
        b.anims.push(anime.animate(b.bolt, { opacity: [0, 1], scale: [0.4, 1], duration: 500, ease: 'outBack(2)' }));
        b.anims.push(anime.animate(b.bolt, { scale: [1, 1.14, 1], duration: 1200, delay: 500, loop: true, ease: 'inOut(2)' }));
        var shine = { x: -100 };
        b.fill.style.setProperty('--btl-shine', 1);
        b.anims.push(anime.animate(shine, {
          x: 260, duration: 1500, loop: true, ease: 'inOut(2)', loopDelay: 500,
          onUpdate: function () { b.fill.style.setProperty('--btl-x', shine.x + '%'); }
        }));
      }
    }

    function drain(b) {
      stop(b);
      if (!b.visible) return;
      var st = { v: b.value };
      if (b.bolt) b.anims.push(anime.animate(b.bolt, { opacity: 0, scale: 0.4, duration: 300, ease: 'in(2)' }));
      b.fill.style.setProperty('--btl-shine', 0);
      b.anims.push(anime.animate(st, {
        v: b.from, duration: 900, ease: 'inOut(2)', onUpdate: function () { draw(b, st.v); },
        onComplete: function () { b.timer = setTimeout(function () { charge(b); }, 500); }
      }));
    }

    function init() {
      var io = new IntersectionObserver(function (entries) {
        entries.forEach(function (e) {
          var b = e.target._btl;
          b.visible = e.isIntersecting;
          if (b.visible) {
            charge(b);   // fills (or carries on filling) and restarts the bolt pulse and shine
            b.started = true;
          } else stop(b);
        });
      }, { threshold: 0.3 });
      document.querySelectorAll('[data-battery-level]').forEach(function (el) {
        if (el._btl) return;
        var b = (el._btl = setup(el));
        if (!b.still) io.observe(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-battery-level="76" style="font-size: 24px"></div>

<!-- a low battery, not charging: -->
<div data-battery-level="12" data-plain></div>