Back to the library
Number ticker — count up
Variant: Count up
The one that is an entrance rather than an update — a stat landing on a dashboard for the first time. A registered `<integer>` custom property is animated and rendered through counter(), so the value itself is the animated thing and no script drives a loop; the deceleration is not authored anywhere, it falls out of putting an expo-out curve on the number. Left-aligned in a reserved three-digit box, because a centred count-up shifts the whole value twice on the way up as it gains digits.
datacounterentrance
The source
A complete HTML document. Paste it into an empty .html file and it plays — no stylesheet, script, font, or image to fetch.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Number ticker — count up</title>
<style>
/* A registered integer is the whole trick: the browser interpolates it,
rounds it every frame, and counter() renders the result — so the value
itself is the animated property and no script has to drive a loop. */
@property --_n {
syntax: '<integer>';
initial-value: 0;
inherits: false;
}
:root {
/* Neutrals — fixed, never remixed. */
--ivory: #faf9f5;
--paper: #ffffff;
--oat: #e3dacc;
--slate: #141413;
/* Knobs — the remix API. A knob exists iff declared here and used below. */
--hue: 38.8;
--lift: 0;
--chroma: 1;
--round: 1;
--speed: 1;
/* Accents — derived from the knobs with the family's offsets baked in,
so one hue roll moves them together. Defaults reproduce the original hex. */
--clay: oklch(calc(0.6724 + var(--lift)) calc(0.1308 * var(--chroma)) var(--hue));
--clay-d: oklch(calc(0.5797 + var(--lift)) calc(0.127 * var(--chroma)) var(--hue));
--olive: oklch(
calc(0.6118 + var(--lift)) calc(0.0713 * var(--chroma)) calc(var(--hue) + 88.3)
);
--rust: oklch(
calc(0.5408 + var(--lift)) calc(0.1357 * var(--chroma)) calc(var(--hue) - 10.3)
);
--_muted: color-mix(in srgb, var(--slate) 45%, transparent);
/* Clock — 4.6s: a 322ms arrival, a 1.15s count, a long hold, a 198ms
exit, and a dead beat before it re-arms. The hold is most of the loop
because this is an entrance — the tile spends its life settled. */
--cycle: 4.6s;
--t: calc(var(--cycle) / var(--speed));
--ease-out: cubic-bezier(0.23, 1, 0.32, 1);
/* Near-exponential decay. The deceleration of the count is not authored
anywhere — it falls out of applying this curve to the number itself,
so the first 200ms cover most of the range and the last hundred land
one at a time, which is what counting to something looks like. */
--ease-accent: cubic-bezier(0.16, 1, 0.3, 1);
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
min-height: 100vh;
display: grid;
place-items: center;
background: var(--ivory);
font-family:
system-ui,
-apple-system,
'Segoe UI',
Roboto,
sans-serif;
color: var(--slate);
}
.tile {
display: grid;
gap: 10px;
justify-items: start;
padding: 18px 22px 20px;
border: 1px solid var(--oat);
border-radius: calc(14px * var(--round));
background: var(--paper);
animation: arrive var(--t) infinite;
}
.label {
font-size: 12px;
font-weight: 600;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--_muted);
}
.value {
font-size: clamp(36px, 11vw, 46px);
font-weight: 600;
font-variant-numeric: tabular-nums;
}
/* Left-aligned inside a reserved three-digit box. Every digit that
appears keeps the column it appeared in, so the number grows rightwards
and nothing already on screen moves; centre or right alignment would
shift the whole value twice on the way up as it gains digits. */
.num {
display: inline-block;
min-width: 3ch;
animation:
count var(--t) infinite,
settle var(--t) infinite;
counter-reset: n var(--_n);
}
.num::after {
content: counter(n);
}
@keyframes count {
0%,
4% {
--_n: 0;
animation-timing-function: var(--ease-accent);
}
29%,
100% {
--_n: 300;
}
}
/* Clay while the number is still moving, released over 400ms once it
lands. The colour is what says the count is finished — the digits stop
changing a few frames before the eye is sure they have. */
@keyframes settle {
0%,
29% {
color: var(--clay-d);
animation-timing-function: var(--ease-out);
}
37.7%,
100% {
color: var(--slate);
}
}
@keyframes arrive {
0%,
4% {
opacity: 0;
transform: translateY(8px);
animation-timing-function: var(--ease-out);
}
11%,
86% {
opacity: 1;
transform: translateY(0);
animation-timing-function: var(--ease-out);
}
90.3%,
100% {
opacity: 0;
transform: translateY(-4px);
}
}
@media (prefers-reduced-motion: reduce) {
/* The count stays — it is a value changing, not a thing moving, and
removing it would leave the tile making no claim at all. The 8px rise
goes, and the cycle stretches to 7.4s so the digits resolve at a pace
that can be read rather than absorbed. */
.tile {
animation-name: arrive-still;
animation-duration: calc(var(--t) * 1.6);
}
.num {
animation-duration: calc(var(--t) * 1.6), calc(var(--t) * 1.6);
}
@keyframes arrive-still {
0%,
4% {
opacity: 0;
}
11%,
86% {
opacity: 1;
}
90.3%,
100% {
opacity: 0;
}
}
}
</style>
</head>
<body>
<div
class="tile"
role="img"
aria-label="A metric tile arriving on a dashboard, its value counting up from zero and decelerating onto 300 active sessions"
>
<span class="label">Active sessions</span>
<span class="value"><span class="num"></span></span>
</div>
</body>
</html>
The variant family
Same idea, one axis moved. Compare them side by side before you commit.