Skip to content

Repository files navigation

React number flow input

NPM VersiongzippedNPM Downloads
Follow daformat on GitHubFollow daformat on X

A zero-dependency React component that renders an animated number input. Digits animate in as they are typed, selecting and replacing a single digit gives you the popular barrel-wheel effect made famous by NumberFlow, and external value changes animate as a coordinated barrel-wheel roll across every digit.

Demo

https://hello-mat.com/design-engineering/component/number-flow-input

Features

  • Zero runtime dependencies — peer-depends on React >= 18, nothing else.
  • Two synchronized inputs — a contenteditable for the animated display and a hidden <input> for native form integration (name, form, required, ...).
  • Controlled or uncontrolled — use value or defaultValue.
  • Locale-aware formatting — optional Intl.NumberFormat thousand separators and locale decimal characters.
  • Smart editing — undo/redo, copy/cut/paste, decimal-scale clamping, max-length, negative numbers, leading-zero handling, etc.
  • Custom validationisAllowed(value) predicate to reject values you don't like.
  • Animations included — digit flow-in, barrel-wheel digit rolls, separator slide-in/out, width animation on group changes.
  • Minimal styles auto-injected — a <style> tag is added to <head> on first mount, no CSS import required. SSR-safe.
  • Fully typed — ships with TypeScript types.
  • Well tested — 220+ unit and integration tests.

Installation

npm install @daformat/react-number-flow-input
yarn add @daformat/react-number-flow-input
pnpm add @daformat/react-number-flow-input
bun add @daformat/react-number-flow-input
deno add npm:@daformat/react-number-flow-input

Quick start

import{NumberFlowInput}from"@daformat/react-number-flow-input";exportfunctionExample(){return(<NumberFlowInputdefaultValue={1234}formatonChange={(value)=>console.log(value)}/>);}

Usage

Uncontrolled

<NumberFlowInputdefaultValue={42}onChange={(value)=>console.log(value)}/>

Controlled

import{useState}from"react";import{NumberFlowInput}from"@daformat/react-number-flow-input";functionControlled(){const[value,setValue]=useState<number|undefined>(0);return<NumberFlowInputvalue={value}onChange={setValue}/>;}

External updates to value are diffed against the previous value and animate as a coordinated barrel-wheel roll. Initial mount never animates.

To opt out of animations on external value changes — for example when restoring a value programmatically or when binding to a noisy state source — pass animateOnValueChange={false}:

<NumberFlowInputvalue={value}onChange={setValue}animateOnValueChange={false}/>

User typing and format / locale toggles still animate; only the prop-driven value updates snap.

Formatted display

<NumberFlowInputformatvalue={1234567}/>// → "1,234,567"<NumberFlowInputformatlocale="de-DE"value={1234567}/>//"1.234.567"

Custom formatter

format also accepts a function for full control over the displayed string. The callback receives the raw display value (digits, optional leading -, optional single . as decimal) and must return the formatted text:

<NumberFlowInputdefaultValue={1234.5}format={(raw)=>`$ ${raw}`}onChange={console.log}/>// → "$ 1234.5"

The callback is only invoked for "real" values — empty / "-" / "." / "-." intermediate states bypass it and render verbatim. If your function throws, the component falls back to a safe locale-decimal-swap output.

For correct cursor positioning and animation diffing, your output should:

  • use the locale's decimal character (or . if no locale is set);
  • preserve the digit order from the raw input.

Decimal scale & negative numbers

<NumberFlowInputallowNegativedecimalScale={2}defaultValue={-1234.5}format/>

decimalScale={0} prevents the user from typing a decimal point at all. decimalScale={n} clamps the number of fractional digits.

Locale

<NumberFlowInputlocale="fr-FR"defaultValue={1234.5}format/>// Renders "1 234,5" (or the locale's group separator).

The component accepts both . and the locale's decimal separator as input — typing either one resolves to the locale's decimal in the display.

Custom validation

<NumberFlowInputisAllowed={(value)=>value==null||(value>=0&&value<=100)}/>

Any keystroke that would produce a value outside the allowed range is rejected and never reaches onChange.

Length limit

<NumberFlowInputmaxLength={6}/>

Form integration

The component renders a hidden <input> (offscreen, readonly) that mirrors the current numeric value, so it participates in native form submissions:

<formaction="/submit"method="post"><NumberFlowInputname="price"requiredmin={0}max={9999}defaultValue={0}/><buttontype="submit">Save</button></form>

name, form, required, min, max, minLength and maxLength are forwarded to the hidden input.

Auto focus / events

<NumberFlowInputautoFocusonFocus={()=>console.log("focused")}onBlur={()=>console.log("blurred")}/>

Ref

The ref is forwarded to the contenteditable element:

constref=useRef<HTMLElement>(null);<NumberFlowInputref={ref}/>;

API

importtype{NumberFlowInputProps,NumberFlowInputCommonProps,NumberFlowInputControlledProps,NumberFlowInputUncontrolledProps,}from"@daformat/react-number-flow-input";

Value props

PropTypeDefaultDescription
valuenumber | string | undefinedControlled value. Accepts a number or a numeric string (see String values). Changes animate as a barrel-wheel roll (except on initial mount).
defaultValuenumber | stringUncontrolled starting value. Accepts the same shapes as value.
onChange(value) => voidCalled with the parsed number (or undefined for intermediate states like "", "-", ".", "-.").
onChangeText(rawText) => voidFires alongside onChange with the raw string representation (e.g. "12345678901234567890.123"). Use this when you need to preserve precision beyond JavaScript's number — see Precision.
animateOnValueChangebooleantrueWhen false, external value updates snap instantly — no digit-roll, no separator slide, no flow animation. Typing and format / locale toggles still animate.

value and defaultValue are mutually exclusive — TypeScript will enforce this.

Formatting

PropTypeDefaultDescription
formatboolean | (raw: string) => stringfalsetrue → group via Intl.NumberFormat. A function takes full control of the output (see Custom formatter).
localestring | Intl.LocaleLocale used for decimal and group separators. Defaults to the runtime's locale.
decimalScalenumberMax number of fractional digits. 0 forbids a decimal point entirely.
autoAddLeadingZerobooleanfalseConvert leading .50.5 (and -.5-0.5) automatically.
allowNegativebooleanfalseAllow typing a leading - to enter negative numbers.

Editing constraints

PropTypeDescription
maxLengthnumberMaximum raw length the user can type (counted before formatting).
minLengthnumberForwarded to the hidden <input> for form validation.
min/maxnumberForwarded to the hidden <input> for form validation.
isAllowed(value: number | null) => boolPredicate that gates every change. Return false to reject the keystroke.

DOM / form passthroughs

id, name, form, required, placeholder, className, style, onFocus, onBlur, autoFocus. className and style are applied to the root wrapper <span>.

Styling

Styles are injected globally on first mount. Every selector is scoped to [data-numberflow-input-root], so they won't leak into your app.

The DOM structure (simplified):

<spandata-numberflow-input-rootclass="{className}"><spandata-numberflow-input-wrapper><spanrole="textbox"
contenteditable="true"
data-numberflow-input-contenteditabledata-placeholder="{placeholder}"
><spandata-char-index="0" data-flowdata-show>1</span><spandata-char-index="1">,</span><!-- ...one span per character... --></span><inputdata-numberflow-input-real-inputtype="text" readonly/><!-- barrel-wheel overlays are appended here while animating --></span></span>

You can target any of the above data attributes to customize the look:

[data-numberflow-input-contenteditable] {
font-variant-numeric: tabular-nums;
font-feature-settings:"tnum";
}
[data-numberflow-input-contenteditable]:empty::before {
color:#999; /* placeholder color */
}

Animation timings live in the injected stylesheet and use cubic-bezier(.215, .61, .355, 1) (ease-out-cubic). The flow-in animation is 0.2s; the barrel-wheel roll and width animation are 0.4s.

Precision

The component is built around a string-based internal representation, so what the user types is preserved character-by-character — there's no silent rounding inside the input itself. Where you can run into precision loss is at the boundaries of JavaScript's number type:

BoundaryLossy?Reason
User typing → DOM displayNoEvery keystroke is applied to a string; the DOM is updated with textContent.
User typing → onChange(value)Yes, for > Number.MAX_SAFE_INTEGER or > 15–17 sig. figs.value is parseFloat(rawText); IEEE 754 double cannot represent every decimal exactly.
Formatted display, integer partYes, for integers > Number.MAX_SAFE_INTEGERWhen format is on, the integer part is re-formatted through Intl.NumberFormat.format(parseFloat(rawText)).
Formatted display, decimal partNoThe decimal part is restored verbatim from the raw string after Intl formatting.
value prop → displayInherits the precision of the value the parent already computed.E.g. 0.1 + 0.2 === 0.30000000000000004 — the component displays exactly what JS gave it.
defaultValue prop → displaySame as above.

String values

To complete the round-trip for arbitrary-precision use cases, value and defaultValue also accept a numeric string in addition to a number:

// Preserves trailing zeros that a `number` would drop<NumberFlowInputvalue="1.50"onChangeText={setRaw}/>// Preserves integers beyond Number.MAX_SAFE_INTEGER<NumberFlowInputvalue="12345678901234567890"onChangeText={setRaw}/>// Currency stored as a string<NumberFlowInputdefaultValue="100.00"format/>

Strings are sanitized with the same pipeline as user input — only characters matching /^-?\d*\.?\d*$/ survive. Junk strings collapse to an empty value (e.g. "$1,234.56""1234.56", "abc"""). Use . as the decimal separator regardless of locale.

onChange always receives a parsed number (so the JavaScript precision boundary still applies on that side). Pair string props with onChangeText if your parent state must keep full precision.

onChangeText for arbitrary-precision values

When you need the exact digits the user typed (BigInt math, currency stored as strings, big-decimal libraries, etc.), use the onChangeText callback — it fires alongside onChange with the raw string representation:

import{useState}from"react";import{NumberFlowInput}from"@daformat/react-number-flow-input";functionHugeNumber(){const[raw,setRaw]=useState("");return(<><NumberFlowInputonChangeText={setRaw}/><p>BigInt: {raw==="" ? "—" : BigInt(raw.split(".")[0]).toString()}</p></>);}

The string is the unformatted internal representation: digits, an optional leading -, and at most one . (always ., never the locale decimal). Intermediate states like "", "-", ".", "-." are surfaced as-is so consumers can render them if they want.

If you only consume onChange (the typical case), be aware that values past 9.007 × 10¹⁵ (the safe-integer ceiling) or with more than ~17 significant digits will round.

Server-side rendering

injectStyles() is a no-op on the server and idempotent on the client. The component itself only touches the DOM inside useInsertionEffect / useEffect, so it renders cleanly in Next.js, Remix and other SSR frameworks.

Browser support

Modern evergreen browsers. Required browser features:

  • Intl.NumberFormat (for format / locale)
  • Web Animations API (element.animate(...)) — used for the barrel-wheel and position animations
  • CSS transition + transform — used for flow-in animation
  • requestAnimationFrame, ResizeObserver

Development

pnpm install
pnpm test# vitest run
pnpm build --watch # tsc -p tsconfig.build.json
pnpm format # prettier --write .
pnpm lint:js # eslint .

Project layout

src/
├── NumberFlowInput.tsx # The component
├── styles.ts # Injected stylesheet
├── index.ts # Public entry point
└── utils/
├── barrelWheel.ts # Wheel DOM helpers
├── changes.ts # Diffing (typing & replacement)
├── combineRefs.ts # Ref forwarding helper
├── cssEasing.ts # Cubic-bezier tokens
├── formatting.ts # Intl.NumberFormat wrapper
├── moveElementPreservingAnimation.ts
├── textCleaning.ts # Raw text sanitization
└── utils.ts # DOM/measurement helpers

Every util has its own *.test.ts file next to it; component-level tests live in src/NumberFlowInput.test.tsx.

License

Zero-Clause BSD — do whatever you want with it.

About

A zero-dependency React component that renders an animated number-flow like number input

Topics

Resources

Stars

15 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages