# Rolling Number

**Numbers that move without losing their place.**

A small, original TypeScript library for interruptible rolling numbers. Native
browser animation playback, a framework-independent DOM API, and thin React and
Solid adapters. MIT licensed. No runtime dependencies in the DOM core.

[Demo](https://rolling.kitlangton.dev) · [Contributing](https://github.com/kitlangton/rolling-number/blob/main/CONTRIBUTING.md) · [Agent guide](https://github.com/kitlangton/rolling-number/blob/main/AGENTS.md) · [MIT license](https://github.com/kitlangton/rolling-number/blob/main/LICENSE)

## Install

```sh
bun add @kitlangton/rolling-number
```

Or use `npm install @kitlangton/rolling-number`. The unscoped name belongs to
another project. Import the stylesheet alongside the DOM, React, or Solid entrypoint.

### For coding agents

The site serves these docs as Markdown: [rolling.kitlangton.dev/llms.txt](https://rolling.kitlangton.dev/llms.txt)
indexes them, [rolling.kitlangton.dev/index.md](https://rolling.kitlangton.dev/index.md)
is this README, and the page itself answers `Accept: text/markdown` with the same
content.

## Try it

```sh
git clone https://github.com/kitlangton/rolling-number.git
cd rolling-number
bun install
bun run dev
```

The main number shows elapsed milliseconds since opening the page. The demo also
includes prices, large integers, typography controls, locale changes and reduced
motion. Nothing needs a remote font or an API key.

## React

```tsx
import { RollingNumber } from '@kitlangton/rolling-number/react'
import '@kitlangton/rolling-number/styles.css'

<RollingNumber
  value={1234.56}
  locales="en-US"
  format={{ style: 'currency', currency: 'USD' }}
  duration={500}
/>
```

React owns the accessible formatted text; the engine owns a separate decorative
mount. There are no per-frame React state updates. Server rendering produces
readable text and the initial hydration does not animate. Use identical initial
values, locales and options on the server and client; differing ICU/CLDR versions
can still produce different formatted text. Hydration warnings are not suppressed.

React 18 and 19 are supported. React is an optional peer dependency; vanilla users
do not need to install it. The React entrypoint preserves its `use client` boundary.

## Solid

```tsx
import { createSignal } from 'solid-js'
import { RollingNumber } from '@kitlangton/rolling-number/solid'
import '@kitlangton/rolling-number/styles.css'

function Balance() {
  const [value, setValue] = createSignal(1234.56)
  return <RollingNumber value={value()} locales="en-US" format={{ style: 'currency', currency: 'USD' }} />
}
```

Solid 1.9+ is supported. Pass reactive props normally; the adapter forwards changes
to the same DOM controller and destroys it on cleanup. Use Solid's `class` and
`ref` props. Server rendering keeps readable text, and hydration adopts it without
an initial roll. React and Solid are optional peers; each adapter imports only its
own framework. The shipped Solid entry works in browser and server builds without
a package-specific JSX transform.

## Rolling text

For labels and headings, use a direct transition: each glyph rolls straight to its
replacement, including lowercase letters and emoji. Newly added letters reveal
from below. This does not enumerate an alphabet or animate every intermediate word.
The initial render stays static for SSR; subsequent text changes animate.

```tsx
import { RollingText } from '@kitlangton/rolling-number/react'
import '@kitlangton/rolling-number/styles.css'

<RollingText text={status} transition="direct" stagger="start" motionBlur />
```

The same options work with `RollingText` from `/solid` and
`createRollingText(element, { text: status, transition: 'direct' })` from the DOM
entrypoint. Direct transitions retain at most the visible pair plus the newest
glyph when interrupted. They use roll mode; combining `transition="direct"` with
`mode="flap"` throws. Existing alphabet-wheel behavior remains the default
`transition="wheel"`, and `charset` applies only to that behavior. RTL text keeps
the readable static fallback.

### Alphabet wheels and split-flap boards

```tsx
import { RollingText } from '@kitlangton/rolling-number/react'

<RollingText text={"FIX THE FIX".padEnd(15)} mode="flap" stagger="start" motionBlur />
```

`RollingText` (also exported from `/solid`, and `createRollingText` from the DOM
core) treats each character as a wheel. Characters in `charset` (default:
space, A–Z, 0–9 and common punctuation, exported as `FLAP_CHARSET`) advance
through the wheel like a departure board; other glyphs crossfade in place. Pass an
array to give each position its own drum, e.g. digit drums for a time and letter
drums for a destination. Words of different length open and close width with the
same layout spring as numbers. With auto direction, wheels advance, so `Z` to `A`
travels forward through the remaining wheel rather than backwards.

`mode="flap"` (numbers too) replaces the gliding reel with real split-flap
mechanics: one card per face hinges at the slot's midline, the top half of the
current face falls, then the bottom half of the next lands, at a mechanical
45–110 ms cadence derived from `duration`, or an explicit `flipDuration` per card.
For example, use `flipDuration={220}` to make individual clock ticks more visible.
Four temporary half-card strips reuse
the same hinge throughout the change; native stepped timing advances their glyphs.
Travel is bounded to one revolution, and settlement returns to one static face.
New characters flap in from the blank face.
`--rn-crease` sets the visible hinge gap. Opt into `motionBlur` for vertical-only
smear as each half turns; landed faces stay sharp. `--rn-blur: 0` disables the smear.
Flap faces need an opaque surface to hide the waiting glyph. The default is the
system `Canvas` color; set `--rn-flap-background` or an opaque `.rn-face` background
to match your design. Ordinary rolling mode keeps its transparent background.
`stagger="start"` or `"end"` sweeps a row left to right or right to left, for
in-place changes as well as new characters.

The board demo also has an opt-in **Sound** toggle. Web Audio synthesizes grouped
tick–clack impacts from the moving drums' native timing. It uses no recordings,
caps concurrent voices and volume, and mutes on reduced motion or a hidden tab.
Sound belongs to the demo, not the library or its adapters.

For a physical board, reserve its slots in the text: comment count `3` uses `" 3"`
with a blank-capable tens drum, and shorter PR titles use trailing blanks up to the
longest title. This keeps the same cards in place instead of inserting digits or
resizing the display. The standalone flap-board experiment uses fictional PR data;
it is separate from the number showcase and is not connected to GitHub.

## Vanilla DOM

```ts
import { createRollingNumber } from '@kitlangton/rolling-number'
import '@kitlangton/rolling-number/styles.css'

const counter = createRollingNumber(document.querySelector('#balance')!, {
  value: 1234.56,
  locales: 'en-US',
  format: { style: 'currency', currency: 'USD' },
})

counter.update({ value: 1300 })
counter.refresh() // Explicit refresh after a theme or variable-font change
counter.finish()  // Immediately show the latest target
counter.destroy() // Releases resources; leaves the final formatted text
```

The controller owns the host's children until destruction. `destroy()` is
idempotent. Invalid values/options throw before replacing the current display.

### Options

| Option | Default | Behavior |
| --- | --- | --- |
| `value` | required | `number` or `bigint`; never parsed from display text |
| `locales` | browser default | Locale(s) passed to `Intl.NumberFormat` |
| `format` | `{}` | Native `Intl.NumberFormatOptions` |
| `duration` | `500` | Milliseconds; `0` disables motion; maximum `10000` |
| `flipDuration` | automatic | Milliseconds per card in flap mode, from `1` to `10000`; leaves roll-mode timing unchanged |
| `animated` | `true` | `false` immediately settles the latest value |
| `motionBlur` | `false` | Opt-in vertical blur on rolling digits and turning flap halves |
| `direction` | `"auto"` | `"auto"`, `"up"`, or `"down"` |
| `pauseOffscreen` | `true` | Offscreen counters keep the latest text without rolling |

Auto direction follows **displayed magnitude**: `-12 → -11` rolls `12 → 11`, with
the sign handled separately. Large jumps have bounded travel; the renderer does
not enumerate every intervening numerical value. Unchanged formatted values do
not restart animations.

The React component additionally accepts ordinary span attributes, including
`className`, `style`, `aria-label`, and an element ref. It does not accept children
or raw HTML. Set `animated={false}` for updates that should settle immediately.
Changes between supported formats animate digits, separators and symbols while
respecting reduced-motion preferences.

For prominent counters, opt into `motionBlur`. Fast reels crossfade into a vertical
SVG blur, then sharpen as they slow down. Stable digits and punctuation stay sharp.
The temporary duplicate reel and native opacity effects are removed on settlement;
disabling the option clears active blur immediately. This adds paint/DOM work and
is not a performance optimization. The showcase and examples enable it; library
counters default to no blur. Reduced motion disables the effect along with rolling.
New digits can also smear vertically during their eased entrance; the blend clears
at settlement. Currency signs, separators and other symbols crossfade instead of
rolling. Replaced symbols keep their semantic position rather than moving through
the adjacent digits, with a small 4% scale accent during replacement.

When a value grows or shrinks by several places at once, the new digits and their
separators cascade outward from the digits already on screen, one short step each.
`stagger` selects the order: `"outward"` (default), `"start"` or `"end"` for a
board-style sweep from either edge, or `"none"`.
The whole cascade stays inside a third of the duration, so it reads as one update rather
than a typing effect, and interruptions still sample the current position of every
place.
See [the scoped blur-cost measurement](https://github.com/kitlangton/rolling-number/blob/main/perf/blur-cost.md) for its overhead and limits.

### Styling hooks

- `data-rn-trend="up" | "down" | "none"` is set on the host for every change, so
  CSS can tint or weight a number by direction without any JavaScript.
- `--rn-blur` (default `1`) scales the optional motion blur per counter; set it on
  the host or any ancestor. It is read during measurement, never during playback.
- `--rn-mask` and `--rn-edge-fade` control the reel's soft top and bottom edges;
  `--rn-crease` is the hinge gap between split-flap halves (default `1px`).
- Wheel slots carry `data-rn-wheel` (and `data-rn-flap` in flap mode); symbol
  slots do not.

## How it stays small and stable

- **One numeral per digit at rest.** During a roll, only a bounded travel strip
  exists; completion returns to one face. Huge value changes do not create huge reels.
- **Native playback.** Critically damped spring trajectories are sampled once into
  a `linear()` easing between two direct transform keyframes where supported.
  Explicit sampled keyframes remain the compatibility fallback. No JavaScript
  animation-frame loop runs during playback.
- **Interruptions replace, not accumulate.** A new target samples the current
  position and velocity; each property has one owning animation.
- **Batched geometry.** Across counters, reads happen before animation writes.
  ResizeObserver tracks intrinsic boxes and individual glyph sizes; font-loading
  events and `refresh()` handle further invalidation.
- **Readable by default.** Reduced motion, unsupported animation APIs, offscreen
  state, and non-rollable formats retain an intact formatted text value.

### Typography and layout contract

Fonts, size, weight, style and spacing are inherited. Proportional numerals work;
`font-variant-numeric: tabular-nums` is optional, not a measurement substitute.

Digit viewports have a linear alpha fade at their top and bottom edges. Tune it
without changing the measurement or animation:

```css
.counter { --rn-edge-fade: 0.12em; } /* default */
.counter--hard-clip { --rn-mask: none; } /* opt out of masking */
```

**The host adopts its target intrinsic width immediately; internal glyphs glide
to their target positions.** The renderer captures the previous and next origins
in shared measurement batches, keeping existing glyphs continuous in left-, center-
and right-aligned layouts. New glyphs rise from below after space starts opening.
This does not animate arbitrary surrounding siblings
or promise zero layout shift. Reserve space with CSS `min-width` when a stable
surrounding layout matters. Ancestor axis-aligned scaling is supported; rotated or
skewed ancestors, vertical writing and per-digit typography are not supported.

Keep horizontal overflow visible on containers around the number. A horizontal
scrollport or `overflow: hidden` can cut off outgoing digits when the host shrinks,
even when the final value fits. The demo leaves both its bento tiles and number
containers unclipped; the renderer handles vertical reel clipping separately.

### Locale and accessibility boundaries

All values use native Intl formatting, including bigint, negative zero, accounting
signs, percentages and alternate grouping. **Rolling currently targets standard
Latin-digit formats in LTR layout.** RTL surroundings/scripts, non-Latin digits, compact/scientific/engineering
notation, NaN and infinity render as intact static localized text. They are not
silently transliterated or forced into LTR layout.

Assistive technology receives one formatted value; decorative glyphs are hidden.
The native value is selectable and copyable, including at rest. Copying uses the
latest formatted target once, not the temporary reel glyphs; while motion is in
progress that target may differ from the intermediate painted digits.
There is no default live region. Applications can opt into `aria-live="polite"`
and `aria-atomic="true"` for a deliberately paced announcement. Reduced-motion
changes settle active animations immediately.

## Performance, without the superlatives

NumberFlow is the inspiration and the comparison target, not copied source.
The benchmark pins **NumberFlow 0.6.2**, measures production code, includes a
plain-text floor, counts shadow-DOM elements, and reports repeated measurements.

```sh
bun run bench
```

See [the methodology](https://github.com/kitlangton/rolling-number/blob/main/perf/method.md) and [research and design tradeoffs](https://github.com/kitlangton/rolling-number/blob/main/docs/research.md).
Benchmarks are workload- and browser-specific. A smaller DOM or no per-frame
JavaScript does not, on its own, prove smoother presented frames or universal speed.

The [latest local DOM comparison](https://github.com/kitlangton/rolling-number/blob/main/perf/current.md) measured **59.8% less
main-thread work and 56.7% fewer retained elements** than NumberFlow 0.6.2 for
100 synchronized counters in Chromium 151 on an Apple M2 Max. Both libraries had
long frame intervals in this stress workload. The report includes the source
fingerprint, spread, limits, and raw runs—not a claim that every application or
browser is faster. [Earlier results](https://github.com/kitlangton/rolling-number/blob/main/perf/results.md) remain for historical context.

## Development

Start with [CONTRIBUTING.md](https://github.com/kitlangton/rolling-number/blob/main/CONTRIBUTING.md) for setup, focused tests, PR guidance,
and the distinction between library releases and website deployment. The short
[AGENTS.md](https://github.com/kitlangton/rolling-number/blob/main/AGENTS.md) map covers rendering invariants and validation expectations.

```sh
bun run check
bunx playwright install chromium firefox webkit
bun run test:browser
bun run build:demo
```

Tests cover formatting, exact bigint handling, interruption continuity, bounded
cleanup, proportional fonts, reduced motion, hidden → visible transitions, and
React hydration under StrictMode and Solid hydration/reactive cleanup in Chromium,
Firefox and WebKit.

The dark-only demo includes a fictional tee shop, a hold-to-charge Super Like,
team seats, a BigInt event viewer, upload, weather, invoice, audience, Scrub, and
direct word transitions. Their SVG illustrations are local and original;
no purchases, uploads or external account changes take place. The header is a plain
wordmark. Buying the demo tee brightens revenue, then fades back over 1.8 seconds;
reduced motion disables that flash. The examples do not re-render on every hero tick.

The unlisted `/benchmarks.html` page compares the React integrations of Rolling
Number, NumberFlow, React Animated Numbers, and React CountUp. It is not linked in
the showcase navigation and asks crawlers not to index it; this is not access
control. Its in-browser elapsed/rAF measurements are separate from the CLI's DOM
task-time benchmark. CountUp is numeric interpolation, not a glyph-roll equivalent.

`dist/` contains ESM and declarations plus an explicit stylesheet. There is no
automatic global style injection, custom-element registration, or server-side DOM
access. See [LICENSE](https://github.com/kitlangton/rolling-number/blob/main/LICENSE).

### Website deployment

`bun run deploy` builds the showcase, then deploys static assets to
Cloudflare Workers at [rolling.kitlangton.dev](https://rolling.kitlangton.dev).
Wrangler uses the operator's Cloudflare login; no credentials belong in the repo.
