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 · Contributing · Agent guide · MIT license
bun add @kitlangton/rolling-numberOr use npm install @kitlangton/rolling-number. The unscoped name belongs to
another project. Import the stylesheet alongside the DOM, React, or Solid entrypoint.
The site serves these docs as Markdown: rolling.kitlangton.dev/llms.txt
indexes them, rolling.kitlangton.dev/index.md
is this README, and the page itself answers Accept: text/markdown with the same
content.
git clone https://github.com/kitlangton/rolling-number.git
cd rolling-number
bun install
bun run devThe 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.
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.
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.
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.
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.
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.
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 textThe controller owns the host's children until destruction. destroy() is
idempotent. Invalid values/options throw before replacing the current display.
| 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 for its overhead and limits.
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(default1) scales the optional motion blur per counter; set it on the host or any ancestor. It is read during measurement, never during playback.--rn-maskand--rn-edge-fadecontrol the reel's soft top and bottom edges;--rn-creaseis the hinge gap between split-flap halves (default1px).- Wheel slots carry
data-rn-wheel(anddata-rn-flapin flap mode); symbol slots do not.
- 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.
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:
.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.
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.
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.
bun run benchSee the methodology and research and design tradeoffs. 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 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 remain for historical context.
Start with CONTRIBUTING.md for setup, focused tests, PR guidance, and the distinction between library releases and website deployment. The short AGENTS.md map covers rendering invariants and validation expectations.
bun run check
bunx playwright install chromium firefox webkit
bun run test:browser
bun run build:demoTests 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.
bun run deploy builds the showcase, then deploys static assets to
Cloudflare Workers at rolling.kitlangton.dev.
Wrangler uses the operator's Cloudflare login; no credentials belong in the repo.