Business & Operations

Free KPI Counter Widget

Animated count-up stats row that springs to life when scrolled into view.

A lightweight stats row that animates numbers from zero to their final values when visitors scroll it into view. Great for agencies, SaaS sites, and small businesses that want to show off projects shipped, clients served, uptime, or years in business. Drop in one script tag with your stats as JSON and it renders anywhere on the page.

Live demo

Embed this widget

Paste either snippet into your page — a Custom HTML block on WordPress, Shopify, or Squarespace works too. Both snippets update live as you customize, so what you copy is exactly what you configured.

Hosted (recommended)html
<script src="https://hayneslab.dev/widgets/kpi-counter.js" data-hl-config='[{"value":120,"label":"Projects Shipped","suffix":"+"},{"value":48,"label":"Happy Clients"},{"value":99.9,"label":"Uptime","suffix":"%"},{"value":8,"label":"Years in Business"}]' async></script>
<!-- Free KPI Counter widget by haynes lab — https://hayneslab.dev/tools/widgets/kpi-counter -->
Inline (self-contained)html
<!-- Free KPI Counter widget by haynes lab — https://hayneslab.dev/tools/widgets/kpi-counter -->
<script data-hl-config='[{"value":120,"label":"Projects Shipped","suffix":"+"},{"value":48,"label":"Happy Clients"},{"value":99.9,"label":"Uptime","suffix":"%"},{"value":8,"label":"Years in Business"}]'>
/*!
 * haynes lab — KPI Counter widget (free)
 * Docs & embed code: https://hayneslab.dev/tools/widgets/kpi-counter
 */
(function () {
  'use strict';

  var PREFIX = 'hlw-kpi-counter';
  var STYLE_ID = 'hlw-kpi-counter-styles';

  function findScript() {
    return document.currentScript ||
      document.querySelector('script[src*="widgets/kpi-counter.js"]');
  }

  function injectStyles() {
    if (document.getElementById(STYLE_ID)) return;
    var css =
      '.' + PREFIX + ', .' + PREFIX + ' * { box-sizing: border-box; }' +
      '.' + PREFIX + ' { position: relative; max-width: 960px; margin: 0 auto; padding: 28px 24px 30px;' +
      ' font-family: system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;' +
      ' font-size: 16px; line-height: 1.5; color: #1f2937; background: #ffffff;' +
      ' border: 1px solid #e5e7eb; border-radius: 12px; }' +
      '.' + PREFIX + '__row { display: flex; flex-wrap: wrap; }' +
      '.' + PREFIX + '__stat { flex: 1 1 160px; min-width: 0; padding: 8px 16px; text-align: center; }' +
      '.' + PREFIX + '__stat + .' + PREFIX + '__stat { border-left: 1px solid #e5e7eb; }' +
      '.' + PREFIX + '__value { font-size: 36px; font-weight: 700; line-height: 1.15;' +
      ' color: var(--hlw-accent); font-variant-numeric: tabular-nums; white-space: nowrap; }' +
      '.' + PREFIX + '__label { margin-top: 4px; font-size: 13px; font-weight: 500;' +
      ' text-transform: uppercase; letter-spacing: 0.06em; color: #6b7280; }' +
      '.' + PREFIX + '__credit { position: absolute; right: 10px; bottom: 6px; font-size: 11px;' +
      ' display: inline-flex; align-items: center; gap: 4px; color: #9ca3af; text-decoration: none; }' +
      '.' + PREFIX + '__credit svg { display: block; }' +
      '.' + PREFIX + '__credit:hover { color: #6b7280; text-decoration: underline; }' +
      '@media (max-width: 640px) {' +
      ' .' + PREFIX + ' { padding-bottom: 34px; }' +
      ' .' + PREFIX + '__stat { flex: 1 1 40%; padding: 10px 8px; }' +
      ' .' + PREFIX + '__stat + .' + PREFIX + '__stat { border-left: 0; }' +
      ' .' + PREFIX + '__value { font-size: 28px; } }';
    var style = document.createElement('style');
    style.id = STYLE_ID;
    style.textContent = css;
    document.head.appendChild(style);
  }

  function parseStats(script) {
    var raw = script.getAttribute('data-hl-config');
    if (!raw) {
      console.warn('[haynes lab kpi-counter] Missing required data-hl-config attribute (JSON array of {value, label, prefix?, suffix?}). Nothing rendered.');
      return null;
    }
    var items;
    try {
      items = JSON.parse(raw);
    } catch (e) {
      console.warn('[haynes lab kpi-counter] data-hl-config is not valid JSON. Nothing rendered.', e);
      return null;
    }
    if (!Array.isArray(items)) {
      console.warn('[haynes lab kpi-counter] data-hl-config must be a JSON array. Nothing rendered.');
      return null;
    }
    var valid = items.filter(function (s) {
      return s && typeof s.value === 'number' && isFinite(s.value) &&
        typeof s.label === 'string' && s.label;
    });
    if (!valid.length) {
      console.warn('[haynes lab kpi-counter] data-hl-config has no valid stats (each needs a numeric "value" and a "label"). Nothing rendered.');
      return null;
    }
    return valid;
  }

  function el(tag, className, text) {
    var node = document.createElement(tag);
    if (className) node.className = className;
    if (text != null) node.textContent = text;
    return node;
  }

  function logoSvg() {
    var svgNS = 'http://www.w3.org/2000/svg';
    var svg = document.createElementNS(svgNS, 'svg');
    svg.setAttribute('viewBox', '0 0 32 32');
    svg.setAttribute('width', '13');
    svg.setAttribute('height', '13');
    svg.setAttribute('aria-hidden', 'true');
    svg.setAttribute('focusable', 'false');
    function circle(cx, cy, r, attrs) {
      var c = document.createElementNS(svgNS, 'circle');
      c.setAttribute('cx', cx);
      c.setAttribute('cy', cy);
      c.setAttribute('r', r);
      for (var key in attrs) c.setAttribute(key, attrs[key]);
      svg.appendChild(c);
    }
    circle(16, 16, 16, { fill: '#0f0a0a' });
    circle(16, 16, 12, { fill: 'none', stroke: '#fff5f5', 'stroke-width': 1, opacity: 0.3 });
    circle(16, 16, 9, { fill: 'none', stroke: '#fff5f5', 'stroke-width': 1, opacity: 0.5 });
    circle(16, 16, 6, { fill: 'none', stroke: '#fff5f5', 'stroke-width': 1.2, opacity: 0.9 });
    circle(16, 16, 3, { fill: '#06b6d4' });
    circle(24.5, 7.5, 2, { fill: '#f97316' });
    circle(7, 16, 1.8, { fill: '#f97316' });
    circle(20, 20, 1.5, { fill: '#f97316' });
    return svg;
  }

  function decimalsOf(n) {
    var s = String(n);
    var i = s.indexOf('.');
    return i === -1 ? 0 : Math.min(s.length - i - 1, 2);
  }

  function formatNumber(n, decimals) {
    return n.toLocaleString(undefined, {
      minimumFractionDigits: decimals,
      maximumFractionDigits: decimals
    });
  }

  function build(script) {
    var stats = parseStats(script);
    if (!stats) return;

    var accent = script.getAttribute('data-hl-accent') || '#f97316';
    var duration = parseInt(script.getAttribute('data-hl-duration'), 10);
    if (isNaN(duration) || duration < 0) duration = 1600;

    injectStyles();

    var root = el('div', PREFIX);
    root.style.setProperty('--hlw-accent', accent);

    var row = el('div', PREFIX + '__row');
    root.appendChild(row);

    var cells = stats.map(function (s) {
      var cell = el('div', PREFIX + '__stat');
      var value = el('div', PREFIX + '__value');
      var label = el('div', PREFIX + '__label', s.label);
      cell.appendChild(value);
      cell.appendChild(label);
      row.appendChild(cell);
      return { stat: s, valueNode: value };
    });

    var credit = el('a', PREFIX + '__credit');
    credit.appendChild(logoSvg());
    credit.appendChild(document.createTextNode('Powered by haynes lab'));
    credit.href = 'https://hayneslab.dev/tools/widgets/kpi-counter';
    credit.target = '_blank';
    credit.rel = 'noopener';
    root.appendChild(credit);

    function renderFinal() {
      cells.forEach(function (c) {
        var d = decimalsOf(c.stat.value);
        c.valueNode.textContent = (c.stat.prefix || '') +
          formatNumber(c.stat.value, d) + (c.stat.suffix || '');
      });
    }

    function animate() {
      if (duration === 0 ||
          (window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches)) {
        renderFinal();
        return;
      }
      var start = null;
      function frame(now) {
        if (start === null) start = now;
        var t = Math.min((now - start) / duration, 1);
        var eased = 1 - Math.pow(1 - t, 3);
        cells.forEach(function (c) {
          var d = decimalsOf(c.stat.value);
          c.valueNode.textContent = (c.stat.prefix || '') +
            formatNumber(c.stat.value * eased, d) + (c.stat.suffix || '');
        });
        if (t < 1) {
          requestAnimationFrame(frame);
        } else {
          renderFinal();
        }
      }
      requestAnimationFrame(frame);
    }

    function place() {
      if (script.parentNode && script.parentNode.nodeName !== 'HEAD') {
        script.insertAdjacentElement('afterend', root);
      } else {
        document.body.appendChild(root);
      }
    }

    place();

    if ('IntersectionObserver' in window) {
      var started = false;
      var observer = new IntersectionObserver(function (entries) {
        for (var i = 0; i < entries.length; i++) {
          if (entries[i].isIntersecting && !started) {
            started = true;
            observer.disconnect();
            animate();
          }
        }
      }, { threshold: 0.25 });
      observer.observe(root);
    } else {
      renderFinal();
    }
  }

  function init() {
    var script = findScript();
    if (!script) {
      console.warn('[haynes lab kpi-counter] Could not locate its own script tag. Nothing rendered.');
      return;
    }
    build(script);
  }

  if (document.body) {
    init();
  } else {
    document.addEventListener('DOMContentLoaded', init);
  }
})();

</script>

Configuration

Every option is an attribute on the script tag — the same attributes the customizer above exposes. All attributes are optional unless marked required — defaults apply when omitted.

AttributeDefaultDescription
data-hl-config(required)JSON array of stats, each with a numeric "value", a "label", and optional "prefix" and "suffix" strings.
data-hl-accent"#f97316"Color used for the stat numbers; accepts any CSS color.
data-hl-duration"1600"How long the count-up animation takes in milliseconds; set to "0" to show final values instantly.

Frequently asked questions

How do I install the KPI Counter?

Paste a single script tag where you want the stats row to appear: <script src="https://hayneslab.dev/widgets/kpi-counter.js" data-hl-config='[{"value":120,"label":"Projects Shipped"}]' async></script>. The widget renders inline right where the tag sits.

How do I customize the stats and colors?

Set data-hl-config to a JSON array of stats — each needs a numeric "value" and a "label", plus optional "prefix" (like "$") and "suffix" (like "%" or "+"). Use data-hl-accent for the number color and data-hl-duration to control the animation speed.

Does it work on WordPress, Shopify, or Squarespace?

Yes — it is a single vanilla JS file with no dependencies. Paste the script tag into any HTML block, embed widget, or theme file and it works on WordPress, Shopify, Squarespace, Wix, and plain HTML sites.

More free widgets

Need a custom widget?

We build custom widgets, integrations, and whole products. Tell us what your site needs and we'll scope it.

Get in Touch