# Number Flow (Moonarc)

A number whose digits roll to the new value when it changes. Each column slides to its digit on the preset spring, thousands separators stay put, and leading columns collapse when the number gets shorter. The value is one custom property your code writes; every column derives its digit from it with round() and mod(). No script of its own.

- Import: `import NumberFlow from '@moonarc/core/NumberFlow'`
- Install: `npx astro add moonarc` · copy-paste: `npx shadcn@latest add https://moonarc.dev/r/number-flow.json`
- Tier A · category data · trigger click
- Readout: `<NumberFlow value={1284}>`
- Browser support: newly (Chrome 125 · Firefox 118 · Safari 15.4); elsewhere: the value as plain text, updated only by a re-render
- Measured cost: 0 B JS (CSS 5.5 kB raw)
- Page: https://moonarc.dev/components/number-flow/

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `value` | `number` | none | Initial value, 0 or more. Later values are written to --ma-nf-value on the element (or an ancestor, which is how PriceSwitch drives it). |
| `as` | `HTMLTag` | `'span'` | Element to render. |
| `digits` | `number` | `1` | Integer columns to keep room for; leading zeros collapse, so a number that grows past the initial width still fits. |
| `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. %. |
| `duration` | `number` | `preset (ui: 450)` | Roll duration in ms; unset, the active preset's. |

## Usage

```astro
<NumberFlow value={1284} id="stars" />
<NumberFlow value={99.5} decimals={1} suffix="%" />

<script>
  // roll to a new value: write the property, keep the text honest (a screen reader reads it; an older browser shows it)
  const n = document.getElementById('stars');
  n.style.setProperty('--ma-nf-value', '1321');
  n.querySelector('.ma-nf__static').textContent = '1,321';
</script>
```

## Reduced motion

Digits change in place; nothing rolls.

## With ClientRouter

CSS-only; nothing to rebind. A value written before a navigation is not carried over unless the element is persisted.

## Craft

- One custom property is the whole API. The column for 10^k shows mod(round(down, value / 10^k), 10): no script parses the number into digits, so the component can be driven by a radio and :has() as easily as by JavaScript.
- Columns roll on the preset spring; with lively, the strip overshoots by a sliver of the next digit and settles, which is the detail number-flow libraries spend a spring on.
- Leading columns collapse through max-width, and the separator after them collapses with them, so 1,284 rolling to 96 shrinks to two digits instead of showing 0,096.
- Tabular figures and a fixed 1 em row: the number never jitters sideways while rolling and every column is exactly one digit tall.
- The server renders the value as text as well; browsers without CSS math show that text and never a column of zeros. Everywhere else the same text is visually hidden, not removed: it is what a screen reader reads, so the columns stay aria-hidden and there is no aria-label on a span.
- The columns take no selection (user-select: none), so selecting and copying the number copies the visually hidden text and never ten digits per column. They stay left to right on a right-to-left page, where a number still reads that way.
- Rolls to the shorter path are not chosen: 9 → 0 rolls back through 8. Direction-aware rolling needs script, and the plain roll reads fine at one spring length.

## Replaces

- NumberFlow (number-flow, Motion Primitives)
- AnimatedNumber (Skiper, motion.dev)

## Source

```astro
---
/**
 * NumberFlow — digits roll to a new value. Every digit is a column of 0–9
 * translated to the right row; the value lives in one custom property
 * (--ma-nf-value) and each column derives its own digit from it with round()
 * and mod(), so writing that property from your code rolls every column to
 * its new digit on the preset spring, and leading columns collapse when the
 * number gets shorter. No script of its own. The server also renders the
 * value as text, which is what browsers without CSS math show.
 */
import type { HTMLAttributes, HTMLTag } from 'astro/types';

interface Props extends HTMLAttributes<'span'> {
  /** Initial value, ≥ 0. Later values come from `--ma-nf-value` on the element or any ancestor. */
  value: number;
  /** Element to render. */
  as?: HTMLTag;
  /** Integer columns to keep room for; leading zeros collapse, so this is headroom, not padding. */
  digits?: 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;
  /** Roll duration in ms. Unset: the preset's --ma-duration. */
  duration?: number;
}

const { value, as: Tag = 'span', digits = 1, decimals = 0, separator = ',', decimal = '.', prefix = '', suffix = '', duration, class: className, style, ...rest } = Astro.props;
const places = Math.max(0, Math.min(decimals, 4));
const abs = Math.max(0, value);
const fixed = abs.toFixed(places);
const [intPart, fracPart] = fixed.split('.');
const intCols = Math.max(1, digits, intPart!.length);
const label = `${prefix}${intPart!.replace(/\B(?=(\d{3})+(?!\d))/g, separator)}${fracPart ? decimal + fracPart : ''}${suffix}`;
// column k shows the digit at 10^k of the value scaled to an integer (value × 10^decimals); highest first
const cols = Array.from({ length: intCols + places }, (_, i) => intCols + places - 1 - i);
const DIGITS = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
const vars = [`--ma-nf-init:${abs}`, `--ma-nf-scale:${10 ** places}`, duration !== undefined && `--ma-nf-dur:${duration}ms`, typeof style === 'string' ? style : ''].filter(Boolean).join(';');
---

<Tag class:list={['ma-nf', className]} data-value={abs} style={vars} {...rest}>
  <span class="ma-nf__static">{label}</span>
  <span class="ma-nf__digits" aria-hidden="true">
    {prefix}
    {cols.map((k) => {
      const p = k - places;
      return (
        <>
          {p === -1 && <span class="ma-nf__mark">{decimal}</span>}
          <span class="ma-nf__col" data-lead={p > 0 ? '' : undefined} style={`--ma-nf-k:${10 ** k}`}>
            <b class="ma-nf__strip">{DIGITS.map((d) => <i>{d}</i>)}</b>
          </span>
          {p > 0 && p % 3 === 0 && <span class="ma-nf__mark" data-lead style={`--ma-nf-k:${10 ** k}`}>{separator}</span>}
        </>
      );
    })}
    {suffix}
  </span>
</Tag>

<style is:global>
  @layer components {
    :where(.ma-nf) {
      display: inline-block;
      font-variant-numeric: tabular-nums;
      /* the live value: --ma-nf-value from your code or an ancestor, else the server's */
      --ma-nf-v: var(--ma-nf-value, var(--ma-nf-init, 0));
      --ma-nf-int: round(nearest, var(--ma-nf-v) * var(--ma-nf-scale, 1), 1);
    }
    /* the columns are presentation: not selectable, so a copy takes the value text instead of ten digits per column;
       and a number reads left to right on a right-to-left page too, where the flex row put its columns back to front */
    :where(.ma-nf__digits) {
      display: none;
      direction: ltr;
      -webkit-user-select: none;
      user-select: none;
    }
    /* the columns need round() and mod(); elsewhere the static text stays */
    @supports (translate: calc(mod(round(down, 7, 1), 10) * 1em)) {
      /* still the text a screen reader reads (and a copy copies): hidden from the eye only */
      :where(.ma-nf__static) {
        position: absolute;
        width: 1px;
        height: 1px;
        overflow: hidden;
        clip-path: inset(50%);
        white-space: nowrap;
      }
      :where(.ma-nf__digits) {
        display: inline-flex;
        align-items: baseline;
      }
    }
    :where(.ma-nf__col),
    :where(.ma-nf__mark) {
      --ma-nf-hi: round(down, var(--ma-nf-int) / var(--ma-nf-k, 1), 1);
    }
    :where(.ma-nf__col) {
      display: inline-block;
      height: 1em;
      line-height: 1;
      clip-path: inset(0);
      --ma-nf-d: mod(var(--ma-nf-hi), 10);
    }
    :where(.ma-nf__strip) {
      display: grid;
      font-weight: inherit;
      translate: 0 calc(var(--ma-nf-d) * -1em);
      transition: translate var(--ma-nf-dur, var(--ma-duration)) var(--ma-ease);
    }
    :where(.ma-nf__strip > i) {
      display: block;
      height: 1em;
      line-height: 1;
      font-style: normal;
    }
    /* leading columns (and the separator after them) collapse while the number is shorter */
    :where(.ma-nf__col[data-lead]),
    :where(.ma-nf__mark[data-lead]) {
      max-width: calc(min(1, var(--ma-nf-hi)) * 2ch);
      overflow: clip;
      transition: max-width var(--ma-nf-dur, var(--ma-duration)) var(--ma-ease);
    }
    :where(.ma-nf__col[data-lead]) {
      transition:
        max-width var(--ma-nf-dur, var(--ma-duration)) var(--ma-ease),
        translate var(--ma-nf-dur, var(--ma-duration)) var(--ma-ease);
    }
    @media (prefers-reduced-motion: reduce) {
      :where(.ma-nf__strip),
      :where(.ma-nf__col[data-lead]),
      :where(.ma-nf__mark[data-lead]) {
        transition: none;
      }
    }
  }
</style>

```
