SaaS Landing Page Key Stats Counter
The hero stat row every SaaS marketing page ships — 99.98% uptime, 12,480 teams, 4.9 rating — counting up on load with zero JavaScript. A registered @property integer is animated by the style engine and printed as text through counter(), so the numbers are genuinely interpolated rather than swapped between hardcoded strings. Staggered animation-delay gives the row a choreographed cascade instead of four things firing at once.
Published
The code
<section class="cnc-01" aria-label="SaaS landing page key stats counter demo">
<div class="cnc-01__stage">
<p class="cnc-01__eyebrow">Trusted at scale</p>
<h2 class="cnc-01__head">Numbers that hold up<br>under production load</h2>
<dl class="cnc-01__row">
<div class="cnc-01__stat">
<dd class="cnc-01__val"><span class="cnc-01__num" aria-hidden="true"></span><span class="cnc-01__suf">%</span><span class="cnc-01__sr">99.98 percent</span></dd>
<dt class="cnc-01__key">Uptime, 12-month rolling</dt>
</div>
<div class="cnc-01__stat">
<dd class="cnc-01__val"><span class="cnc-01__num" aria-hidden="true"></span><span class="cnc-01__suf">k</span><span class="cnc-01__sr">12.4 thousand</span></dd>
<dt class="cnc-01__key">Teams onboarded</dt>
</div>
<div class="cnc-01__stat">
<dd class="cnc-01__val"><span class="cnc-01__num" aria-hidden="true"></span><span class="cnc-01__suf">ms</span><span class="cnc-01__sr">42 milliseconds</span></dd>
<dt class="cnc-01__key">Median API response</dt>
</div>
<div class="cnc-01__stat">
<dd class="cnc-01__val"><span class="cnc-01__pre">4.</span><span class="cnc-01__num" aria-hidden="true"></span><span class="cnc-01__sr">4.9 out of 5</span></dd>
<dt class="cnc-01__key">Average review score</dt>
</div>
</dl>
<p class="cnc-01__chip" aria-hidden="true">@property <integer> · counter-reset var() · content:counter() · staggered delay</p>
</div>
</section><section class="cnc-01" aria-label="SaaS landing page key stats counter demo">
<div class="cnc-01__stage">
<p class="cnc-01__eyebrow">Trusted at scale</p>
<h2 class="cnc-01__head">Numbers that hold up<br>under production load</h2>
<dl class="cnc-01__row">
<div class="cnc-01__stat">
<dd class="cnc-01__val"><span class="cnc-01__num" aria-hidden="true"></span><span class="cnc-01__suf">%</span><span class="cnc-01__sr">99.98 percent</span></dd>
<dt class="cnc-01__key">Uptime, 12-month rolling</dt>
</div>
<div class="cnc-01__stat">
<dd class="cnc-01__val"><span class="cnc-01__num" aria-hidden="true"></span><span class="cnc-01__suf">k</span><span class="cnc-01__sr">12.4 thousand</span></dd>
<dt class="cnc-01__key">Teams onboarded</dt>
</div>
<div class="cnc-01__stat">
<dd class="cnc-01__val"><span class="cnc-01__num" aria-hidden="true"></span><span class="cnc-01__suf">ms</span><span class="cnc-01__sr">42 milliseconds</span></dd>
<dt class="cnc-01__key">Median API response</dt>
</div>
<div class="cnc-01__stat">
<dd class="cnc-01__val"><span class="cnc-01__pre">4.</span><span class="cnc-01__num" aria-hidden="true"></span><span class="cnc-01__sr">4.9 out of 5</span></dd>
<dt class="cnc-01__key">Average review score</dt>
</div>
</dl>
<p class="cnc-01__chip" aria-hidden="true">@property <integer> · counter-reset var() · content:counter() · staggered delay</p>
</div>
</section>@property --cnc-01-n {
syntax: '<integer>';
inherits: false;
initial-value: 0;
}
.cnc-01,
.cnc-01 *,
.cnc-01 *::before,
.cnc-01 *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
.cnc-01 {
width: 100%;
min-height: 100vh;
min-height: 100svh;
display: block;
--cnc-01-bg: oklch(0.17 0.02 265);
--cnc-01-ink: oklch(0.98 0.004 260);
--cnc-01-mut: oklch(0.68 0.02 265);
--cnc-01-acc: oklch(0.82 0.17 158);
background: var(--cnc-01-bg);
color: var(--cnc-01-ink);
font-family: 'Segoe UI',system-ui,-apple-system,sans-serif;
}
.cnc-01__stage {
min-height: 100vh;
min-height: 100svh;
display: grid;
place-items: center;
align-content: center;
gap: clamp(14px,2vw,20px);
padding: clamp(24px,6vw,72px);
text-align: center;
background: radial-gradient(90% 70% at 50% 0%,color-mix(in oklch,var(--cnc-01-acc) 16%,transparent),transparent 70%);
}
.cnc-01__eyebrow {
font-size: 12px;
font-weight: 700;
letter-spacing: .16em;
text-transform: uppercase;
color: var(--cnc-01-acc);
}
.cnc-01__head {
font-size: clamp(26px,4.4vw,46px);
font-weight: 680;
line-height: 1.1;
letter-spacing: -.03em;
text-wrap: balance;
}
.cnc-01__row {
display: grid;
grid-template-columns: repeat(auto-fit,minmax(168px,1fr));
gap: clamp(18px,3vw,10px);
width: min(100%,940px);
margin-top: clamp(14px,3vw,30px);
padding: clamp(20px,3vw,30px) 0;
border-block: 1px solid oklch(1 0 0/.12);
}
.cnc-01__stat {
display: grid;
gap: 8px;
padding: 0 clamp(10px,2vw,22px);
border-left: 1px solid oklch(1 0 0/.09);
}
.cnc-01__stat:first-child {
border-left: 0;
}
.cnc-01__val {
display: flex;
align-items: baseline;
justify-content: center;
font-size: clamp(38px,5.6vw,60px);
font-weight: 700;
letter-spacing: -.045em;
line-height: 1;
font-variant-numeric: tabular-nums;
font-feature-settings: 'tnum' 1;
}
.cnc-01__num::after {
content: counter(cnc-01-n);
}
.cnc-01__pre,
.cnc-01__suf {
color: var(--cnc-01-acc);
}
.cnc-01__suf {
font-size: .5em;
font-weight: 640;
margin-left: 2px;
letter-spacing: 0;
}
.cnc-01__key {
font-size: 13px;
line-height: 1.4;
color: var(--cnc-01-mut);
text-wrap: balance;
}
.cnc-01__num {
counter-reset: cnc-01-n var(--cnc-01-n);
animation: var(--cnc-01-kf) 2.2s cubic-bezier(.16,1,.3,1) both;
}
.cnc-01__stat:nth-child(1) .cnc-01__num {
--cnc-01-kf: cnc-01-a;
animation-delay: .05s;
}
.cnc-01__stat:nth-child(2) .cnc-01__num {
--cnc-01-kf: cnc-01-b;
animation-delay: .17s;
}
.cnc-01__stat:nth-child(3) .cnc-01__num {
--cnc-01-kf: cnc-01-c;
animation-delay: .29s;
}
.cnc-01__stat:nth-child(4) .cnc-01__num {
--cnc-01-kf: cnc-01-d;
animation-delay: .41s;
}
@keyframes cnc-01-a {
from {
--cnc-01-n: 0;
}
to {
--cnc-01-n: 99;
}
}
@keyframes cnc-01-b {
from {
--cnc-01-n: 0;
}
to {
--cnc-01-n: 12;
}
}
@keyframes cnc-01-c {
from {
--cnc-01-n: 0;
}
to {
--cnc-01-n: 42;
}
}
@keyframes cnc-01-d {
from {
--cnc-01-n: 0;
}
to {
--cnc-01-n: 9;
}
}
.cnc-01__stat {
animation: cnc-01-rise .8s cubic-bezier(.16,1,.3,1) both;
animation-delay: calc(var(--cnc-01-i,0) * .12s);
}
.cnc-01__stat:nth-child(2) {
--cnc-01-i: 1;
}
.cnc-01__stat:nth-child(3) {
--cnc-01-i: 2;
}
.cnc-01__stat:nth-child(4) {
--cnc-01-i: 3;
}
@keyframes cnc-01-rise {
from {
opacity: 0;
transform: translateY(14px);
}
to {
opacity: 1;
transform: none;
}
}
.cnc-01__sr {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.cnc-01__chip {
margin-top: clamp(10px,2vw,18px);
font-family: ui-monospace,Menlo,Consolas,monospace;
font-size: 11.5px;
color: var(--cnc-01-mut);
padding: 7px 14px;
border: 1px solid oklch(1 0 0/.13);
border-radius: 99px;
}
@supports not (background: paint(x)) {
.cnc-01__num::after {
content: counter(cnc-01-n);
}
}
@media (prefers-reduced-motion: reduce) {
.cnc-01__num {
animation-duration: 1ms;
}
.cnc-01__stat {
animation: none;
}
}@property --cnc-01-n {
syntax: '<integer>';
inherits: false;
initial-value: 0;
}
.cnc-01,
.cnc-01 *,
.cnc-01 *::before,
.cnc-01 *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
.cnc-01 {
width: 100%;
min-height: 100vh;
min-height: 100svh;
display: block;
--cnc-01-bg: oklch(0.17 0.02 265);
--cnc-01-ink: oklch(0.98 0.004 260);
--cnc-01-mut: oklch(0.68 0.02 265);
--cnc-01-acc: oklch(0.82 0.17 158);
background: var(--cnc-01-bg);
color: var(--cnc-01-ink);
font-family: 'Segoe UI',system-ui,-apple-system,sans-serif;
}
.cnc-01__stage {
min-height: 100vh;
min-height: 100svh;
display: grid;
place-items: center;
align-content: center;
gap: clamp(14px,2vw,20px);
padding: clamp(24px,6vw,72px);
text-align: center;
background: radial-gradient(90% 70% at 50% 0%,color-mix(in oklch,var(--cnc-01-acc) 16%,transparent),transparent 70%);
}
.cnc-01__eyebrow {
font-size: 12px;
font-weight: 700;
letter-spacing: .16em;
text-transform: uppercase;
color: var(--cnc-01-acc);
}
.cnc-01__head {
font-size: clamp(26px,4.4vw,46px);
font-weight: 680;
line-height: 1.1;
letter-spacing: -.03em;
text-wrap: balance;
}
.cnc-01__row {
display: grid;
grid-template-columns: repeat(auto-fit,minmax(168px,1fr));
gap: clamp(18px,3vw,10px);
width: min(100%,940px);
margin-top: clamp(14px,3vw,30px);
padding: clamp(20px,3vw,30px) 0;
border-block: 1px solid oklch(1 0 0/.12);
}
.cnc-01__stat {
display: grid;
gap: 8px;
padding: 0 clamp(10px,2vw,22px);
border-left: 1px solid oklch(1 0 0/.09);
}
.cnc-01__stat:first-child {
border-left: 0;
}
.cnc-01__val {
display: flex;
align-items: baseline;
justify-content: center;
font-size: clamp(38px,5.6vw,60px);
font-weight: 700;
letter-spacing: -.045em;
line-height: 1;
font-variant-numeric: tabular-nums;
font-feature-settings: 'tnum' 1;
}
.cnc-01__num::after {
content: counter(cnc-01-n);
}
.cnc-01__pre,
.cnc-01__suf {
color: var(--cnc-01-acc);
}
.cnc-01__suf {
font-size: .5em;
font-weight: 640;
margin-left: 2px;
letter-spacing: 0;
}
.cnc-01__key {
font-size: 13px;
line-height: 1.4;
color: var(--cnc-01-mut);
text-wrap: balance;
}
.cnc-01__num {
counter-reset: cnc-01-n var(--cnc-01-n);
animation: var(--cnc-01-kf) 2.2s cubic-bezier(.16,1,.3,1) both;
}
.cnc-01__stat:nth-child(1) .cnc-01__num {
--cnc-01-kf: cnc-01-a;
animation-delay: .05s;
}
.cnc-01__stat:nth-child(2) .cnc-01__num {
--cnc-01-kf: cnc-01-b;
animation-delay: .17s;
}
.cnc-01__stat:nth-child(3) .cnc-01__num {
--cnc-01-kf: cnc-01-c;
animation-delay: .29s;
}
.cnc-01__stat:nth-child(4) .cnc-01__num {
--cnc-01-kf: cnc-01-d;
animation-delay: .41s;
}
@keyframes cnc-01-a {
from {
--cnc-01-n: 0;
}
to {
--cnc-01-n: 99;
}
}
@keyframes cnc-01-b {
from {
--cnc-01-n: 0;
}
to {
--cnc-01-n: 12;
}
}
@keyframes cnc-01-c {
from {
--cnc-01-n: 0;
}
to {
--cnc-01-n: 42;
}
}
@keyframes cnc-01-d {
from {
--cnc-01-n: 0;
}
to {
--cnc-01-n: 9;
}
}
.cnc-01__stat {
animation: cnc-01-rise .8s cubic-bezier(.16,1,.3,1) both;
animation-delay: calc(var(--cnc-01-i,0) * .12s);
}
.cnc-01__stat:nth-child(2) {
--cnc-01-i: 1;
}
.cnc-01__stat:nth-child(3) {
--cnc-01-i: 2;
}
.cnc-01__stat:nth-child(4) {
--cnc-01-i: 3;
}
@keyframes cnc-01-rise {
from {
opacity: 0;
transform: translateY(14px);
}
to {
opacity: 1;
transform: none;
}
}
.cnc-01__sr {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.cnc-01__chip {
margin-top: clamp(10px,2vw,18px);
font-family: ui-monospace,Menlo,Consolas,monospace;
font-size: 11.5px;
color: var(--cnc-01-mut);
padding: 7px 14px;
border: 1px solid oklch(1 0 0/.13);
border-radius: 99px;
}
@supports not (background: paint(x)) {
.cnc-01__num::after {
content: counter(cnc-01-n);
}
}
@media (prefers-reduced-motion: reduce) {
.cnc-01__num {
animation-duration: 1ms;
}
.cnc-01__stat {
animation: none;
}
}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: SaaS Landing Page Key Stats Counter
Source: https://codefronts.com/motion/css-number-counter-animations/saas-landing-page-key-stats-counter/
The hero stat row every SaaS marketing page ships — 99.98% uptime, 12,480 teams, 4.9 rating — counting up on load with zero JavaScript. A registered @property integer is animated by the style engine and printed as text through counter(), so the numbers are genuinely interpolated rather than swapped between hardcoded strings. Staggered animation-delay gives the row a choreographed cascade instead of four things firing at once.
## HTML
```html
<section class="cnc-01" aria-label="SaaS landing page key stats counter demo">
<div class="cnc-01__stage">
<p class="cnc-01__eyebrow">Trusted at scale</p>
<h2 class="cnc-01__head">Numbers that hold up<br>under production load</h2>
<dl class="cnc-01__row">
<div class="cnc-01__stat">
<dd class="cnc-01__val"><span class="cnc-01__num" aria-hidden="true"></span><span class="cnc-01__suf">%</span><span class="cnc-01__sr">99.98 percent</span></dd>
<dt class="cnc-01__key">Uptime, 12-month rolling</dt>
</div>
<div class="cnc-01__stat">
<dd class="cnc-01__val"><span class="cnc-01__num" aria-hidden="true"></span><span class="cnc-01__suf">k</span><span class="cnc-01__sr">12.4 thousand</span></dd>
<dt class="cnc-01__key">Teams onboarded</dt>
</div>
<div class="cnc-01__stat">
<dd class="cnc-01__val"><span class="cnc-01__num" aria-hidden="true"></span><span class="cnc-01__suf">ms</span><span class="cnc-01__sr">42 milliseconds</span></dd>
<dt class="cnc-01__key">Median API response</dt>
</div>
<div class="cnc-01__stat">
<dd class="cnc-01__val"><span class="cnc-01__pre">4.</span><span class="cnc-01__num" aria-hidden="true"></span><span class="cnc-01__sr">4.9 out of 5</span></dd>
<dt class="cnc-01__key">Average review score</dt>
</div>
</dl>
<p class="cnc-01__chip" aria-hidden="true">@property <integer> · counter-reset var() · content:counter() · staggered delay</p>
</div>
</section>
```
## CSS
```css
@property --cnc-01-n {
syntax: '<integer>';
inherits: false;
initial-value: 0;
}
.cnc-01,
.cnc-01 *,
.cnc-01 *::before,
.cnc-01 *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
.cnc-01 {
width: 100%;
min-height: 100vh;
min-height: 100svh;
display: block;
--cnc-01-bg: oklch(0.17 0.02 265);
--cnc-01-ink: oklch(0.98 0.004 260);
--cnc-01-mut: oklch(0.68 0.02 265);
--cnc-01-acc: oklch(0.82 0.17 158);
background: var(--cnc-01-bg);
color: var(--cnc-01-ink);
font-family: 'Segoe UI',system-ui,-apple-system,sans-serif;
}
.cnc-01__stage {
min-height: 100vh;
min-height: 100svh;
display: grid;
place-items: center;
align-content: center;
gap: clamp(14px,2vw,20px);
padding: clamp(24px,6vw,72px);
text-align: center;
background: radial-gradient(90% 70% at 50% 0%,color-mix(in oklch,var(--cnc-01-acc) 16%,transparent),transparent 70%);
}
.cnc-01__eyebrow {
font-size: 12px;
font-weight: 700;
letter-spacing: .16em;
text-transform: uppercase;
color: var(--cnc-01-acc);
}
.cnc-01__head {
font-size: clamp(26px,4.4vw,46px);
font-weight: 680;
line-height: 1.1;
letter-spacing: -.03em;
text-wrap: balance;
}
.cnc-01__row {
display: grid;
grid-template-columns: repeat(auto-fit,minmax(168px,1fr));
gap: clamp(18px,3vw,10px);
width: min(100%,940px);
margin-top: clamp(14px,3vw,30px);
padding: clamp(20px,3vw,30px) 0;
border-block: 1px solid oklch(1 0 0/.12);
}
.cnc-01__stat {
display: grid;
gap: 8px;
padding: 0 clamp(10px,2vw,22px);
border-left: 1px solid oklch(1 0 0/.09);
}
.cnc-01__stat:first-child {
border-left: 0;
}
.cnc-01__val {
display: flex;
align-items: baseline;
justify-content: center;
font-size: clamp(38px,5.6vw,60px);
font-weight: 700;
letter-spacing: -.045em;
line-height: 1;
font-variant-numeric: tabular-nums;
font-feature-settings: 'tnum' 1;
}
.cnc-01__num::after {
content: counter(cnc-01-n);
}
.cnc-01__pre,
.cnc-01__suf {
color: var(--cnc-01-acc);
}
.cnc-01__suf {
font-size: .5em;
font-weight: 640;
margin-left: 2px;
letter-spacing: 0;
}
.cnc-01__key {
font-size: 13px;
line-height: 1.4;
color: var(--cnc-01-mut);
text-wrap: balance;
}
.cnc-01__num {
counter-reset: cnc-01-n var(--cnc-01-n);
animation: var(--cnc-01-kf) 2.2s cubic-bezier(.16,1,.3,1) both;
}
.cnc-01__stat:nth-child(1) .cnc-01__num {
--cnc-01-kf: cnc-01-a;
animation-delay: .05s;
}
.cnc-01__stat:nth-child(2) .cnc-01__num {
--cnc-01-kf: cnc-01-b;
animation-delay: .17s;
}
.cnc-01__stat:nth-child(3) .cnc-01__num {
--cnc-01-kf: cnc-01-c;
animation-delay: .29s;
}
.cnc-01__stat:nth-child(4) .cnc-01__num {
--cnc-01-kf: cnc-01-d;
animation-delay: .41s;
}
@keyframes cnc-01-a {
from {
--cnc-01-n: 0;
}
to {
--cnc-01-n: 99;
}
}
@keyframes cnc-01-b {
from {
--cnc-01-n: 0;
}
to {
--cnc-01-n: 12;
}
}
@keyframes cnc-01-c {
from {
--cnc-01-n: 0;
}
to {
--cnc-01-n: 42;
}
}
@keyframes cnc-01-d {
from {
--cnc-01-n: 0;
}
to {
--cnc-01-n: 9;
}
}
.cnc-01__stat {
animation: cnc-01-rise .8s cubic-bezier(.16,1,.3,1) both;
animation-delay: calc(var(--cnc-01-i,0) * .12s);
}
.cnc-01__stat:nth-child(2) {
--cnc-01-i: 1;
}
.cnc-01__stat:nth-child(3) {
--cnc-01-i: 2;
}
.cnc-01__stat:nth-child(4) {
--cnc-01-i: 3;
}
@keyframes cnc-01-rise {
from {
opacity: 0;
transform: translateY(14px);
}
to {
opacity: 1;
transform: none;
}
}
.cnc-01__sr {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.cnc-01__chip {
margin-top: clamp(10px,2vw,18px);
font-family: ui-monospace,Menlo,Consolas,monospace;
font-size: 11.5px;
color: var(--cnc-01-mut);
padding: 7px 14px;
border: 1px solid oklch(1 0 0/.13);
border-radius: 99px;
}
@supports not (background: paint(x)) {
.cnc-01__num::after {
content: counter(cnc-01-n);
}
}
@media (prefers-reduced-motion: reduce) {
.cnc-01__num {
animation-duration: 1ms;
}
.cnc-01__stat {
animation: none;
}
}
```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: SaaS Landing Page Key Stats Counter
Source: https://codefronts.com/motion/css-number-counter-animations/saas-landing-page-key-stats-counter/
The hero stat row every SaaS marketing page ships — 99.98% uptime, 12,480 teams, 4.9 rating — counting up on load with zero JavaScript. A registered @property integer is animated by the style engine and printed as text through counter(), so the numbers are genuinely interpolated rather than swapped between hardcoded strings. Staggered animation-delay gives the row a choreographed cascade instead of four things firing at once.
## HTML
```html
<section class="cnc-01" aria-label="SaaS landing page key stats counter demo">
<div class="cnc-01__stage">
<p class="cnc-01__eyebrow">Trusted at scale</p>
<h2 class="cnc-01__head">Numbers that hold up<br>under production load</h2>
<dl class="cnc-01__row">
<div class="cnc-01__stat">
<dd class="cnc-01__val"><span class="cnc-01__num" aria-hidden="true"></span><span class="cnc-01__suf">%</span><span class="cnc-01__sr">99.98 percent</span></dd>
<dt class="cnc-01__key">Uptime, 12-month rolling</dt>
</div>
<div class="cnc-01__stat">
<dd class="cnc-01__val"><span class="cnc-01__num" aria-hidden="true"></span><span class="cnc-01__suf">k</span><span class="cnc-01__sr">12.4 thousand</span></dd>
<dt class="cnc-01__key">Teams onboarded</dt>
</div>
<div class="cnc-01__stat">
<dd class="cnc-01__val"><span class="cnc-01__num" aria-hidden="true"></span><span class="cnc-01__suf">ms</span><span class="cnc-01__sr">42 milliseconds</span></dd>
<dt class="cnc-01__key">Median API response</dt>
</div>
<div class="cnc-01__stat">
<dd class="cnc-01__val"><span class="cnc-01__pre">4.</span><span class="cnc-01__num" aria-hidden="true"></span><span class="cnc-01__sr">4.9 out of 5</span></dd>
<dt class="cnc-01__key">Average review score</dt>
</div>
</dl>
<p class="cnc-01__chip" aria-hidden="true">@property <integer> · counter-reset var() · content:counter() · staggered delay</p>
</div>
</section>
```
## CSS
```css
@property --cnc-01-n {
syntax: '<integer>';
inherits: false;
initial-value: 0;
}
.cnc-01,
.cnc-01 *,
.cnc-01 *::before,
.cnc-01 *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
.cnc-01 {
width: 100%;
min-height: 100vh;
min-height: 100svh;
display: block;
--cnc-01-bg: oklch(0.17 0.02 265);
--cnc-01-ink: oklch(0.98 0.004 260);
--cnc-01-mut: oklch(0.68 0.02 265);
--cnc-01-acc: oklch(0.82 0.17 158);
background: var(--cnc-01-bg);
color: var(--cnc-01-ink);
font-family: 'Segoe UI',system-ui,-apple-system,sans-serif;
}
.cnc-01__stage {
min-height: 100vh;
min-height: 100svh;
display: grid;
place-items: center;
align-content: center;
gap: clamp(14px,2vw,20px);
padding: clamp(24px,6vw,72px);
text-align: center;
background: radial-gradient(90% 70% at 50% 0%,color-mix(in oklch,var(--cnc-01-acc) 16%,transparent),transparent 70%);
}
.cnc-01__eyebrow {
font-size: 12px;
font-weight: 700;
letter-spacing: .16em;
text-transform: uppercase;
color: var(--cnc-01-acc);
}
.cnc-01__head {
font-size: clamp(26px,4.4vw,46px);
font-weight: 680;
line-height: 1.1;
letter-spacing: -.03em;
text-wrap: balance;
}
.cnc-01__row {
display: grid;
grid-template-columns: repeat(auto-fit,minmax(168px,1fr));
gap: clamp(18px,3vw,10px);
width: min(100%,940px);
margin-top: clamp(14px,3vw,30px);
padding: clamp(20px,3vw,30px) 0;
border-block: 1px solid oklch(1 0 0/.12);
}
.cnc-01__stat {
display: grid;
gap: 8px;
padding: 0 clamp(10px,2vw,22px);
border-left: 1px solid oklch(1 0 0/.09);
}
.cnc-01__stat:first-child {
border-left: 0;
}
.cnc-01__val {
display: flex;
align-items: baseline;
justify-content: center;
font-size: clamp(38px,5.6vw,60px);
font-weight: 700;
letter-spacing: -.045em;
line-height: 1;
font-variant-numeric: tabular-nums;
font-feature-settings: 'tnum' 1;
}
.cnc-01__num::after {
content: counter(cnc-01-n);
}
.cnc-01__pre,
.cnc-01__suf {
color: var(--cnc-01-acc);
}
.cnc-01__suf {
font-size: .5em;
font-weight: 640;
margin-left: 2px;
letter-spacing: 0;
}
.cnc-01__key {
font-size: 13px;
line-height: 1.4;
color: var(--cnc-01-mut);
text-wrap: balance;
}
.cnc-01__num {
counter-reset: cnc-01-n var(--cnc-01-n);
animation: var(--cnc-01-kf) 2.2s cubic-bezier(.16,1,.3,1) both;
}
.cnc-01__stat:nth-child(1) .cnc-01__num {
--cnc-01-kf: cnc-01-a;
animation-delay: .05s;
}
.cnc-01__stat:nth-child(2) .cnc-01__num {
--cnc-01-kf: cnc-01-b;
animation-delay: .17s;
}
.cnc-01__stat:nth-child(3) .cnc-01__num {
--cnc-01-kf: cnc-01-c;
animation-delay: .29s;
}
.cnc-01__stat:nth-child(4) .cnc-01__num {
--cnc-01-kf: cnc-01-d;
animation-delay: .41s;
}
@keyframes cnc-01-a {
from {
--cnc-01-n: 0;
}
to {
--cnc-01-n: 99;
}
}
@keyframes cnc-01-b {
from {
--cnc-01-n: 0;
}
to {
--cnc-01-n: 12;
}
}
@keyframes cnc-01-c {
from {
--cnc-01-n: 0;
}
to {
--cnc-01-n: 42;
}
}
@keyframes cnc-01-d {
from {
--cnc-01-n: 0;
}
to {
--cnc-01-n: 9;
}
}
.cnc-01__stat {
animation: cnc-01-rise .8s cubic-bezier(.16,1,.3,1) both;
animation-delay: calc(var(--cnc-01-i,0) * .12s);
}
.cnc-01__stat:nth-child(2) {
--cnc-01-i: 1;
}
.cnc-01__stat:nth-child(3) {
--cnc-01-i: 2;
}
.cnc-01__stat:nth-child(4) {
--cnc-01-i: 3;
}
@keyframes cnc-01-rise {
from {
opacity: 0;
transform: translateY(14px);
}
to {
opacity: 1;
transform: none;
}
}
.cnc-01__sr {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.cnc-01__chip {
margin-top: clamp(10px,2vw,18px);
font-family: ui-monospace,Menlo,Consolas,monospace;
font-size: 11.5px;
color: var(--cnc-01-mut);
padding: 7px 14px;
border: 1px solid oklch(1 0 0/.13);
border-radius: 99px;
}
@supports not (background: paint(x)) {
.cnc-01__num::after {
content: counter(cnc-01-n);
}
}
@media (prefers-reduced-motion: reduce) {
.cnc-01__num {
animation-duration: 1ms;
}
.cnc-01__stat {
animation: none;
}
}
```How this works
CSS cannot animate text content — but it can animate a registered custom property, and a CSS counter can read that property. Chain the two and you get a real count-up in the style engine:
@property --cnc-01-n {
syntax: '<integer>'; inherits: false; initial-value: 0;
}
.num {
counter-reset: cnc-01-n var(--cnc-01-n); /* number → counter */
animation: count 2.2s cubic-bezier(.16,1,.3,1) both;
}
.num::after { content: counter(cnc-01-n); } /* counter → text */
@keyframes count { to { --cnc-01-n: 12480; } }The syntax descriptor is the whole trick: an unregistered custom property is just a string and animates discretely (0 → 12480, one jump). Registering it as <integer> teaches the engine its type, so it interpolates smoothly and rounds to a whole number on every frame.
Each stat owns its own keyframes and an animation-delay 120 ms apart, which is what makes the row feel authored rather than automatic. The visible digits are aria-hidden; a visually-hidden span carries the final value as plain text, so a screen reader hears "12,480 teams onboarded" once instead of two thousand intermediate numbers.
Make it yours
- Different ease per stat:
cubic-bezier(.16,1,.3,1)lands hard and fast (good for big numbers);linearreads like a meter;steps(30)gives a deliberately mechanical tick. - Add thousands separators by splitting the number into two counters — see demo 09 for the full Intl-formatted version, or hardcode a comma between two spans (12 · , · 480).
- Re-theme in one line: every color derives from
--cnc-01-accthrough color-mix(), so a single oklch hue rotation restyles glow, rules and accent text together. - Replay on scroll instead of on load: remove
both, addanimation-play-state:paused, and resume it from an IntersectionObserver — or use the scroll-driven version in demo 02.
Gotchas — read before shipping
- Without @property the animation snaps. An unregistered custom property has no type, so the engine can only do a discrete 50%-of-the-way swap — always register the syntax.
- counter() output is not selectable, searchable, or in the accessibility tree in a useful way. Always pair it with a real text node (visually hidden here) carrying the true value.
- Decimals need two counters: CSS counters are integers only. Animate 999.8 as an integer and place the decimal point between two counter() calls (demo 16 does exactly this).
- Digits of different widths cause layout jitter as the number climbs. font-variant-numeric:tabular-nums fixes the advance width; without it the whole row twitches.
- Under prefers-reduced-motion the number must still reach its final value — set the animation to a 1 ms duration rather than none, or the counter freezes at zero.
Browser support
| Chrome | Safari | Firefox | Edge |
|---|---|---|---|
| 114+ | 17.5+ | 128+ | 114+ |
Floor set by oklch(), color-mix(), @property, text-wrap as this demo is written. The core technique itself goes back further — Chrome 111+.
@property is the floor: Chrome 85, Safari 16.4, Firefox 128 (July 2024). Older Firefox shows the initial-value (0) — the visually-hidden real number and an @supports fallback that prints the static value keep it correct.