25 CSS Number Counter Animations10 / 25

Pure CSSMIT licensed

SVG Stroke-Dash Progress Ring Counter

Three rings, one technique, zero JavaScript: a registered @property drives both the SVG stroke-dashoffset and the printed percentage from the same animation, so the arc and the number can never disagree. A conic-gradient variant and a segmented-track variant are included for comparison.

Published

Live Demo
Try it

The code

<section class="cnc-10" aria-label="SVG stroke dash progress ring counter demo">
  <div class="cnc-10__stage">
    <div class="cnc-10__grid">
      <figure class="cnc-10__item" style="--cnc-10-to:.78;--cnc-10-ton:78;--cnc-10-hue:250">
        <div class="cnc-10__ring">
          <svg viewBox="0 0 120 120" aria-hidden="true">
            <circle class="cnc-10__track" cx="60" cy="60" r="52"/>
            <circle class="cnc-10__arc" cx="60" cy="60" r="52" pathLength="100"/>
          </svg>
          <span class="cnc-10__num" aria-hidden="true"></span>
        </div>
        <figcaption class="cnc-10__cap">Sprint completion<span class="cnc-10__sr">78 percent</span></figcaption>
      </figure>
      <figure class="cnc-10__item" style="--cnc-10-to:.94;--cnc-10-ton:94;--cnc-10-hue:155">
        <div class="cnc-10__ring">
          <svg viewBox="0 0 120 120" aria-hidden="true">
            <circle class="cnc-10__track" cx="60" cy="60" r="52"/>
            <circle class="cnc-10__arc" cx="60" cy="60" r="52" pathLength="100"/>
          </svg>
          <span class="cnc-10__num" aria-hidden="true"></span>
        </div>
        <figcaption class="cnc-10__cap">Test coverage<span class="cnc-10__sr">94 percent</span></figcaption>
      </figure>
      <figure class="cnc-10__item cnc-10__item--conic" style="--cnc-10-to:.41;--cnc-10-ton:41;--cnc-10-hue:35">
        <div class="cnc-10__ring">
          <span class="cnc-10__conic" aria-hidden="true"></span>
          <span class="cnc-10__num" aria-hidden="true"></span>
        </div>
        <figcaption class="cnc-10__cap">Storage used<span class="cnc-10__sr">41 percent</span></figcaption>
      </figure>
    </div>
    <p class="cnc-10__chip" aria-hidden="true">pathLength=100 · @property inherits:true · one animation drives arc + counter</p>
  </div>
</section>
@property --cnc-10-p {
  syntax: '<number>';
  inherits: true;
  initial-value: 0;
}

@property --cnc-10-n {
  syntax: '<integer>';
  inherits: true;
  initial-value: 0;
}

.cnc-10,
.cnc-10 *,
.cnc-10 *::before,
.cnc-10 *::after {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

.cnc-10 {
  width: 100%;
  min-height: 100vh;
  min-height: 100svh;
  display: block;
  --cnc-10-bg: oklch(0.16 0.02 260);
  --cnc-10-ink: oklch(0.98 0.004 260);
  --cnc-10-mut: oklch(0.67 0.02 260);
  background: var(--cnc-10-bg);
  color: var(--cnc-10-ink);
  font-family: 'Segoe UI',system-ui,-apple-system,sans-serif;
}

.cnc-10__stage {
  min-height: 100vh;
  min-height: 100svh;
  display: grid;
  place-items: center;
  align-content: center;
  gap: 26px;
  padding: clamp(18px,4vw,48px);
}

.cnc-10__grid {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: clamp(18px,4vw,40px);
}

.cnc-10__item {
  --cnc-10-acc: oklch(0.75 0.17 var(--cnc-10-hue,250));
  display: grid;
  justify-items: center;
  gap: 14px;
  animation: cnc-10-fill 2.1s cubic-bezier(.16,1,.3,1) forwards;
}

.cnc-10__item:nth-child(2) {
  animation-delay: .15s;
}

.cnc-10__item:nth-child(3) {
  animation-delay: .3s;
}

@keyframes cnc-10-fill {
  from {
    --cnc-10-p: 0;
    --cnc-10-n: 0;
  }

  to {
    --cnc-10-p: var(--cnc-10-to);
    --cnc-10-n: var(--cnc-10-ton);
  }
}

.cnc-10__ring {
  position: relative;
  display: grid;
  place-items: center;
  width: clamp(124px,26vw,158px);
  aspect-ratio: 1;
}

.cnc-10__ring svg {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  transform: rotate(-90deg);
  overflow: visible;
}

.cnc-10__track {
  fill: none;
  stroke: oklch(1 0 0/.1);
  stroke-width: 9;
}

.cnc-10__arc {
  fill: none;
  stroke: var(--cnc-10-acc);
  stroke-width: 9;
  stroke-linecap: round;
  stroke-dasharray: 100;
  stroke-dashoffset: calc(100 - 100 * var(--cnc-10-p));
  filter: drop-shadow(0 0 10px color-mix(in oklch,var(--cnc-10-acc) 55%,transparent));
}

.cnc-10__conic {
  position: absolute;
  inset: 0;
  border-radius: 50%;
  background: conic-gradient(var(--cnc-10-acc) calc(var(--cnc-10-p) * 360deg),oklch(1 0 0/.1) 0);
  -webkit-mask: radial-gradient(farthest-side,transparent calc(50% - 9px),#000 calc(50% - 8px));
  mask: radial-gradient(farthest-side,transparent calc(50% - 9px),#000 calc(50% - 8px));
}

.cnc-10__num {
  counter-reset: cnc-10-n var(--cnc-10-n);
  font-size: clamp(30px,6vw,38px);
  font-weight: 740;
  letter-spacing: -.04em;
  font-variant-numeric: tabular-nums;
  font-feature-settings: 'tnum' 1;
}

.cnc-10__num::after {
  content: counter(cnc-10-n) "%";
}

.cnc-10__cap {
  font-size: 13px;
  color: var(--cnc-10-mut);
  text-align: center;
}

.cnc-10__sr {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

.cnc-10__chip {
  font-family: ui-monospace,Menlo,Consolas,monospace;
  font-size: 11.5px;
  color: var(--cnc-10-mut);
  padding: 7px 14px;
  border: 1px solid oklch(1 0 0/.13);
  border-radius: 99px;
  text-align: center;
  max-width: min(100%,520px);
}

@media (prefers-reduced-motion: reduce) {
  .cnc-10__item {
    animation-duration: 1ms;
  }
}
Paste this into ChatGPT, Claude, Cursor, or any coding assistant. The block below is pre-framed with everything the AI needs to integrate this demo into your project — markup, styles, scoping notes, and the source URL. Hit Copy and paste straight into your chat.
Here's a working CSS Number Counter Animation from CodeFronts. Use it as-is or adapt to your framework. All classes are scoped under a unique prefix so the code won't collide with your existing styles. MIT licensed.
Demo: SVG Stroke-Dash Progress Ring Counter
Source: https://codefronts.com/motion/css-number-counter-animations/svg-stroke-dash-progress-ring-counter/

Three rings, one technique, zero JavaScript: a registered @property drives both the SVG stroke-dashoffset and the printed percentage from the same animation, so the arc and the number can never disagree. A conic-gradient variant and a segmented-track variant are included for comparison.
## HTML
```html
<section class="cnc-10" aria-label="SVG stroke dash progress ring counter demo">
  <div class="cnc-10__stage">
    <div class="cnc-10__grid">
      <figure class="cnc-10__item" style="--cnc-10-to:.78;--cnc-10-ton:78;--cnc-10-hue:250">
        <div class="cnc-10__ring">
          <svg viewBox="0 0 120 120" aria-hidden="true">
            <circle class="cnc-10__track" cx="60" cy="60" r="52"/>
            <circle class="cnc-10__arc" cx="60" cy="60" r="52" pathLength="100"/>
          </svg>
          <span class="cnc-10__num" aria-hidden="true"></span>
        </div>
        <figcaption class="cnc-10__cap">Sprint completion<span class="cnc-10__sr">78 percent</span></figcaption>
      </figure>
      <figure class="cnc-10__item" style="--cnc-10-to:.94;--cnc-10-ton:94;--cnc-10-hue:155">
        <div class="cnc-10__ring">
          <svg viewBox="0 0 120 120" aria-hidden="true">
            <circle class="cnc-10__track" cx="60" cy="60" r="52"/>
            <circle class="cnc-10__arc" cx="60" cy="60" r="52" pathLength="100"/>
          </svg>
          <span class="cnc-10__num" aria-hidden="true"></span>
        </div>
        <figcaption class="cnc-10__cap">Test coverage<span class="cnc-10__sr">94 percent</span></figcaption>
      </figure>
      <figure class="cnc-10__item cnc-10__item--conic" style="--cnc-10-to:.41;--cnc-10-ton:41;--cnc-10-hue:35">
        <div class="cnc-10__ring">
          <span class="cnc-10__conic" aria-hidden="true"></span>
          <span class="cnc-10__num" aria-hidden="true"></span>
        </div>
        <figcaption class="cnc-10__cap">Storage used<span class="cnc-10__sr">41 percent</span></figcaption>
      </figure>
    </div>
    <p class="cnc-10__chip" aria-hidden="true">pathLength=100 · @property inherits:true · one animation drives arc + counter</p>
  </div>
</section>
```
## CSS
```css
@property --cnc-10-p {
  syntax: '<number>';
  inherits: true;
  initial-value: 0;
}

@property --cnc-10-n {
  syntax: '<integer>';
  inherits: true;
  initial-value: 0;
}

.cnc-10,
.cnc-10 *,
.cnc-10 *::before,
.cnc-10 *::after {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

.cnc-10 {
  width: 100%;
  min-height: 100vh;
  min-height: 100svh;
  display: block;
  --cnc-10-bg: oklch(0.16 0.02 260);
  --cnc-10-ink: oklch(0.98 0.004 260);
  --cnc-10-mut: oklch(0.67 0.02 260);
  background: var(--cnc-10-bg);
  color: var(--cnc-10-ink);
  font-family: 'Segoe UI',system-ui,-apple-system,sans-serif;
}

.cnc-10__stage {
  min-height: 100vh;
  min-height: 100svh;
  display: grid;
  place-items: center;
  align-content: center;
  gap: 26px;
  padding: clamp(18px,4vw,48px);
}

.cnc-10__grid {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: clamp(18px,4vw,40px);
}

.cnc-10__item {
  --cnc-10-acc: oklch(0.75 0.17 var(--cnc-10-hue,250));
  display: grid;
  justify-items: center;
  gap: 14px;
  animation: cnc-10-fill 2.1s cubic-bezier(.16,1,.3,1) forwards;
}

.cnc-10__item:nth-child(2) {
  animation-delay: .15s;
}

.cnc-10__item:nth-child(3) {
  animation-delay: .3s;
}

@keyframes cnc-10-fill {
  from {
    --cnc-10-p: 0;
    --cnc-10-n: 0;
  }

  to {
    --cnc-10-p: var(--cnc-10-to);
    --cnc-10-n: var(--cnc-10-ton);
  }
}

.cnc-10__ring {
  position: relative;
  display: grid;
  place-items: center;
  width: clamp(124px,26vw,158px);
  aspect-ratio: 1;
}

.cnc-10__ring svg {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  transform: rotate(-90deg);
  overflow: visible;
}

.cnc-10__track {
  fill: none;
  stroke: oklch(1 0 0/.1);
  stroke-width: 9;
}

.cnc-10__arc {
  fill: none;
  stroke: var(--cnc-10-acc);
  stroke-width: 9;
  stroke-linecap: round;
  stroke-dasharray: 100;
  stroke-dashoffset: calc(100 - 100 * var(--cnc-10-p));
  filter: drop-shadow(0 0 10px color-mix(in oklch,var(--cnc-10-acc) 55%,transparent));
}

.cnc-10__conic {
  position: absolute;
  inset: 0;
  border-radius: 50%;
  background: conic-gradient(var(--cnc-10-acc) calc(var(--cnc-10-p) * 360deg),oklch(1 0 0/.1) 0);
  -webkit-mask: radial-gradient(farthest-side,transparent calc(50% - 9px),#000 calc(50% - 8px));
  mask: radial-gradient(farthest-side,transparent calc(50% - 9px),#000 calc(50% - 8px));
}

.cnc-10__num {
  counter-reset: cnc-10-n var(--cnc-10-n);
  font-size: clamp(30px,6vw,38px);
  font-weight: 740;
  letter-spacing: -.04em;
  font-variant-numeric: tabular-nums;
  font-feature-settings: 'tnum' 1;
}

.cnc-10__num::after {
  content: counter(cnc-10-n) "%";
}

.cnc-10__cap {
  font-size: 13px;
  color: var(--cnc-10-mut);
  text-align: center;
}

.cnc-10__sr {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

.cnc-10__chip {
  font-family: ui-monospace,Menlo,Consolas,monospace;
  font-size: 11.5px;
  color: var(--cnc-10-mut);
  padding: 7px 14px;
  border: 1px solid oklch(1 0 0/.13);
  border-radius: 99px;
  text-align: center;
  max-width: min(100%,520px);
}

@media (prefers-reduced-motion: reduce) {
  .cnc-10__item {
    animation-duration: 1ms;
  }
}
```

How this works

The ring's geometry is arithmetic you do once. For radius r, circumference C = 2πr; set the dash pattern to the full circle and offset it by the unfilled portion:

circle { stroke-dasharray: 314.16;              /* C for r = 50 */
         stroke-dashoffset: calc(314.16 - 314.16 * var(--cnc-10-p)); }

Now animate one variable and both halves of the component follow:

@property --cnc-10-p { syntax: '<number>'; inherits: true; initial-value: 0 }
@property --cnc-10-n { syntax: '<integer>'; inherits: true; initial-value: 0 }

.ring { animation: fill 2s cubic-bezier(.16,1,.3,1) forwards }
@keyframes fill { to { --cnc-10-p: .78; --cnc-10-n: 78 } }

Both properties are declared inherits: true, so the arc (a descendant SVG circle) and the label (a pseudo-element using counter()) each read the same inherited value — one animation, two synchronised outputs, no JS. transform: rotate(-90deg) on the circle starts the arc at 12 o'clock, and stroke-linecap: round gives it the soft terminal every modern dashboard uses.

The conic variant does the same job with background: conic-gradient(var(--acc) calc(var(--p) * 360deg), transparent 0) and a mask — cheaper, but no round caps.

Make it yours

  • Thickness and cap: stroke-width sets the ring weight; drop stroke-linecap:round for a technical, blueprint feel.
  • Gradient arcs: reference an SVG <linearGradient> as the stroke, or overlay a conic-gradient ring with mask-composite for a two-tone sweep.
  • Threshold colouring: stroke: color-mix(in oklch, var(--bad), var(--good) calc(var(--p) * 100%)) ramps red → green as the ring fills.
  • Segmented gauge: add a second dasharray layer with small dashes as the track for a stepped, Apple-Watch-style dial.

Gotchas — read before shipping

  • Get the circumference right or the arc under/overshoots: C = 2πr where r is the SVG radius attribute, not the box size. Use pathLength="100" to sidestep the maths entirely — then dasharray is just 100.
  • SVG strokes are centred on the path, so half the stroke-width paints outside the radius and gets clipped. Inset the circle by half the stroke or set overflow:visible.
  • @property with inherits:false will not reach a descendant circle — this pattern specifically needs inherits:true.
  • A ring alone is not accessible. Add role="progressbar" with aria-valuenow/min/max, or a visually-hidden text equivalent.
  • Percentages above 100 wrap the arc silently. Clamp p to 1 with min() before it reaches the dashoffset calc.

Browser support

ChromeSafariFirefoxEdge
111+16.4+128+111+

@property is the floor (Chrome 85 / Safari 16.4 / Firefox 128). Without it the rings render at their initial value — the visually-hidden text keeps the meaning, and an @supports block can paint a static filled arc as a fallback.

Techniques used in this demo

Search CodeFronts

Loading…