Skip to content

Components / Text

Count Up

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.

Live demo

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.

Measured

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

Baseline · newly available

Chrome 91 · Firefox 128 · Safari 17; needs property, counter-style. Elsewhere: the final number, shown at once.

Install

One command adds the integration, the base tokens and every component. Then import what you use.

npx astro add moonarc
pnpm astro add moonarc
bunx astro add moonarc
src/pages/index.astro
---
import CountUp from '@moonarc/core/CountUp';
---
<CountUp to={2847} />
<CountUp to={99.5} decimals={1} suffix="%" duration={1800} />
<CountUp to={1200000} prefix="$" as="strong" />
Copy it into your project instead (shadcn registry)

Owns the file, no dependency. The registry item also installs Scroll Reveal, the shared runtime and the base tokens.

terminal
npx shadcn@latest add https://moonarc.dev/r/count-up.json

The CLI needs a components.json and the @/* alias, which Setup has. The file lands in src/components/moonarc/.

Source

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.astro
---
/**
 * 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>

Props

PropTypeDefaultDescription
tonumbernoneTarget value. Negative numbers keep their sign.
asHTMLTag'span'Element to render.
durationnumber1400Duration in ms. Ease-out, so most of the change happens early.
decimalsnumber0Decimal places, 0–4.
separatorstring','Thousands separator.
decimalstring'.'Decimal mark.
prefixstringnoneText before the number, e.g. $.
suffixstringnoneText after the number, e.g. %.
delaynumber0Delay in ms before the number fades in and starts counting.

Reduced motion

The final number appears without counting.

With ClientRouter

Inherits Reveal: rebinds after every navigation, so the count replays on a fresh page.

Why it is built this way

Replaces: CountUp (React Bits) · NumberTicker (Magic UI) · react-countup. See the migration table.

Progress

Loading

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.

newly · Chrome 91 · Firefox 128 · Safari 17scroll

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.

every browserscroll

Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown