Skip to content

Components / Data

Number Flow

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.

Live demo

1,284

Live, from the library itself: scroll, hover, navigate away and back. The demo is never gated.

Measured

JavaScript of its own

0 B

CSS 5.5 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 125 · Firefox 118 · Safari 15.4; needs css-math. Elsewhere: the value as plain text, updated only by a re-render.

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 NumberFlow from '@moonarc/core/NumberFlow';
---
<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>
Copy it into your project instead (shadcn registry)

Owns the file, no dependency. The registry item also installs the base tokens.

terminal
npx shadcn@latest add https://moonarc.dev/r/number-flow.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; it imports nothing.

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

Props

PropTypeDefaultDescription
valuenumbernoneInitial value, 0 or more. Later values are written to --ma-nf-value on the element (or an ancestor, which is how PriceSwitch drives it).
asHTMLTag'span'Element to render.
digitsnumber1Integer columns to keep room for; leading zeros collapse, so a number that grows past the initial width still fits.
decimalsnumber0Decimal places, 0–4.
separatorstring','Thousands separator.
decimalstring'.'Decimal mark.
prefixstringnoneText before the number, e.g. $.
suffixstringnoneText after the number, e.g. %.
durationnumberpreset (ui: 450)Roll duration in ms; unset, the active preset's.

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.

Why it is built this way

Replaces: NumberFlow (number-flow, Motion Primitives) · AnimatedNumber (Skiper, motion.dev). See the migration table.

Counts a number up from zero to its value when it scrolls into view, with thousands separators, decimals, prefix and suffix.

newly · Chrome 91 · Firefox 128 · Safari 17scroll

A flip clock counting down to a date: days, hours, minutes, seconds, each a card whose top half folds down over the hinge when the number changes.

every browserload

sample prices

Starter

$12/mo$115/yr

one site

Studio

$48/mo$460/yr

up to ten

A monthly / yearly toggle whose prices roll to the other amount instead of swapping.

newly · Chrome 125 · Firefox 129 · Safari 17.5click

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