Updated 2026-10-14. 8 effects.
A number counter animation counts from zero to a target when it scrolls into view. Watch the element with IntersectionObserver, then step the value with requestAnimationFrame along an ease-out curve and format it with Intl.NumberFormat. Use tabular numbers so the width never jitters, and keep the final number in the HTML for screen readers.
Counters belong on stats rows, pricing totals, fundraising progress and launch countdowns. The simple count-up suits most pages; the rolling odometer and flip digits add weight to a single big number; the countdown fits events and sales that end on a date.
Every counter here ships with the real, final number already in the markup. A visitor without JavaScript, a search engine and a screen reader all get the true value, and the animation only plays over the top of it. The numbers in the demos are samples, so replace them with your own.
| Effect | Built with | Size (gzipped) | Running cost |
|---|---|---|---|
| Rolling Odometer Number Counter | CSS only | 2.8 KB | Six translated digit strips updated by one short requestAnimationFrame run; no layout work per frame. |
| Bakery Stats Row That Counts Up | CSS only | 2.3 KB | One short requestAnimationFrame run that rewrites four text nodes and one transform each, then stops for good. |
| Calm Countdown Timer to a Date | CSS only | 2.6 KB | One timeout per minute and a single 60 second transform animation on a one pixel line. |
| Currency Total Counter With Cents | CSS only | 2.4 KB | Four short text rewrites per frame for under two seconds, driven by one requestAnimationFrame loop. |
| Hero Number Counter With Motion Blur | CSS only | 2.1 KB | Rebuilds five or six digit spans per frame for about two seconds; the blur filter is applied to those spans only. |
| Live Demo Counter That Ticks Up | CSS only | 2.5 KB | A timeout every few seconds swaps one or two digit spans; the only loop is a slow opacity pulse on a dot. |
| Percentage Counter With Progress Ring | CSS only | 2.8 KB | Updates a stroke offset, a few dozen tick colours and three text nodes per frame for under two seconds. |
| Split-Flap Number Counter | CSS only | 2.7 KB | A short interval swaps a few text nodes; each flip is two CSS transform animations on small elements. |
A mileage reading built like a real odometer: shaded drums behind a brass bezel, with an ivory tenths wheel at the end. The tenths spin freely while each drum to the left only turns as the one beside it rolls past nine, so carries look mechanical rather than random. On phones the whole meter scales with the screen width and stays on one line.
<section class="odo-root" data-value="48306.4" data-drums="5">
<div class="odo-inner">
<p class="odo-eyebrow">Cobble Lane Couriers</p>
<h2 class="odo-title">Miles ridden by bike this year</h2>
<div class="odo-bezel">
<div class="odo-case">
<!-- The finished figure is written here for screen readers and no-JS visitors; the script builds the drums beside it. -->
<p class="odo-readout"><span class="odo-sr">48,306.4 miles</span><span class="odo-static" aria-hidden="true">48,306.4</span></p>
</div>
</div>
<p class="odo-note">Sample figure for a made-up courier. Each drum only turns while the one to its right passes nine.</p>
<button type="button" class="odo-replay">Roll it again</button>
</div>
</section>
<style>
/* Values worth changing:
--odo-drum / --odo-digit: drum face and numeral colours
--odo-brass-1, --odo-brass-2: bezel metal --odo-cell: height of one digit window
data-value and data-drums on .odo-root set the figure and how many whole-number drums show */
.odo-root { --odo-drum: #141518; --odo-digit: #eee8da; --odo-brass-1: #c9a76a; --odo-brass-2: #8c6b37; --odo-cell: 1.3em;
position: relative; overflow: hidden; background: #202226; color: #eee8da; padding: 64px 20px; text-align: center;
font-family: ui-sans-serif, system-ui, sans-serif; }
.odo-root::before { content: ""; position: absolute; inset: 0; pointer-events: none;
background: radial-gradient(80% 60% at 50% 0%, rgba(214,180,120,.14), rgba(0,0,0,0) 70%); }
.odo-inner { position: relative; max-width: 768px; margin: 0 auto; display: flex; flex-direction: column; align-items: center; }
.odo-eyebrow { margin: 0; font-size: 14px; font-weight: 500; letter-spacing: .2em; text-transform: uppercase; color: #d6b478; }
.odo-title { margin: 12px 0 0; font-size: clamp(30px, 4vw, 36px); font-weight: 600; letter-spacing: -.01em; }
.odo-bezel { margin-top: 40px; padding: 3px; border-radius: 16px; box-shadow: 0 24px 60px -20px rgba(0,0,0,.8);
background: linear-gradient(180deg, var(--odo-brass-1), var(--odo-brass-2) 50%, #c19b5c); }
.odo-case { border-radius: 14px; padding: 16px 12px; background: linear-gradient(180deg, #2c2e33, #17181b); }
.odo-readout { margin: 0; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-weight: 600; line-height: 1;
font-size: clamp(2.4rem, 9vw, 4.5rem); font-variant-numeric: tabular-nums; }
.odo-row { display: flex; align-items: center; gap: .08em; }
.odo-drum { position: relative; display: inline-block; width: .86em; height: var(--odo-cell); overflow: hidden; border-radius: 3px;
background: var(--odo-drum); color: var(--odo-digit); }
.odo-drum.odo-light { background: #ece6d8; color: #1c1d20; }
.odo-strip { position: absolute; left: 0; right: 0; top: 0; will-change: transform; }
.odo-strip span { display: flex; height: var(--odo-cell); align-items: center; justify-content: center; }
.odo-drum::after { content: ""; position: absolute; inset: 0; pointer-events: none;
background: linear-gradient(180deg, rgba(0,0,0,.85), rgba(0,0,0,0) 34%, rgba(255,255,255,.07) 48%, rgba(0,0,0,0) 62%, rgba(0,0,0,.85)); }
.odo-drum.odo-light::after { background: linear-gradient(180deg, rgba(40,30,10,.55), rgba(40,30,10,0) 32%, rgba(255,255,255,.18) 48%, rgba(40,30,10,0) 64%, rgba(40,30,10,.55)); }
.odo-sep { align-self: flex-end; width: .28em; padding-bottom: .1em; font-size: .6em; color: #8f8a80; }
.odo-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
.odo-note { margin: 32px 0 0; max-width: 28rem; font-size: 14px; line-height: 1.6; color: #b9b3a7; }
.odo-replay { margin-top: 24px; height: 40px; padding: 0 18px; border-radius: 999px; border: 1px solid rgba(214,180,120,.4);
background: transparent; color: #eee8da; font: inherit; font-size: 14px; cursor: pointer; }
.odo-replay:hover { background: rgba(255,255,255,.05); }
.odo-replay:focus-visible { outline: 2px solid #d6b478; outline-offset: 2px; }
@media (min-width: 640px) { .odo-root { padding: 80px 20px; } .odo-case { padding: 20px; } }
@media (prefers-reduced-motion: reduce) { .odo-strip { transition: none !important; } }
</style>
<script>
(function () {
var DURATION = 2400; // ms for the full roll
var root = document.querySelector(".odo-root");
if (!root) return;
var value = Number(root.dataset.value);
var drums = Number(root.dataset.drums);
var readout = root.querySelector(".odo-readout");
var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
var group = (new Intl.NumberFormat().formatToParts(1000).filter(function (p) { return p.type === "group"; })[0] || { value: "," }).value;
var decimal = (new Intl.NumberFormat().formatToParts(1.5).filter(function (p) { return p.type === "decimal"; })[0] || { value: "." }).value;
// Build the drums with createElement, replacing the static figure.
var row = document.createElement("span");
row.className = "odo-row";
row.setAttribute("aria-hidden", "true");
var strips = [];
function sep(ch) { var s = document.createElement("span"); s.className = "odo-sep"; s.textContent = ch; row.appendChild(s); }
for (var i = 0; i <= drums; i++) {
if (i === drums) sep(decimal);
else if (i > 0 && (drums - i) % 3 === 0) sep(group);
var drum = document.createElement("span");
drum.className = "odo-drum" + (i === drums ? " odo-light" : "");
var strip = document.createElement("span");
strip.className = "odo-strip";
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0].forEach(function (d) { var c = document.createElement("span"); c.textContent = d; strip.appendChild(c); });
drum.appendChild(strip);
row.appendChild(drum);
strips.push(strip);
}
readout.removeChild(readout.querySelector(".odo-static"));
readout.appendChild(row);
// Each drum's position, tenths last. A drum turns only while every drum to its right is passing 9 to 0.
function paint(v) {
var ticks = Math.round(v * 10 * 1e6) / 1e6;
for (var p = 0; p <= drums; p++) {
var unit = Math.pow(10, p), pos;
if (p === 0) pos = ticks % 10;
else pos = Math.floor(ticks / unit) % 10 + Math.max(0, (ticks % unit) - (unit - 1));
strips[drums - p].style.transform = "translateY(" + (-pos * 1.3) + "em)";
}
}
function ease(t) { return 1 - Math.pow(1 - t, 4); }
var raf = 0, start = 0, elapsed = 0, visible = false, done = false;
function tick(now) {
if (!start) start = now - elapsed;
elapsed = now - start;
var p = Math.min(1, elapsed / DURATION);
paint(value * ease(p));
done = p >= 1;
if (!done && visible) raf = requestAnimationFrame(tick);
}
if (reduce || !("IntersectionObserver" in window)) { paint(value); return; }
paint(0);
var io = new IntersectionObserver(function (entries) {
visible = entries[0].isIntersecting;
cancelAnimationFrame(raf);
start = 0;
if (visible && !done) raf = requestAnimationFrame(tick);
}, { threshold: 0.3 });
io.observe(root);
root.querySelector(".odo-replay").addEventListener("click", function () {
cancelAnimationFrame(raf); elapsed = 0; start = 0; done = false;
if (visible) raf = requestAnimationFrame(tick);
});
})();
</script>
With reduced motion: The drums sit still on the final reading and the replay button does nothing.
Running cost: Six translated digit strips updated by one short requestAnimationFrame run; no layout work per frame.
Full page for Rolling Odometer Number Counter
Four figures for a neighbourhood bakery set on warm, lightly grained paper. When the row scrolls into view each serif numeral climbs from zero with a cubic ease-out, one a beat after the other, while a thin rust rule above it grows in step. It suits an about page or a quiet trust strip under a hero.
<section class="bkc-root">
<div class="bkc-inner">
<p class="bkc-eyebrow">Fennel Street Bakehouse</p>
<h2 class="bkc-title">Twelve years of early mornings, counted.</h2>
<!-- Each number: data-value is the final figure, data-decimals how many decimals to show.
The final value is written in the HTML so screen readers and no-JS visitors get it. -->
<dl class="bkc-grid">
<div class="bkc-stat"><dt><span class="bkc-bar" aria-hidden="true"></span>Loaves baked this year</dt><dd data-value="18240" data-decimals="0"><span class="bkc-sr">18,240</span><span class="bkc-num" aria-hidden="true"><span class="bkc-size">18,240</span><span class="bkc-live">18,240</span></span></dd></div>
<div class="bkc-stat"><dt><span class="bkc-bar" aria-hidden="true"></span>Regulars on the stamp card</dt><dd data-value="1312" data-decimals="0"><span class="bkc-sr">1,312</span><span class="bkc-num" aria-hidden="true"><span class="bkc-size">1,312</span><span class="bkc-live">1,312</span></span></dd></div>
<div class="bkc-stat"><dt><span class="bkc-bar" aria-hidden="true"></span>Average review, out of 5</dt><dd data-value="4.9" data-decimals="1"><span class="bkc-sr">4.9</span><span class="bkc-num" aria-hidden="true"><span class="bkc-size">4.9</span><span class="bkc-live">4.9</span></span></dd></div>
<div class="bkc-stat"><dt><span class="bkc-bar" aria-hidden="true"></span>Years on Fennel Street</dt><dd data-value="12" data-decimals="0"><span class="bkc-sr">12</span><span class="bkc-num" aria-hidden="true"><span class="bkc-size">12</span><span class="bkc-live">12</span></span></dd></div>
</dl>
<p class="bkc-note">Sample numbers for a made-up bakery, shown to demonstrate the counter.</p>
</div>
</section>
<style>
/* Values worth changing:
--bkc-paper: background colour --bkc-ink: number colour
--bkc-accent: the rule that grows duration and stagger live in the script */
.bkc-root { --bkc-paper: #f4ede1; --bkc-ink: #2a1f16; --bkc-soft: #5e4d3c; --bkc-accent: #b4532a;
position: relative; overflow: hidden; background: var(--bkc-paper); color: var(--bkc-ink);
padding: 64px 20px; font-family: ui-sans-serif, system-ui, sans-serif; }
.bkc-root::before { content: ""; position: absolute; inset: 0; pointer-events: none; opacity: .35; mix-blend-mode: multiply;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 .45 0 0 0 0 .36 0 0 0 0 .25 0 0 0 .22 0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E"); }
.bkc-inner { position: relative; max-width: 1024px; margin: 0 auto; }
.bkc-eyebrow { margin: 0; font-size: 14px; font-weight: 500; letter-spacing: .18em; text-transform: uppercase; color: #8a4a24; }
.bkc-title { margin: 12px 0 0; max-width: 36rem; font-family: ui-serif, Georgia, serif; font-weight: 400; font-size: clamp(30px, 4vw, 36px); line-height: 1.2; }
.bkc-grid { margin: 48px 0 0; display: grid; grid-template-columns: repeat(2, 1fr); gap: 40px 24px; }
@media (min-width: 1024px) { .bkc-grid { grid-template-columns: repeat(4, 1fr); } }
.bkc-stat { position: relative; display: flex; flex-direction: column; padding-top: 20px; border-top: 1px solid rgba(42,31,22,.15); }
.bkc-stat dt { order: 2; margin-top: 8px; font-size: 14px; line-height: 1.4; color: var(--bkc-soft); }
.bkc-stat dd { order: 1; margin: 0; font-family: ui-serif, Georgia, serif; font-size: clamp(36px, 5vw, 48px); line-height: 1; font-variant-numeric: tabular-nums lining-nums; }
.bkc-bar { position: absolute; left: 0; top: -1px; width: 100%; height: 2px; background: var(--bkc-accent); transform-origin: left; }
.bkc-num { display: grid; }
.bkc-size, .bkc-live { grid-area: 1 / 1; }
.bkc-size { visibility: hidden; }
.bkc-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
.bkc-note { margin: 48px 0 0; font-size: 12px; color: var(--bkc-soft); }
@media (min-width: 640px) { .bkc-root { padding: 80px 40px; } }
@media (prefers-reduced-motion: reduce) { .bkc-bar { transform: none !important; } }
</style>
<script>
(function () {
var DURATION = 1400; // ms each number takes to land
var STAGGER = 140; // ms between numbers starting
var root = document.currentScript && document.currentScript.parentNode.querySelector(".bkc-root") || document.querySelector(".bkc-root");
if (!root || !("IntersectionObserver" in window)) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return; // keep the final numbers
var stats = Array.prototype.map.call(root.querySelectorAll(".bkc-stat"), function (el) {
var dd = el.querySelector("dd");
var decimals = Number(dd.dataset.decimals || 0);
return {
value: Number(dd.dataset.value), decimals: decimals,
live: el.querySelector(".bkc-live"), bar: el.querySelector(".bkc-bar"),
fmt: new Intl.NumberFormat(undefined, { minimumFractionDigits: decimals, maximumFractionDigits: decimals })
};
});
function ease(t) { return 1 - Math.pow(1 - t, 3); }
function paint(elapsed) {
var done = true;
stats.forEach(function (s, i) {
var p = Math.min(1, Math.max(0, (elapsed - i * STAGGER) / DURATION));
if (p < 1) done = false;
var v = s.value * ease(p);
s.live.textContent = s.fmt.format(s.decimals ? v : Math.round(v));
s.bar.style.transform = "scaleX(" + ease(p) + ")";
});
return done;
}
paint(0);
var raf = 0, start = 0, elapsed = 0, visible = false;
function tick(now) {
if (!start) start = now - elapsed;
elapsed = now - start;
if (!paint(elapsed) && visible) raf = requestAnimationFrame(tick);
}
var io = new IntersectionObserver(function (entries) {
visible = entries[0].isIntersecting;
cancelAnimationFrame(raf);
start = 0;
if (visible) raf = requestAnimationFrame(tick);
}, { threshold: 0.25 });
io.observe(root);
})();
</script>
With reduced motion: The numbers and accent rules appear already finished, with no counting at all.
Running cost: One short requestAnimationFrame run that rewrites four text nodes and one transform each, then stops for good.
A countdown for a pottery studio's kiln opening, set in large serif numerals on a soft dawn gradient. On arrival the days, hours and minutes rise from zero to the real time left, then the figures only change once a minute, with a fine copper line creeping under the minutes. No seconds, no flashing, nothing urgent.
<section class="cdn-root" data-target="2026-12-05T10:00:00" data-event="Winter kiln opening">
<div class="cdn-glow" aria-hidden="true"></div>
<div class="cdn-inner">
<p class="cdn-eyebrow">Linden Pottery</p>
<h2 class="cdn-title">Winter kiln opening</h2>
<p class="cdn-when"><time class="cdn-time" datetime="2026-12-05T10:00:00">Saturday, December 5, 10:00 AM</time>, at the studio</p>
<!-- The sentence below is what screen readers and no-JS visitors get; the script keeps it current. -->
<p class="cdn-sr">Winter kiln opening on Saturday, December 5 at 10:00 AM.</p>
<div class="cdn-row" aria-hidden="true">
<div class="cdn-unit"><span class="cdn-num"><span class="cdn-size">000</span><span class="cdn-live" data-unit="days"> </span></span><span class="cdn-label">Days</span></div>
<span class="cdn-colon">:</span>
<div class="cdn-unit"><span class="cdn-num"><span class="cdn-size">00</span><span class="cdn-live" data-unit="hours"> </span></span><span class="cdn-label">Hours</span></div>
<span class="cdn-colon">:</span>
<div class="cdn-unit"><span class="cdn-num"><span class="cdn-size">00</span><span class="cdn-live" data-unit="minutes"> </span></span><span class="cdn-label">Minutes</span><span class="cdn-track"><span class="cdn-bar"></span></span></div>
</div>
<p class="cdn-note">The figures change once a minute and only a hairline creeps under the minutes, so it can sit beside your booking button without nagging. The studio and date are made up for this demo.</p>
</div>
</section>
<style>
/* Values worth changing:
data-target on .cdn-root: the date and time to count down to (local time)
--cdn-ink: numerals --cdn-accent: the minute hairline and eyebrow
the background gradient on .cdn-root sets the dawn colours */
.cdn-root { --cdn-ink: #3a2a22; --cdn-soft: #5d463a; --cdn-accent: #94512f;
position: relative; overflow: hidden; padding: 64px 20px; color: var(--cdn-ink); text-align: center;
background: linear-gradient(170deg, #f8ecdd 0%, #f4dccb 55%, #eecdbd 100%); font-family: ui-sans-serif, system-ui, sans-serif; }
.cdn-glow { position: absolute; top: -160px; left: 50%; width: 720px; height: 420px; transform: translateX(-50%); border-radius: 50%; pointer-events: none;
background: radial-gradient(closest-side, rgba(255,246,228,.9), rgba(255,246,228,0)); }
.cdn-inner { position: relative; max-width: 768px; margin: 0 auto; }
.cdn-eyebrow { margin: 0; font-size: 14px; font-weight: 500; letter-spacing: .2em; text-transform: uppercase; color: var(--cdn-accent); }
.cdn-title { margin: 12px 0 0; font-family: ui-serif, Georgia, serif; font-weight: 400; font-size: clamp(30px, 4vw, 36px); line-height: 1.2; }
.cdn-when { margin: 12px 0 0; font-size: 16px; color: var(--cdn-soft); }
.cdn-row { margin-top: 40px; display: flex; justify-content: center; align-items: flex-start; gap: 12px; }
.cdn-unit { display: flex; flex-direction: column; align-items: center; }
.cdn-num { display: grid; font-weight: 200; letter-spacing: -.025em; font-size: clamp(3rem, 13vw, 6.5rem); line-height: 1; font-variant-numeric: tabular-nums lining-nums; }
.cdn-size, .cdn-live { grid-area: 1 / 1; text-align: center; }
.cdn-size { visibility: hidden; }
.cdn-colon { padding-top: 8px; font-weight: 200; font-size: 36px; color: rgba(58,42,34,.3); }
.cdn-label { margin-top: 12px; font-size: 12px; font-weight: 500; letter-spacing: .2em; text-transform: uppercase; color: #6b5245; }
.cdn-track { margin-top: 12px; width: 100%; height: 1px; overflow: hidden; background: rgba(58,42,34,.15); }
.cdn-bar { display: block; height: 100%; background: var(--cdn-accent); transform-origin: left; transform: scaleX(0);
animation: cdn-fill 60s linear infinite; }
.cdn-root[data-paused="true"] .cdn-bar { animation-play-state: paused; }
@keyframes cdn-fill { from { transform: scaleX(0); } to { transform: scaleX(1); } }
.cdn-note { margin: 40px auto 0; max-width: 28rem; font-size: 14px; line-height: 1.6; color: var(--cdn-soft); }
.cdn-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
@media (min-width: 640px) { .cdn-root { padding: 80px 20px; } .cdn-row { gap: 24px; } .cdn-colon { padding-top: 20px; font-size: 60px; } }
@media (prefers-reduced-motion: reduce) { .cdn-bar { animation: none; } }
</style>
<script>
(function () {
var INTRO_MS = 1300; // first count-up from zero
var root = document.querySelector(".cdn-root");
if (!root) return;
var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
var plain = new Intl.NumberFormat();
var two = new Intl.NumberFormat(undefined, { minimumIntegerDigits: 2 });
var dateLabel = new Intl.DateTimeFormat(undefined, { weekday: "long", day: "numeric", month: "long", hour: "numeric", minute: "2-digit" });
var live = {};
Array.prototype.forEach.call(root.querySelectorAll(".cdn-live"), function (el) { live[el.dataset.unit] = el; });
var sr = root.querySelector(".cdn-sr"), timeEl = root.querySelector(".cdn-time"), bar = root.querySelector(".cdn-bar");
var event = root.dataset.event;
function target(now) { var d = new Date(root.dataset.target); while (d.getTime() < now) d.setFullYear(d.getFullYear() + 1); return d; }
function left(now) {
var ms = Math.max(0, target(now).getTime() - now), mins = Math.floor(ms / 60000);
return { days: Math.floor(mins / 1440), hours: Math.floor((mins % 1440) / 60), minutes: mins % 60, sec: (ms % 60000) / 1000 };
}
function paint(e) {
var now = Date.now(), r = left(now), d = target(now);
live.days.textContent = plain.format(Math.round(r.days * e));
live.hours.textContent = two.format(Math.round(r.hours * e));
live.minutes.textContent = two.format(Math.round(r.minutes * e));
root.querySelector(".cdn-size").textContent = plain.format(r.days);
timeEl.setAttribute("datetime", d.toISOString());
timeEl.textContent = dateLabel.format(d);
sr.textContent = event + " in " + plain.format(r.days) + " days, " + r.hours + " hours and " + r.minutes + " minutes.";
bar.style.animationDelay = "-" + r.sec.toFixed(2) + "s";
}
paint(1);
if (!("IntersectionObserver" in window)) return;
var raf = 0, timer = 0, started = 0, introDone = reduce;
if (!reduce) paint(0);
function intro(t) {
if (!started) started = t;
var p = Math.min(1, (t - started) / INTRO_MS);
paint(1 - Math.pow(1 - p, 3));
if (p < 1) raf = requestAnimationFrame(intro); else introDone = true;
}
function schedule() {
if (introDone) paint(1);
timer = setTimeout(schedule, 60000 - (Date.now() % 60000) + 20);
}
var io = new IntersectionObserver(function (entries) {
var on = entries[0].isIntersecting;
clearTimeout(timer); cancelAnimationFrame(raf);
root.dataset.paused = on ? "false" : "true";
if (!on) return;
schedule();
if (!introDone) { started = 0; raf = requestAnimationFrame(intro); }
}, { threshold: 0.3 });
io.observe(root);
})();
</script>
With reduced motion: The real time left appears at once, still updating each minute, and the hairline stays empty.
Running cost: One timeout per minute and a single 60 second transform animation on a one pixel line.
A paper statement slip for a small soap maker on a sea glass tint. Three line amounts and their sum tick up together in monospace figures, cents spinning quickest and settling last, while the big total lifts its cents into a smaller raised pair. Fits revenue recaps, donation totals and fundraising pages.
<section class="cur-root" data-currency="USD">
<div class="cur-inner">
<div>
<p class="cur-eyebrow">Saltmarsh Soap Co.</p>
<h2 class="cur-title">October takings, to the cent.</h2>
<p class="cur-copy">The cents run fastest and settle last, so the total feels counted rather than faded in. Sample figures for a made-up shop.</p>
</div>
<div class="cur-slip">
<div class="cur-head"><span>Statement</span><span>1 to 31 Oct</span></div>
<!-- data-amount holds each final figure; the readable amount is already in .cur-sr for screen readers and no-JS. -->
<ul class="cur-lines">
<li><span class="cur-label">Bars and liquid soap</span><span class="cur-amt" data-amount="6412.80"><span class="cur-sr">$6,412.80</span><span class="cur-num" aria-hidden="true"><span class="cur-size">$6,412.80</span><span class="cur-live">$6,412.80</span></span></span></li>
<li><span class="cur-label">Gift boxes</span><span class="cur-amt" data-amount="3958.25"><span class="cur-sr">$3,958.25</span><span class="cur-num" aria-hidden="true"><span class="cur-size">$3,958.25</span><span class="cur-live">$3,958.25</span></span></span></li>
<li><span class="cur-label">Wholesale to two shops</span><span class="cur-amt" data-amount="2475.30"><span class="cur-sr">$2,475.30</span><span class="cur-num" aria-hidden="true"><span class="cur-size">$2,475.30</span><span class="cur-live">$2,475.30</span></span></span></li>
</ul>
<div class="cur-total">
<p class="cur-total-label">Total received</p>
<p class="cur-big"><span class="cur-amt cur-sum" data-amount="12846.35"><span class="cur-sr">$12,846.35</span><span class="cur-num" aria-hidden="true"><span class="cur-size">$12,846.35</span><span class="cur-live">$12,846.35</span></span></span></p>
</div>
</div>
</div>
</section>
<style>
/* Values worth changing:
--cur-bg: page tint --cur-ink: figures --cur-accent: the raised cents
data-currency on .cur-root picks the currency; DURATION in the script sets the pace */
.cur-root { --cur-bg: #dcebe4; --cur-ink: #163a31; --cur-soft: #2c4f45; --cur-accent: #2f6b5b; --cur-paper: #fbfaf6;
background: var(--cur-bg); color: var(--cur-ink); padding: 64px 20px; font-family: ui-sans-serif, system-ui, sans-serif; }
.cur-inner { max-width: 1024px; margin: 0 auto; display: grid; gap: 40px; align-items: center; }
@media (min-width: 768px) { .cur-inner { grid-template-columns: 1fr minmax(0, 420px); } .cur-root { padding: 80px 20px; } }
.cur-eyebrow { margin: 0; font-size: 14px; font-weight: 500; letter-spacing: .18em; text-transform: uppercase; color: var(--cur-accent); }
.cur-title { margin: 12px 0 0; font-size: clamp(30px, 4vw, 36px); font-weight: 600; line-height: 1.2; letter-spacing: -.01em; }
.cur-copy { margin: 16px 0 0; max-width: 24rem; font-size: 16px; line-height: 1.6; color: var(--cur-soft); }
.cur-slip { position: relative; background: var(--cur-paper); border-radius: 8px 8px 0 0; padding: 28px 24px 40px;
box-shadow: 0 30px 60px -30px rgba(22,58,49,.45), 0 2px 6px rgba(22,58,49,.08); }
.cur-slip::after { content: ""; position: absolute; left: 0; right: 0; bottom: -12px; height: 12px;
background: radial-gradient(circle at 8px 0, var(--cur-paper) 7px, transparent 7.5px) -8px 0 / 16px 12px repeat-x; }
.cur-head { display: flex; justify-content: space-between; font-size: 12px; letter-spacing: .16em; text-transform: uppercase; color: #4f6b63; }
.cur-lines { list-style: none; margin: 20px 0 0; padding: 0; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 14px; }
.cur-lines li { display: flex; justify-content: space-between; align-items: baseline; gap: 16px; padding: 12px 0; border-top: 1px dashed rgba(22,58,49,.2); }
.cur-lines li:first-child { border-top: 0; }
.cur-label { font-family: ui-sans-serif, system-ui, sans-serif; color: var(--cur-soft); }
.cur-total { margin-top: 16px; padding-top: 16px; border-top: 2px solid var(--cur-ink); }
.cur-total-label { margin: 0; font-size: 12px; letter-spacing: .16em; text-transform: uppercase; color: #4f6b63; }
.cur-big { margin: 4px 0 0; text-align: right; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-weight: 600; line-height: 1;
letter-spacing: -.02em; font-size: clamp(2.2rem, 8vw, 3.25rem); }
.cur-num { display: inline-grid; justify-items: end; font-variant-numeric: tabular-nums; }
.cur-size, .cur-live { grid-area: 1 / 1; }
.cur-size { visibility: hidden; }
.cur-cents { font-size: .55em; vertical-align: .62em; color: var(--cur-accent); }
.cur-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
@media (min-width: 640px) { .cur-slip { padding: 28px 32px 40px; } }
@media (prefers-reduced-motion: reduce) { .cur-live { transition: none; } }
</style>
<script>
(function () {
var DURATION = 1700; // ms for the total to land
var root = document.querySelector(".cur-root");
if (!root) return;
var money = new Intl.NumberFormat("en-US", { style: "currency", currency: root.dataset.currency || "USD" });
var items = Array.prototype.map.call(root.querySelectorAll(".cur-amt"), function (el) {
return { value: Number(el.dataset.amount), live: el.querySelector(".cur-live"), big: el.classList.contains("cur-sum") };
});
function draw(item, v) {
var parts = money.formatToParts(Math.round(v * 100) / 100);
while (item.live.firstChild) item.live.removeChild(item.live.firstChild);
parts.forEach(function (p) {
if (item.big && p.type === "decimal") return;
var s = document.createElement("span");
if (item.big && p.type === "fraction") s.className = "cur-cents";
s.textContent = p.value;
item.live.appendChild(s);
});
}
function paint(t) { var e = 1 - Math.pow(1 - t, 3); items.forEach(function (it) { draw(it, it.value * e); }); }
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches || !("IntersectionObserver" in window)) { paint(1); return; }
paint(0);
var raf = 0, start = 0, elapsed = 0, visible = false;
function tick(now) {
if (!start) start = now - elapsed;
elapsed = now - start;
var t = Math.min(1, elapsed / DURATION);
paint(t);
if (t < 1 && visible) raf = requestAnimationFrame(tick);
}
var io = new IntersectionObserver(function (entries) {
visible = entries[0].isIntersecting;
cancelAnimationFrame(raf);
start = 0;
if (visible) raf = requestAnimationFrame(tick);
}, { threshold: 0.3 });
io.observe(root);
})();
</script>
With reduced motion: The slip shows every amount and the total already settled to the cent.
Running cost: Four short text rewrites per frame for under two seconds, driven by one requestAnimationFrame loop.
One very large figure for a window cleaning firm, slate ink on near white. While it counts, each digit is blurred by how fast it is changing: the ones place smears first, the tens a little less, and the leading digits stay crisp, then everything sharpens as the count eases to rest. Made for a single proud number in a hero.
<section class="hnb-root" data-value="23418">
<div class="hnb-inner">
<p class="hnb-eyebrow">Tidewell Window Cleaning</p>
<!-- The final figure is in the heading for screen readers and no-JS; the script only redraws the aria-hidden copy. -->
<h2 class="hnb-head">
<span class="hnb-sr">23,418 windows cleaned since 2019</span>
<span class="hnb-num" aria-hidden="true"><span class="hnb-size">23,418</span><span class="hnb-live">23,418</span></span>
</h2>
<p class="hnb-caption" aria-hidden="true">windows cleaned since 2019</p>
<span class="hnb-rule" aria-hidden="true"></span>
<p class="hnb-copy">Fast digits soften while they spin and sharpen as they slow, the way a real dial looks in motion. A sample figure for a made-up firm.</p>
<a class="hnb-btn" href="#">Book a clean</a>
</div>
</section>
<style>
/* Values worth changing:
--hnb-ink: numeral colour --hnb-wash: the soft glow behind the figure
data-value on .hnb-root: the number DURATION and MAX_BLUR in the script */
.hnb-root { --hnb-ink: #0f1b2a; --hnb-soft: #33475c; --hnb-wash: rgba(188,207,226,.45);
position: relative; overflow: hidden; background: #f7f9fb; color: var(--hnb-ink); padding: 64px 20px; text-align: center;
font-family: ui-sans-serif, system-ui, sans-serif; }
.hnb-root::before { content: ""; position: absolute; inset: 0; pointer-events: none;
background: radial-gradient(70% 55% at 50% 45%, var(--hnb-wash), rgba(247,249,251,0) 70%); }
.hnb-inner { position: relative; max-width: 896px; margin: 0 auto; display: flex; flex-direction: column; align-items: center; }
.hnb-eyebrow { margin: 0; font-size: 14px; font-weight: 500; letter-spacing: .2em; text-transform: uppercase; color: #40566e; }
.hnb-head { margin: 24px 0 0; font-weight: 600; line-height: 1; letter-spacing: -.04em; }
.hnb-num { display: grid; justify-items: center; font-size: clamp(4.5rem, 19vw, 11rem); font-variant-numeric: tabular-nums; }
.hnb-size, .hnb-live { grid-area: 1 / 1; }
.hnb-size { visibility: hidden; }
.hnb-live { display: flex; }
.hnb-d { display: inline-block; }
.hnb-sep { color: #8aa0b6; }
.hnb-caption { margin: 16px 0 0; font-size: clamp(18px, 2vw, 20px); color: var(--hnb-soft); }
.hnb-rule { margin-top: 32px; width: 96px; height: 1px; background: rgba(15,27,42,.2); }
.hnb-copy { margin: 32px 0 0; max-width: 28rem; font-size: 16px; line-height: 1.6; color: var(--hnb-soft); }
.hnb-btn { margin-top: 32px; display: inline-flex; align-items: center; height: 44px; padding: 0 24px; border-radius: 999px;
background: var(--hnb-ink); color: #fff; font-size: 14px; font-weight: 500; text-decoration: none; }
.hnb-btn:hover { background: #22344a; }
.hnb-btn:focus-visible { outline: 2px solid var(--hnb-ink); outline-offset: 3px; }
.hnb-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
@media (min-width: 640px) { .hnb-root { padding: 96px 20px; } }
@media (prefers-reduced-motion: reduce) { .hnb-d { filter: none !important; transform: none !important; opacity: 1 !important; } }
</style>
<script>
(function () {
var DURATION = 2200; // ms for the count
var MAX_BLUR = 6; // px of blur on a digit spinning too fast to read
var root = document.querySelector(".hnb-root");
if (!root) return;
var target = Number(root.dataset.value);
var live = root.querySelector(".hnb-live");
var fmt = new Intl.NumberFormat();
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches || !("IntersectionObserver" in window)) return;
function draw(t) {
var v = Math.round(target * (1 - Math.pow(1 - t, 4)));
var speed = t >= 1 ? 0 : target * 4 * Math.pow(1 - t, 3) * 16 / DURATION; // units per frame
var chars = fmt.format(v).split("");
var place = chars.filter(function (c) { return /\d/.test(c); }).length;
while (live.firstChild) live.removeChild(live.firstChild);
chars.forEach(function (c) {
var s = document.createElement("span");
s.textContent = c;
if (!/\d/.test(c)) { s.className = "hnb-sep"; live.appendChild(s); return; }
place--;
var blur = Math.min(MAX_BLUR, Math.max(0, (speed / Math.pow(10, place) - 0.15) * 5));
s.className = "hnb-d";
if (blur > .05) { s.style.filter = "blur(" + blur.toFixed(2) + "px)"; s.style.transform = "scaleY(" + (1 + blur * .02) + ")"; }
s.style.opacity = 1 - blur * .06;
live.appendChild(s);
});
}
draw(0);
var raf = 0, start = 0, elapsed = 0, visible = false;
function tick(now) {
if (!start) start = now - elapsed;
elapsed = now - start;
var t = Math.min(1, elapsed / DURATION);
draw(t);
if (t < 1 && visible) raf = requestAnimationFrame(tick);
}
var io = new IntersectionObserver(function (entries) {
visible = entries[0].isIntersecting;
cancelAnimationFrame(raf);
start = 0;
if (visible) raf = requestAnimationFrame(tick);
}, { threshold: 0.3 });
io.observe(root);
})();
</script>
With reduced motion: The full figure shows sharp and settled, with no blur and no counting.
Running cost: Rebuilds five or six digit spans per frame for about two seconds; the blur filter is applied to those spans only.
A small dashboard card for a refill shop that keeps a running tally of jars filled today. A timer stands in for real events: every few seconds it adds one to three, only the digits that changed roll up into place, and a faint +2 chip rises and fades beside the figure. The card says plainly that the numbers are a demo.
<section class="lvc-root" data-start="1284">
<div class="lvc-card">
<div class="lvc-top">
<p class="lvc-shop">Bramble Refill Shop</p>
<span class="lvc-badge"><span class="lvc-dot" aria-hidden="true"></span>Demo</span>
</div>
<h2 class="lvc-title">Jars refilled today</h2>
<div class="lvc-row">
<!-- The figure is written here for screen readers and no-JS; the script redraws only the aria-hidden copy. -->
<p class="lvc-num"><span class="lvc-sr">1,284 jars, a sample figure</span><span class="lvc-live" aria-hidden="true">1,284</span></p>
<span class="lvc-chip" aria-hidden="true"></span>
</div>
<p class="lvc-note">Demo counter with sample numbers: a timer adds one to three every few seconds. In your app, call the same update when a real event arrives.</p>
</div>
</section>
<style>
/* Values worth changing:
--lvc-bg: page colour --lvc-accent: badge, dot and the +n chip
data-start on .lvc-root: the opening figure MIN_GAP / MAX_GAP in the script: how often it ticks */
.lvc-root { --lvc-bg: #0d1624; --lvc-ink: #e8eef4; --lvc-soft: #a8bccc; --lvc-accent: #7fd1c3;
position: relative; overflow: hidden; background: var(--lvc-bg); color: var(--lvc-ink); padding: 64px 20px; font-family: ui-sans-serif, system-ui, sans-serif; }
.lvc-root::before { content: ""; position: absolute; inset: 0; pointer-events: none;
background: radial-gradient(70% 60% at 50% 110%, rgba(84,170,160,.22), rgba(13,22,36,0) 70%); }
.lvc-card { position: relative; max-width: 576px; margin: 0 auto; padding: 24px; border-radius: 16px; border: 1px solid rgba(255,255,255,.1); background: rgba(255,255,255,.03); }
.lvc-top { display: flex; justify-content: space-between; align-items: center; gap: 16px; }
.lvc-shop { margin: 0; font-size: 14px; font-weight: 500; color: var(--lvc-soft); }
.lvc-badge { display: inline-flex; align-items: center; gap: 8px; padding: 4px 12px; border-radius: 999px; border: 1px solid rgba(127,209,195,.3);
font-size: 12px; font-weight: 500; color: #9fe0d4; }
.lvc-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--lvc-accent); animation: lvc-dot 2.4s ease-in-out infinite; }
.lvc-root[data-paused="true"] .lvc-dot { animation-play-state: paused; }
.lvc-title { margin: 24px 0 0; font-size: 16px; font-weight: 500; color: #c9d6e2; }
.lvc-row { margin-top: 8px; display: flex; align-items: flex-end; gap: 12px; }
.lvc-num { margin: 0; font-size: clamp(60px, 9vw, 72px); font-weight: 600; line-height: 1; letter-spacing: -.02em; font-variant-numeric: tabular-nums; }
.lvc-live { display: inline-flex; overflow: hidden; padding: 4px 0; min-width: 3.1em; }
.lvc-digit { display: inline-block; animation: lvc-in .42s cubic-bezier(.2,.8,.2,1) both; }
.lvc-sep { color: #6f8597; }
.lvc-chip { margin-bottom: 8px; padding: 2px 8px; border-radius: 6px; background: rgba(127,209,195,.15); color: #9fe0d4;
font-size: 14px; font-weight: 500; font-variant-numeric: tabular-nums; opacity: 0; }
.lvc-chip.lvc-show { animation: lvc-chip 1.6s ease-out both; }
.lvc-note { margin: 24px 0 0; padding-top: 20px; border-top: 1px solid rgba(255,255,255,.1); font-size: 14px; line-height: 1.6; color: var(--lvc-soft); }
.lvc-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
@keyframes lvc-in { from { transform: translateY(55%); opacity: 0; filter: blur(2px); } to { transform: none; opacity: 1; filter: none; } }
@keyframes lvc-chip { 0% { transform: translateY(6px); opacity: 0; } 15% { transform: none; opacity: 1; } 75% { opacity: 1; } 100% { transform: translateY(-8px); opacity: 0; } }
@keyframes lvc-dot { 0%, 100% { opacity: .35; } 50% { opacity: 1; } }
@media (min-width: 640px) { .lvc-root { padding: 80px 20px; } .lvc-card { padding: 32px; } }
@media (prefers-reduced-motion: reduce) { .lvc-digit, .lvc-chip.lvc-show, .lvc-dot { animation: none; } }
</style>
<script>
(function () {
var INTRO_FROM = 1180; // the first count runs up from here
var INTRO_MS = 1000;
var MIN_GAP = 1800, MAX_GAP = 4200; // ms between demo ticks
var root = document.querySelector(".lvc-root");
if (!root) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches || !("IntersectionObserver" in window)) return; // stays on the written figure
var fmt = new Intl.NumberFormat();
var live = root.querySelector(".lvc-live"), sr = root.querySelector(".lvc-sr"), chip = root.querySelector(".lvc-chip");
var value = Number(root.dataset.start), shown = "";
function draw(v) {
var text = fmt.format(v);
var old = shown; shown = text;
var kids = live.children;
// Rebuild only when the length changes; otherwise swap the digits that changed so just they roll in.
if (!old || old.length !== text.length || kids.length !== text.length) {
while (live.firstChild) live.removeChild(live.firstChild);
text.split("").forEach(function (c) { var s = document.createElement("span"); s.className = /\d/.test(c) ? "lvc-digit" : "lvc-sep"; s.textContent = c; live.appendChild(s); });
return;
}
text.split("").forEach(function (c, i) {
if (old[i] === c) return;
var s = document.createElement("span"); s.className = "lvc-digit"; s.textContent = c;
live.replaceChild(s, kids[i]);
});
}
function bump() {
var by = 1 + Math.floor(Math.random() * 3);
value += by;
draw(value);
sr.textContent = fmt.format(value) + " jars, a sample figure";
chip.textContent = "+" + by;
chip.classList.remove("lvc-show"); void chip.offsetWidth; chip.classList.add("lvc-show");
queue(MIN_GAP + Math.random() * (MAX_GAP - MIN_GAP));
}
var raf = 0, timer = 0, started = 0, introDone = false;
function queue(ms) { clearTimeout(timer); timer = setTimeout(bump, ms); }
function intro(t) {
if (!started) started = t;
var p = Math.min(1, (t - started) / INTRO_MS);
draw(Math.round(INTRO_FROM + (value - INTRO_FROM) * (1 - Math.pow(1 - p, 3))));
if (p < 1) raf = requestAnimationFrame(intro); else { introDone = true; queue(600); }
}
draw(INTRO_FROM);
var io = new IntersectionObserver(function (entries) {
var on = entries[0].isIntersecting;
root.dataset.paused = on ? "false" : "true";
clearTimeout(timer); cancelAnimationFrame(raf);
if (!on) return;
if (introDone) queue(MIN_GAP); else { started = 0; raf = requestAnimationFrame(intro); }
}, { threshold: 0.3 });
io.observe(root);
})();
</script>
With reduced motion: The card shows the opening figure without counting, and the demo timer never starts.
Running cost: A timeout every few seconds swaps one or two digit spans; the only loop is a slow opacity pulse on a dot.
A large ring for a garden care firm's on-time rate, flanked by two smaller ones, all on deep forest green. The arc, the fifty tick marks around it and the percentage in the middle are drawn from one value on each frame, so the number never runs ahead of the fill. Good for service promises and survey results.
<section class="pcr-root">
<svg width="0" height="0" style="position:absolute" aria-hidden="true"><defs><linearGradient id="pcr-grad-v" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#a9c97a"/><stop offset="1" stop-color="#e6eeb8"/></linearGradient></defs></svg>
<div class="pcr-inner">
<!-- data-value on each .pcr-item is the final percentage. The readable figure is already in .pcr-sr. -->
<figure class="pcr-main pcr-item" data-value="94" data-big="1">
<figcaption class="pcr-cap">
<span class="pcr-big"><span class="pcr-sr">94%</span><span class="pcr-num" aria-hidden="true"><span class="pcr-size">94%</span><span class="pcr-live">94%</span></span></span>
<span class="pcr-label">Visits done on the booked day</span>
</figcaption>
</figure>
<div>
<p class="pcr-eyebrow">Fernway Garden Care</p>
<h2 class="pcr-title">Turning up when we said we would.</h2>
<ul class="pcr-list">
<li class="pcr-item" data-value="81"><span class="pcr-small"></span><span><span class="pcr-mid"><span class="pcr-sr">81%</span><span class="pcr-num" aria-hidden="true"><span class="pcr-size">81%</span><span class="pcr-live">81%</span></span></span><span class="pcr-label">Clients who rebook within a year</span></span></li>
<li class="pcr-item" data-value="67"><span class="pcr-small"></span><span><span class="pcr-mid"><span class="pcr-sr">67%</span><span class="pcr-num" aria-hidden="true"><span class="pcr-size">67%</span><span class="pcr-live">67%</span></span></span><span class="pcr-label">Green waste composted on site</span></span></li>
</ul>
<p class="pcr-note">Sample percentages for a made-up business. Ring and number are drawn from the same value each frame.</p>
</div>
</div>
</section>
<style>
/* Values worth changing:
--pcr-bg: background --pcr-track: unfilled ring gradient stops in #pcr-grad-v: the filled ring
DURATION, STAGGER and TICKS sit at the top of the script */
.pcr-root { --pcr-bg: #13261d; --pcr-ink: #eef3e4; --pcr-soft: #c3cfb3; --pcr-track: rgba(233,240,220,.12);
position: relative; overflow: hidden; background: var(--pcr-bg); color: var(--pcr-ink); padding: 64px 20px; font-family: ui-sans-serif, system-ui, sans-serif; }
.pcr-root::before { content: ""; position: absolute; inset: 0; pointer-events: none;
background: radial-gradient(60% 70% at 30% 40%, rgba(169,201,122,.12), rgba(0,0,0,0) 70%); }
.pcr-inner { position: relative; max-width: 1024px; margin: 0 auto; display: grid; gap: 48px; align-items: center; }
@media (min-width: 768px) { .pcr-inner { grid-template-columns: auto 1fr; } .pcr-root { padding: 80px 20px; } }
.pcr-main { position: relative; margin: 0 auto; width: min(72vw, 280px); height: min(72vw, 280px); }
.pcr-main svg, .pcr-small svg { display: block; width: 100%; height: 100%; transform: rotate(-90deg); }
.pcr-cap { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; }
.pcr-big { font-size: clamp(60px, 7vw, 72px); font-weight: 300; letter-spacing: -.02em; line-height: 1; }
.pcr-num { display: grid; font-variant-numeric: tabular-nums; }
.pcr-big .pcr-num { justify-items: center; }
.pcr-size, .pcr-live { grid-area: 1 / 1; }
.pcr-size { visibility: hidden; }
.pcr-label { display: block; font-size: 14px; line-height: 1.35; color: var(--pcr-soft); }
.pcr-main .pcr-label { margin-top: 8px; max-width: 11rem; }
.pcr-eyebrow { margin: 0; font-size: 14px; font-weight: 500; letter-spacing: .18em; text-transform: uppercase; color: #b8d38a; }
.pcr-title { margin: 12px 0 0; max-width: 28rem; font-size: clamp(30px, 4vw, 36px); font-weight: 600; line-height: 1.2; letter-spacing: -.01em; }
.pcr-list { list-style: none; margin: 32px 0 0; padding: 0; display: grid; gap: 20px; }
@media (min-width: 640px) { .pcr-list { grid-template-columns: 1fr 1fr; } }
.pcr-list li { display: flex; align-items: center; gap: 16px; }
.pcr-small { width: 64px; height: 64px; flex-shrink: 0; }
.pcr-mid { display: block; font-size: 24px; font-weight: 500; }
.pcr-note { margin: 32px 0 0; font-size: 12px; color: #a9b79a; }
.pcr-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
@media (prefers-reduced-motion: reduce) { .pcr-root circle { transition: none; } }
</style>
<script>
(function () {
var DURATION = 1600; // ms for a ring to fill
var STAGGER = 180; // ms between rings starting
var TICKS = 50; // marks around the big ring
var root = document.querySelector(".pcr-root");
if (!root) return;
var NS = "http://www.w3.org/2000/svg";
var pct = new Intl.NumberFormat(undefined, { style: "percent", maximumFractionDigits: 0 });
var R = 42, C = 2 * Math.PI * R;
function el(name, attrs) { var n = document.createElementNS(NS, name); for (var k in attrs) n.setAttribute(k, attrs[k]); return n; }
var items = Array.prototype.map.call(root.querySelectorAll(".pcr-item"), function (item) {
var big = item.dataset.big === "1";
var svg = el("svg", { viewBox: "0 0 100 100", "aria-hidden": "true" });
var w = big ? 5 : 7;
svg.appendChild(el("circle", { cx: 50, cy: 50, r: R, fill: "none", stroke: "rgba(233,240,220,.12)", "stroke-width": w }));
var arc = el("circle", { cx: 50, cy: 50, r: R, fill: "none", stroke: "url(#pcr-grad-v)", "stroke-width": w, "stroke-linecap": "round", "stroke-dasharray": C });
svg.appendChild(arc);
var ticks = [];
if (big) for (var i = 0; i < TICKS; i++) {
var a = (i / TICKS) * Math.PI * 2;
var t = el("line", { x1: 50 + Math.cos(a) * 47.5, y1: 50 + Math.sin(a) * 47.5, x2: 50 + Math.cos(a) * 49.5, y2: 50 + Math.sin(a) * 49.5, "stroke-width": .7 });
svg.appendChild(t); ticks.push(t);
}
if (big) item.insertBefore(svg, item.firstChild); else item.querySelector(".pcr-small").appendChild(svg);
return { value: Number(item.dataset.value), arc: arc, ticks: ticks, live: item.querySelector(".pcr-live") };
});
function draw(it, v) {
it.arc.setAttribute("stroke-dashoffset", C * (1 - v / 100));
it.arc.style.opacity = v > .2 ? 1 : 0;
it.ticks.forEach(function (t, i) { t.setAttribute("stroke", (i / TICKS) * 100 < v ? "#d9e7a6" : "rgba(233,240,220,.18)"); });
it.live.textContent = pct.format(Math.round(v) / 100);
}
function ease(t) { return 1 - Math.pow(1 - t, 3); }
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches || !("IntersectionObserver" in window)) {
items.forEach(function (it) { draw(it, it.value); }); return;
}
items.forEach(function (it) { draw(it, 0); });
var raf = 0, start = 0, elapsed = 0, visible = false;
function tick(now) {
if (!start) start = now - elapsed;
elapsed = now - start;
var moving = false;
items.forEach(function (it, i) {
var p = Math.min(1, Math.max(0, (elapsed - i * STAGGER) / DURATION));
if (p < 1) moving = true;
draw(it, it.value * ease(p));
});
if (moving && visible) raf = requestAnimationFrame(tick);
}
var io = new IntersectionObserver(function (entries) {
visible = entries[0].isIntersecting;
cancelAnimationFrame(raf);
start = 0;
if (visible) raf = requestAnimationFrame(tick);
}, { threshold: 0.3 });
io.observe(root);
})();
</script>
With reduced motion: All three rings appear full to their final values with the numbers already in place.
Running cost: Updates a stroke offset, a few dozen tick colours and three text nodes per frame for under two seconds.
Three weekly figures for a harbourside restaurant, each digit printed on a charcoal card cut across the middle. As the board comes into view, every card steps upward from zero, its upper leaf dropping on a hinge and darkening as it falls, until the real number rests. Good for venues, transport and event pages.
<section class="sfc-root">
<div class="sfc-inner">
<p class="sfc-eyebrow">Harbour Kitchen, Pier 4</p>
<h2 class="sfc-title">The week on the pass</h2>
<div class="sfc-board">
<!-- data-value is the final figure. The readable number stays in .sfc-sr; the script builds the flaps. -->
<dl class="sfc-list">
<div class="sfc-row"><dt>Covers served this week</dt><dd data-value="1486"><span class="sfc-sr">1,486</span><span class="sfc-static" aria-hidden="true">1,486</span></dd></div>
<div class="sfc-row"><dt>Tables turned</dt><dd data-value="312"><span class="sfc-sr">312</span><span class="sfc-static" aria-hidden="true">312</span></dd></div>
<div class="sfc-row"><dt>Oysters shucked</dt><dd data-value="2040"><span class="sfc-sr">2,040</span><span class="sfc-static" aria-hidden="true">2,040</span></dd></div>
</dl>
</div>
<p class="sfc-note">Sample numbers for a made-up restaurant. Each leaf flips one step at a time, the way a station board does.</p>
</div>
</section>
<style>
/* Values worth changing:
--sfc-face / --sfc-ink: flap colour and numeral colour
--sfc-d: how long one leaf takes to fall (keep it under the STEP in the script)
--sfc-size: numeral size */
.sfc-root { --sfc-face: #24282e; --sfc-ink: #efe9dc; --sfc-d: 76ms; --sfc-size: 2.6rem;
background: #0f1318; color: #efe9dc; padding: 64px 20px; font-family: ui-sans-serif, system-ui, sans-serif; }
.sfc-inner { max-width: 768px; margin: 0 auto; }
.sfc-eyebrow { margin: 0; font-size: 14px; font-weight: 500; letter-spacing: .2em; text-transform: uppercase; color: #e0b860; }
.sfc-title { margin: 12px 0 0; font-size: clamp(30px, 4vw, 36px); font-weight: 600; letter-spacing: -.01em; }
.sfc-board { margin-top: 40px; padding: 16px; border-radius: 12px; border: 1px solid rgba(255,255,255,.05); background: #161a20;
box-shadow: inset 0 2px 12px rgba(0,0,0,.6); }
.sfc-list { margin: 0; }
.sfc-row { display: flex; flex-direction: column; gap: 12px; padding: 16px 0; border-top: 1px solid rgba(255,255,255,.05); }
.sfc-row:first-child { padding-top: 0; border-top: 0; } .sfc-row:last-child { padding-bottom: 0; }
.sfc-row dt { font-size: 14px; letter-spacing: .14em; text-transform: uppercase; color: #a9b0b9; }
.sfc-row dd { margin: 0; font-size: var(--sfc-size); font-weight: 600; line-height: 1; font-variant-numeric: tabular-nums; }
@media (min-width: 640px) {
.sfc-root { padding: 80px 20px; } .sfc-board { padding: 24px; } .sfc-root { --sfc-size: 3.75rem; }
.sfc-row { flex-direction: row; align-items: center; justify-content: space-between; }
}
.sfc-flaps { display: flex; align-items: flex-end; gap: .07em; }
.sfc-comma { width: .22em; padding-bottom: .08em; text-align: center; font-size: .55em; color: #6f7782; }
.sfc-card { position: relative; display: inline-block; width: .92em; height: 1.36em; perspective: 3.5em; border-radius: .09em;
box-shadow: 0 .06em .12em rgba(0,0,0,.55), inset 0 0 0 1px rgba(255,255,255,.04); }
.sfc-half { position: absolute; left: 0; right: 0; height: 50%; overflow: hidden; color: var(--sfc-ink); backface-visibility: hidden; }
.sfc-top { top: 0; border-radius: .09em .09em 0 0; background: linear-gradient(180deg, #2b3037, var(--sfc-face)); transform-origin: 50% 100%; }
.sfc-bot { bottom: 0; border-radius: 0 0 .09em .09em; background: linear-gradient(180deg, #1f2328, #262a30); transform-origin: 50% 0; }
.sfc-face { position: absolute; left: 0; right: 0; height: 200%; display: flex; align-items: center; justify-content: center; }
.sfc-top .sfc-face { top: 0; } .sfc-bot .sfc-face { top: -100%; }
.sfc-fall { z-index: 2; animation: sfc-fall var(--sfc-d) cubic-bezier(.55,0,.9,.4) both; }
.sfc-rise { z-index: 2; animation: sfc-rise var(--sfc-d) cubic-bezier(.2,.7,.35,1) calc(var(--sfc-d) * .92) both; }
.sfc-fall::after { content: ""; position: absolute; inset: 0; background: #000; animation: sfc-dim var(--sfc-d) ease-in both; }
.sfc-seam { position: absolute; left: 0; right: 0; top: 50%; height: 2px; margin-top: -1px; background: rgba(0,0,0,.7); z-index: 3; }
.sfc-pin { position: absolute; top: 50%; width: .06em; height: .16em; margin-top: -.08em; background: #0b0d10; z-index: 3; border-radius: 1px; }
.sfc-pin-l { left: 0; } .sfc-pin-r { right: 0; }
@keyframes sfc-fall { from { transform: rotateX(0); } to { transform: rotateX(-90deg); } }
@keyframes sfc-rise { from { transform: rotateX(90deg); } to { transform: rotateX(0); } }
@keyframes sfc-dim { from { opacity: 0; } to { opacity: .45; } }
.sfc-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
.sfc-note { margin: 24px 0 0; font-size: 14px; line-height: 1.6; color: #a9b0b9; }
@media (prefers-reduced-motion: reduce) { .sfc-fall, .sfc-rise { display: none; } }
</style>
<script>
(function () {
var STEP = 170; // ms between flips
var root = document.querySelector(".sfc-root");
if (!root) return;
var fmt = new Intl.NumberFormat();
var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
function half(cls, ch) {
var h = document.createElement("span"); h.className = "sfc-half " + cls;
var f = document.createElement("span"); f.className = "sfc-face"; f.textContent = ch;
h.appendChild(f); return h;
}
function makeFlap(ch) {
var card = document.createElement("span"); card.className = "sfc-card";
var top = half("sfc-top", ch), bot = half("sfc-bot", ch);
card.appendChild(top); card.appendChild(bot);
["sfc-seam", "sfc-pin sfc-pin-l", "sfc-pin sfc-pin-r"].forEach(function (c) { var s = document.createElement("span"); s.className = c; card.appendChild(s); });
var flap = { card: card, top: top, bot: bot, cur: ch, leaves: [] };
return flap;
}
function setFlap(f, ch) {
if (f.cur === ch) return;
var prev = f.cur; f.cur = ch;
f.leaves.forEach(function (l) { if (l.parentNode) l.parentNode.removeChild(l); });
f.top.firstChild.textContent = ch; // new top waits under the falling leaf
f.bot.firstChild.textContent = prev; // old bottom stays until the new leaf covers it
var fall = half("sfc-top sfc-fall", prev), rise = half("sfc-bot sfc-rise", ch);
f.card.insertBefore(fall, f.card.children[2]); f.card.insertBefore(rise, f.card.children[3]);
f.leaves = [fall, rise];
}
var all = [];
Array.prototype.forEach.call(root.querySelectorAll("dd[data-value]"), function (dd) {
var target = Number(dd.dataset.value);
var text = fmt.format(target);
var wrap = document.createElement("span"); wrap.className = "sfc-flaps"; wrap.setAttribute("aria-hidden", "true");
text.split("").forEach(function (ch) {
if (/\d/.test(ch)) {
var f = makeFlap(reduce ? ch : "0"); f.target = Number(ch); f.n = reduce ? f.target : 0;
wrap.appendChild(f.card); all.push(f);
} else {
var c = document.createElement("span"); c.className = "sfc-comma"; c.textContent = ch; wrap.appendChild(c);
}
});
dd.removeChild(dd.querySelector(".sfc-static"));
dd.appendChild(wrap);
});
if (reduce || !("IntersectionObserver" in window)) { all.forEach(function (f) { setFlap(f, String(f.target)); }); return; }
var timer = 0;
function step() {
var moving = false;
all.forEach(function (f) { if (f.n < f.target) { f.n++; moving = true; setFlap(f, String(f.n)); } });
if (!moving) clearInterval(timer);
}
var io = new IntersectionObserver(function (entries) {
clearInterval(timer);
if (entries[0].isIntersecting) timer = setInterval(step, STEP);
}, { threshold: 0.3 });
io.observe(root);
})();
</script>
With reduced motion: Every card shows its final digit at once and no leaves fall.
Running cost: A short interval swaps a few text nodes; each flip is two CSS transform animations on small elements.
A count-up changes one text node per frame for about a second and then stops, which is negligible. The odometer styles move digit columns with transform rather than rewriting text, so they stay smooth even when several run at once.
Counters only start once they are on screen and never run again after finishing, unless you ask them to. The countdown is the exception: it updates once per second on a timer rather than per frame, and it pauses while the tab is hidden and corrects itself from the clock when it resumes.
Create an IntersectionObserver for the counter element, start the animation the first time it reports isIntersecting, then disconnect the observer so it only plays once.
Most fonts give each digit a different width, so the number changes width as it counts. Set font-variant-numeric: tabular-nums so every digit takes the same space.
Use Intl.NumberFormat with the right locale and options, such as style: currency with a currency code, or style: percent. It handles separators and symbols for each language, so the counter reads correctly everywhere.
Not if you set it up as here: the final value sits in a readable element, and the moving digits are aria-hidden. Avoid putting a live region on a counter, or it may be announced many times.
More collections: CSS Text Animations, Typing Animations and Typewriter Effects, Text Animations for Websites. All text effects.
We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.