Progress
LoadingA bar that fills to its value when it scrolls into view, with the number counting up beside it, or a ring with the number in the middle.
Counts a number up from zero to its value when it scrolls into view, with thousands separators, decimals, prefix and suffix. No script of its own: Reveal supplies the trigger, and the digits are CSS counters driven by a custom property transitioning from 0 to the target. The server HTML already shows the final number.
2,847
sample orders, counted by CSS · 99.5% on time
Live, from the library itself: scroll, hover, navigate away and back. The demo is never gated.
JavaScript of its own
0 B
Uses Reveal and the shared runtime (1.9 kB raw, once per site). With those included: 3.0 kB raw.
CSS 7.8 kB raw including the base tokens. Measured from a production build, and again in CI for every change that can move it.
Browser support
Chrome 91 · Firefox 128 · Safari 17; needs property, counter-style. Elsewhere: the final number, shown at once.
One command adds the integration, the base tokens and every component. Then import what you use.
npx astro add moonarcpnpm astro add moonarcbunx astro add moonarc---
import CountUp from '@moonarc/core/CountUp';
---
<CountUp to={2847} />
<CountUp to={99.5} decimals={1} suffix="%" duration={1800} />
<CountUp to={1200000} prefix="$" as="strong" />Owns the file, no dependency. The registry item also installs Scroll Reveal, the shared runtime and the base tokens.
npx shadcn@latest add https://moonarc.dev/r/count-up.jsonThe CLI needs a components.json and the @/* alias, which Setup has. The file lands in src/components/moonarc/.
The whole component. Self-contained styles in a cascade layer so your classes always win. If you paste it, also copy Reveal.astro and runtime.ts, and add the JS gate to your head.
---
/**
* CountUp — a number counts from zero to its value when it scrolls into view.
* Zero script of its own: it composes Reveal for the trigger, and the digits
* are CSS counters driven by a registered custom property transitioning
* from 0 to the target. Thousands are separate counters so the separator is
* real text; the server HTML already holds the final number.
*/
import type { HTMLAttributes, HTMLTag } from 'astro/types';
import Reveal from './Reveal.astro';
interface Props extends HTMLAttributes<'span'> {
/** Target value. */
to: number;
/** Element to render. */
as?: HTMLTag;
/** Duration in ms. */
duration?: number;
/** Decimal places, 0–4. */
decimals?: number;
/** Thousands separator. */
separator?: string;
/** Decimal mark. */
decimal?: string;
/** Text before the number. */
prefix?: string;
/** Text after the number. */
suffix?: string;
/** Delay in ms before counting starts. */
delay?: number;
}
const { to, as = 'span', duration = 1400, decimals = 0, separator = ',', decimal = '.', prefix = '', suffix = '', delay, class: className, style, ...rest } = Astro.props;
const places = Math.max(0, Math.min(decimals, 4));
const negative = to < 0;
const fixed = Math.abs(to).toFixed(places);
const [intPart, fracPart] = fixed.split('.');
const groups: number[] = [];
for (let i = intPart!.length; i > 0; i -= 3) groups.unshift(Number(intPart!.slice(Math.max(0, i - 3), i)));
const label = `${prefix}${negative ? '-' : ''}${groups.map((g, i) => (i === 0 ? String(g) : String(g).padStart(3, '0'))).join(separator)}${fracPart ? decimal + fracPart : ''}${suffix}`;
---
<Reveal
as={as}
from="none"
duration={400}
delay={delay}
class={['ma-count', className].filter(Boolean).join(' ')}
style={[`--ma-count-dur:${duration}ms`, delay ? `--ma-count-delay:${delay}ms` : '', typeof style === 'string' ? style : ''].filter(Boolean).join(';')}
{...rest}
>
<span class="ma-count__value" aria-hidden="true">
{prefix}{negative && '-'}
{groups.map((g, i) => (
<>
{i > 0 && separator}
<i class="ma-count__n" data-pad={i > 0 ? '' : undefined} style={`--ma-count-to:${g}`}></i>
</>
))}
{fracPart && (
<>
{decimal}
<i class="ma-count__n" data-pad={String(places)} style={`--ma-count-to:${Number(fracPart)}`}></i>
</>
)}
{suffix}
</span>
<span class="ma-sr">{label}</span>
</Reveal>
<style is:global>
@property --ma-count-n {
syntax: '<integer>';
inherits: false;
initial-value: 0;
}
@layer components {
@counter-style ma-pad2 {
system: extends decimal;
pad: 2 '0';
}
@counter-style ma-pad3 {
system: extends decimal;
pad: 3 '0';
}
@counter-style ma-pad4 {
system: extends decimal;
pad: 4 '0';
}
:where(.ma-count__value) {
font-variant-numeric: tabular-nums;
}
/* the count waits for the delay too: Reveal's delay holds back only the fade, so a number with a delay (StatsRow
staggers its stats) had mostly counted before it showed */
:where(.ma-count__n) {
font-style: inherit;
--ma-count-n: var(--ma-count-to);
transition: --ma-count-n var(--ma-count-dur, 1400ms) var(--ma-ease-out) var(--ma-count-delay, 0ms);
}
/* the counter is reset on the pseudo-element that prints it: reset on the <i>, WebKit printed the first group's value
in every later group ("2,002" for 2,847, "1.1" for 1.9). --ma-count-n is registered with inherits: false, so the
pseudo-element takes it explicitly */
:where(.ma-count__n)::before {
--ma-count-n: inherit;
counter-reset: ma-n var(--ma-count-n);
content: counter(ma-n);
}
:where(.ma-count__n[data-pad=''])::before,
:where(.ma-count__n[data-pad='3'])::before {
content: counter(ma-n, ma-pad3);
}
:where(.ma-count__n[data-pad='2'])::before {
content: counter(ma-n, ma-pad2);
}
:where(.ma-count__n[data-pad='4'])::before {
content: counter(ma-n, ma-pad4);
}
:where(.ma-count__n[data-pad='1'])::before {
content: counter(ma-n);
}
/* Under the JS gate the digits start at zero and transition to the value once Reveal marks the wrapper in view. */
:where([data-ma-js] .ma-count:not(.ma-in) .ma-count__n) {
--ma-count-n: 0;
transition: none;
}
@media (prefers-reduced-motion: reduce) {
:where(.ma-count__n) {
transition: none;
}
}
}
</style>| Prop | Type | Default | Description |
|---|---|---|---|
to | number | none | Target value. Negative numbers keep their sign. |
as | HTMLTag | 'span' | Element to render. |
duration | number | 1400 | Duration in ms. Ease-out, so most of the change happens early. |
decimals | number | 0 | Decimal places, 0–4. |
separator | string | ',' | Thousands separator. |
decimal | string | '.' | Decimal mark. |
prefix | string | none | Text before the number, e.g. $. |
suffix | string | none | Text after the number, e.g. %. |
delay | number | 0 | Delay in ms before the number fades in and starts counting. |
The final number appears without counting.
ClientRouterInherits Reveal: rebinds after every navigation, so the count replays on a fresh page.
Replaces: CountUp (React Bits) · NumberTicker (Magic UI) · react-countup. See the migration table.
A bar that fills to its value when it scrolls into view, with the number counting up beside it, or a ring with the number in the middle.
Entrance on scroll that plays once and stays, staggers its children, scrubs with the scroll position when asked, honours reduced motion, and keeps working after every ClientRouter navigation.
Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown