12 CSS Progress Bar Designs

Circular Counter

A radial chart with a synchronised counting number in the centre. The ring fills via SVG `stroke-dasharray` (with `pathLength="100"` so the math is honest) while the number animates from 0 — bound by light JS to keep the number truly in sync.

Light JS MIT licensed

Circular Counter the 9th of 12 designs in the 12 CSS Progress Bar Designs collection. The design pairs CSS styling with a small amount of JavaScript for interactivity. Copy the HTML, CSS and JavaScript panels below into your project — the JS is self-contained, has zero dependencies, and is safe to drop into any framework (React, Vue, Svelte, plain HTML). The design honours prefers-reduced-motion and uses real semantic markup, so it ships accessibility-ready out of the box.

Live preview

Open in playground

The code

<div
  class="pb-count"
  role="progressbar"
  aria-valuenow="0"
  aria-valuemin="0"
  aria-valuemax="100"
  aria-label="Score"
  data-pb-count="86"
>
  <svg class="pb-count-ring" viewBox="0 0 100 100" aria-hidden="true">
    <defs>
      <linearGradient id="pb-count-grad" x1="0" y1="0" x2="1" y2="1">
        <stop offset="0%" stop-color="#34d399" />
        <stop offset="100%" stop-color="#10b981" />
      </linearGradient>
    </defs>
    <circle class="pb-count-bg" cx="50" cy="50" r="42"></circle>
    <circle class="pb-count-prog" cx="50" cy="50" r="42" pathLength="100"></circle>
  </svg>
  <div class="pb-count-meta">
    <strong data-pb-count-num>0</strong>
    <span>score</span>
  </div>
</div>
.pb-count {
  position: relative;
  width: 130px;
  height: 130px;
  font-family: system-ui, sans-serif;
}

.pb-count-ring {
  width: 100%;
  height: 100%;
  transform: rotate(-90deg);
}

.pb-count-bg,
.pb-count-prog {
  fill: none;
  stroke-width: 7;
  stroke-linecap: round;
}

.pb-count-bg {
  stroke: rgba(255, 255, 255, 0.06);
}

.pb-count-prog {
  stroke: url(#pb-count-grad);
  stroke-dasharray: 100;
  stroke-dashoffset: 100;
  transition: stroke-dashoffset 1.4s cubic-bezier(0.5, 0, 0.3, 1.2);
}

.pb-count.is-ready .pb-count-prog {
  stroke-dashoffset: calc(100 - (var(--pb-count-pct, 0) * 100));
}

.pb-count-meta {
  position: absolute;
  inset: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 4px;
  line-height: 1;
}

.pb-count-meta strong {
  display: block;
  font-size: 28px;
  font-weight: 700;
  color: #f0eeff;
  font-variant-numeric: tabular-nums;
  line-height: 1;
  letter-spacing: -0.02em;
}

.pb-count-meta span {
  display: block;
  font-size: 9.5px;
  font-weight: 600;
  color: #34d399;
  letter-spacing: 0.18em;
  text-transform: uppercase;
  line-height: 1;
}
// Reads the target value from data-pb-count and animates the
// SVG ring + centre number from 0 to that value over 1.4s.
// Drop this on every page where you render a .pb-count element.
document.querySelectorAll("[data-pb-count]").forEach(function (el) {
  var target = Number(el.dataset.pbCount) || 0;
  var num = el.querySelector("[data-pb-count-num]");

  // Drive the ring's stroke-dashoffset via a CSS custom property
  el.style.setProperty("--pb-count-pct", String(target / 100));
  requestAnimationFrame(function () {
    el.classList.add("is-ready");
  });

  // Tick the centre number in sync with the 1.4s ring transition
  var start = null;
  var duration = 1400;
  function tick(t) {
    if (start === null) start = t;
    var p = Math.min(1, (t - start) / duration);
    var eased = 1 - Math.pow(1 - p, 3); // ease-out cubic
    var v = Math.round(target * eased);
    if (num) num.textContent = v;
    el.setAttribute("aria-valuenow", String(v));
    if (p < 1) requestAnimationFrame(tick);
  }
  requestAnimationFrame(tick);
});

Search CodeFronts

Loading…