CSS Counters & Progress
When to Use
When a client wants animated counters, progress rings, step indicators, or number displays — without JavaScript counting libraries.
Decision
| Client asks for... | Use... | Why |
|---|---|---|
| Circular progress ring | SVG circle + stroke-dashoffset driven by CSS variable |
Animatable, accessible |
| Horizontal progress bar | scaleX() + CSS variable for percentage |
Compositor-safe animation |
| Step indicator (1 of 5) | CSS counters + :nth-child styling |
Pure CSS, semantic |
| Animated counting number | @property + counter() + animation |
CSS-only counting effect |
| Scroll-driven progress | animation-timeline: scroll() on scaleX |
See Scroll-Aware Components |
Pattern: Circular Progress Ring
.progress-ring {
--progress: 0.75; /* 0 to 1 */
--size: 120px;
--stroke: 8px;
width: var(--size);
height: var(--size);
}
.progress-ring circle {
fill: none;
stroke-width: var(--stroke);
r: calc(var(--size) / 2 - var(--stroke));
cx: calc(var(--size) / 2);
cy: calc(var(--size) / 2);
/* Circumference = 2πr */
stroke-dasharray: calc(3.1416 * (var(--size) - var(--stroke) * 2));
stroke-dashoffset: calc(
3.1416 * (var(--size) - var(--stroke) * 2) * (1 - var(--progress))
);
transform: rotate(-90deg);
transform-origin: center;
transition: stroke-dashoffset 1s var(--ease-emphasized-decel);
}
.progress-ring__bg { stroke: oklch(92% 0 0); }
.progress-ring__fill { stroke: var(--color-primary); stroke-linecap: round; }
Pattern: CSS-Only Animated Counter
@property --num {
syntax: "<integer>";
initial-value: 0;
inherits: false;
}
.counter {
--num: 0;
animation: count-up 2s ease-out forwards;
counter-set: num var(--num);
font-variant-numeric: tabular-nums;
}
.counter::after {
content: counter(num);
}
@keyframes count-up {
to { --num: 847; } /* Target number */
}
/* Scroll-triggered: start counting when in view */
.counter--scroll {
animation: count-up 1s linear forwards;
animation-timeline: view();
animation-range: entry 50% entry 100%;
}
Pattern: Step Indicator
.steps {
display: flex;
counter-reset: step;
}
.step {
counter-increment: step;
flex: 1;
text-align: center;
}
.step::before {
content: counter(step);
display: grid;
place-content: center;
width: 32px;
height: 32px;
border-radius: 50%;
background: oklch(92% 0 0);
margin: 0 auto 0.5rem;
transition: background 0.3s, color 0.3s;
}
.step.is-complete::before {
background: var(--color-primary);
color: white;
content: "✓";
}
.step.is-active::before {
background: var(--color-primary);
color: white;
}
Common Mistakes
- Using JS counting libraries when
@property+counter()works — CSS-only counting is smoother and lighter - Forgetting
font-variant-numeric: tabular-numson counters — without it, digits shift as they change width - Animating
widthfor progress bars — usetransform: scaleX()for 60fps
See Also
- Scroll-Aware Components → scroll-driven progress
- Skeleton and Loading States → loading indicators
- Reference: MDN: @property