A zero-dependency React component that renders an animated number-flow like 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.
https://hello-mat.com/design-engineering/component/number-flow-input
- Zero runtime dependencies — peer-depends on React
>= 18, nothing else. - Two synchronized inputs — a
contenteditablefor the animated display and a hidden<input>for native form integration (name,form,required, ...). - Controlled or uncontrolled — use
valueordefaultValue. - Locale-aware formatting — optional
Intl.NumberFormatthousand separators and locale decimal characters. - Smart editing — undo/redo, copy/cut/paste, decimal-scale clamping, max-length, negative numbers, leading-zero handling, etc.
- Custom validation —
isAllowed(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.
- 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 — 228+ unit and integration tests.
npm install @daformat/react-number-flow-inputyarn add @daformat/react-number-flow-inputpnpm add @daformat/react-number-flow-inputbun add @daformat/react-number-flow-inputdeno add npm:@daformat/react-number-flow-inputimport { NumberFlowInput } from "@daformat/react-number-flow-input";
export function Example() {
return (
<NumberFlowInput
defaultValue={1234}
format
onChange={(value) => console.log(value)}
/>
);
}<NumberFlowInput defaultValue={42} onChange={(value) => console.log(value)} />import { useState } from "react";
import { NumberFlowInput } from "@daformat/react-number-flow-input";
function Controlled() {
const [value, setValue] = useState<number | undefined>(0);
return <NumberFlowInput value={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.
<NumberFlowInput format value={1234567} /> // → "1,234,567"
<NumberFlowInput format locale="de-DE" value={1234567} /> // → "1.234.567"<NumberFlowInput allowNegative decimalScale={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.
<NumberFlowInput locale="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.
<NumberFlowInput
isAllowed={(value) => value == null || (value >= 0 && value <= 100)}
/>Any keystroke that would produce a value outside the allowed range is rejected and never reaches onChange.
<NumberFlowInput maxLength={6} />The component renders a hidden <input> (offscreen, readonly) that mirrors the current numeric value, so it participates in native form submissions:
<form action="/submit" method="post">
<NumberFlowInput name="price" required min={0} max={9999} defaultValue={0} />
<button type="submit">Save</button>
</form>name, form, required, min, max, minLength and maxLength are forwarded to the hidden input.
<NumberFlowInput
autoFocus
onFocus={() => console.log("focused")}
onBlur={() => console.log("blurred")}
/>The ref is forwarded to the contenteditable element:
const ref = useRef<HTMLElement>(null);
<NumberFlowInput ref={ref} />;import type {
NumberFlowInputProps,
NumberFlowInputCommonProps,
NumberFlowInputControlledProps,
NumberFlowInputUncontrolledProps,
} from "@daformat/react-number-flow-input";| Prop | Type | Description |
|---|---|---|
value |
number | undefined |
Controlled value. When provided, changes animate as a barrel-wheel roll (except on initial mount). |
defaultValue |
number |
Uncontrolled starting value. |
onChange |
(value) => void |
Called with the parsed number (or undefined for intermediate states like "", "-", ".", "-."). |
valueanddefaultValueare mutually exclusive — TypeScript will enforce this.
| Prop | Type | Default | Description |
|---|---|---|---|
format |
boolean |
false |
When true, the display uses Intl.NumberFormat grouping. |
locale |
string | Intl.Locale |
— | Locale used for decimal and group separators. Defaults to the runtime's locale. |
decimalScale |
number |
— | Max number of fractional digits. 0 forbids a decimal point entirely. |
autoAddLeadingZero |
boolean |
false |
Convert leading .5 → 0.5 (and -.5 → -0.5) automatically. |
allowNegative |
boolean |
false |
Allow typing a leading - to enter negative numbers. |
| Prop | Type | Description |
|---|---|---|
maxLength |
number |
Maximum raw length the user can type (counted before formatting). |
minLength |
number |
Forwarded to the hidden <input> for form validation. |
min/max |
number |
Forwarded to the hidden <input> for form validation. |
isAllowed |
(value: number | null) => bool |
Predicate that gates every change. Return false to reject the keystroke. |
id, name, form, required, placeholder, className, style, onFocus, onBlur, autoFocus. className and style are applied to the root wrapper <span>.
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):
<span data-numberflow-input-root class="{className}">
<span data-numberflow-input-wrapper>
<span
role="textbox"
contenteditable="true"
data-numberflow-input-contenteditable
data-placeholder="{placeholder}"
>
<span data-char-index="0" data-flow data-show>1</span>
<span data-char-index="1">,</span>
<!-- ...one span per character... -->
</span>
<input data-numberflow-input-real-input type="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.
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.
Modern evergreen browsers. Required browser features:
Intl.NumberFormat(forformat/locale)- Web Animations API (
element.animate(...)) — used for the barrel-wheel and position animations - CSS
transition+transform— used for flow-in animation requestAnimationFrame,ResizeObserver
pnpm install
pnpm test # vitest run
pnpm build # tsc -p tsconfig.build.json
pnpm format # prettier --write .
pnpm lint:js # eslint .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.
Issues and pull requests are welcome at https://github.com/daformat/react-number-flow-input.
When opening a PR, please:
- Add a changeset (
pnpm changeset) describing the change. - Make sure
pnpm cipasses locally (build + format check + tests). - Add tests next to the code you touched — utils live in
src/utils/*.test.ts, component-level behavior insrc/NumberFlowInput.test.tsx.
Zero-Clause BSD — do whatever you want with it.