# Count Up (Moonarc)

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.

- Import: `import CountUp from '@moonarc/core/CountUp'`
- Install: `npx astro add moonarc` · copy-paste: `npx shadcn@latest add https://moonarc.dev/r/count-up.json`
- Tier A · category text · trigger scroll
- Readout: `<CountUp to={2847}>`
- Browser support: newly (Chrome 91 · Firefox 128 · Safari 17); elsewhere: the final number, shown at once
- Measured cost: 0 B JS of its own · uses Reveal + runtime (with dependencies 3.0 kB raw; CSS 7.8 kB raw)
- Page: https://moonarc.dev/components/count-up/

## Props

| 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. |

## Usage

```astro
<CountUp to={2847} />
<CountUp to={99.5} decimals={1} suffix="%" duration={1800} />
<CountUp to={1200000} prefix="$" as="strong" />
```

## Reduced motion

The final number appears without counting.

## With ClientRouter

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

## Craft

- Each thousands group is its own counter, and every group runs on the same duration and ease-out curve, so the whole number follows that curve.
- Groups after the first are padded to three digits with @counter-style, so 1,005 never renders as 1,5.
- Ease-out: a count that lands slowly reads as arriving; one that lands fast reads as a glitch.
- A visually hidden span (.ma-sr) carries the final formatted value; the animated counters are aria-hidden.
- Tabular figures so the number does not jitter sideways while counting.

## Replaces

- CountUp (React Bits)
- NumberTicker (Magic UI)
- react-countup

## Source

```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>

```
