An animated number input component, inspired by the Family crypto wallet and Number Flow. Try typing a number below, when you’ll have some numbers typed in, select a single digit and replace it with another digit to see a barrel-wheel effect like the one popularized by Number Flow. You can install the open-source package and start adding animated number inputs to your site or application.
Open the repo in Github (and drop a star if you like it!), view quick-start to get started.
npm install @daformat/react-number-flow-inputBelow is a minimal example that mirrors the demo above. View the full tsx and scss on github.
Note: the component injects its own stylesheet on first mount for layout and animation — you only need to style typography and colors yourself.
import { useState } from "react";
import { NumberFlowInput } from "@daformat/react-number-flow-input";
import styles from "./styles.module.css";
export const Demo = () => {
const [value, setValue] = useState<number | undefined>();
return (
<NumberFlowInput
value={value}
onChange={setValue}
placeholder="0"
maxLength={8}
format
autoAddLeadingZero
className={styles.number_flow_input}
/>
);
};value prop changes (e.g. from a parent fetching new data), every digit rolls into place as a coordinated barrel-wheel animation. The initial mount never animates, and you can opt out of subsequent prop-driven animations entirely with animateOnValueChange={false} — see External value updates.autoAddLeadingZero prop is set to true.maxLength prop.format to render numbers with thousands separators via Intl.NumberFormat.locale (e.g. "de-DE", "fr-FR") to use the locale’s decimal and group separators. The input accepts both . and the locale-specific decimal as input.decimalScale to clamp the number of fractional digits. decimalScale={0} forbids a decimal point entirely.When the value prop changes (e.g. you fetched a new number from your API), every digit barrel-rolls into place by default. To opt out — for instance when re-hydrating from localStorage or restoring a server-driven state — pass animateOnValueChange={false} and the next prop update snaps in instantly. Typing and format / locale toggles still animate.
import { useEffect, useState } from "react";
import { NumberFlowInput } from "@daformat/react-number-flow-input";
export const RestoreFromServer = () => {
const [value, setValue] = useState<number | undefined>();
// Imagine we re-hydrate from /api/me on mount — we don't want the
// initial restore to barrel-roll from 0 to the saved value. Typing
// and format/locale toggles still animate normally.
useEffect(() => {
fetch("/api/me").then((r) => r.json()).then((d) => setValue(d.balance));
}, []);
return (
<NumberFlowInput
value={value}
onChange={setValue}
animateOnValueChange={false}
format
/>
);
};The format prop accepts a function in addition to a boolean. The callback receives the raw display value (digits, an optional leading -, an optional single .) and returns whatever you want rendered. This lets you prefix a currency, group digits in your own way, or pipe the number through a third-party formatter while keeping all of the component’s animation and cursor handling.
import { NumberFlowInput } from "@daformat/react-number-flow-input";
export const CurrencyDemo = () => {
return (
<NumberFlowInput
defaultValue="1234.56"
// `format` accepts a callback that receives the raw display value
// (digits, optional leading "-", optional single ".") and returns
// the string you want rendered. Empty / "-" / "." / "-." intermediate
// states bypass the callback and render verbatim.
format={(raw) => "$ " + raw}
/>
);
};For correct cursor positioning and animation diffing, the callback’s output should use the locale’s decimal character (or . if no locale is set) and preserve the digit order from the raw input. Intermediate states ("", "-", ".", "-.") bypass the callback and render verbatim. If your function throws, the component falls back to a safe locale-decimal-swap output so a buggy formatter can’t break the input.
Internally the component is string-based, so what the user types is preserved character-by-character — there is no silent rounding inside the input itself. The boundary is the JavaScript number type: anything past Number.MAX_SAFE_INTEGER (~9 × 1015) or with more than ~15–17 significant digits cannot be represented exactly. To keep full precision end-to-end, two opt-in mechanisms cooperate:
value and defaultValue accept a numeric string alongside number. Strings are sanitized with the same pipeline as user input — only characters matching /^-?\d*\.?\d*$/ survive, so junk like "$1,234.56" collapses to "1234.56". Use . as the decimal separator regardless of locale.onChangeText fires alongside onChange with the raw string representation (e.g. "12345678901234567890.123"). Pair these two and your parent state never has to touch parseFloat.import { useState } from "react";
import { NumberFlowInput } from "@daformat/react-number-flow-input";
export const PrecisionDemo = () => {
// Track the raw string the user typed. This preserves trailing zeros
// ("1.50"), integers past Number.MAX_SAFE_INTEGER, and decimals with
// more than ~17 significant digits. `value` accepts the string back
// directly — no parseFloat() round-trip required.
const [raw, setRaw] = useState<string | undefined>("12345678901234567890.50");
return (
<>
<NumberFlowInput value={raw} onChangeText={setRaw} placeholder="0" />
<p>
BigInt (integer part):{" "}
{raw ? BigInt(raw.split(".")[0] || "0").toString() : "—"}
</p>
</>
);
};Trailing zeros, big integers, and long decimals all round-trip intact when you wire value ↔ onChangeText. onChange still receives the parsed number if you want a numeric view at the same time.
The component renders an offscreen, read-only <input> that mirrors the current numeric value, so it participates in native form submissions. name, form, required, min, max, minLength and maxLength are forwarded to that hidden input.
The package exports a single component, NumberFlowInput, forwarding its ref to the contenteditable element. It accepts the props below in addition to a few HTML <input> attributes (min, max, minLength, maxLength, form, required, name, id, placeholder, onFocus, onBlur, className, style) that are forwarded to the appropriate element.
value (number | string | undefined): controlled value. When provided, changes animate as a coordinated barrel-wheel roll across every digit (except on initial mount). Strings are sanitized with the same pipeline as user input (/^-?\d*\.?\d*$/) — see Precision for the rationale and a code example.defaultValue (number | string): uncontrolled starting value. Accepts the same shapes as value. Mutually exclusive with value at the TypeScript level.onChange ((value: number | undefined) => void): called with the parsed number (or undefined for intermediate states like "", "-", ".", "-.").onChangeText ((rawText: string) => void): fires alongside onChange with the raw string representation — digits, an optional leading -, an optional single . (always ., never the locale decimal). Use this when you need exact precision (BigInt math, currency stored as strings, big-decimal libraries, etc). Intermediate states ("", "-", ".", "-.") are reported verbatim.format (boolean | ((raw: string) => string), default false): true groups via Intl.NumberFormat; a function takes full control of the output (see Custom formatter).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, default false): convert leading .5 → 0.5 (and -.5 → -0.5) automatically.allowNegative (boolean, default false): allow typing a leading - to enter negative numbers.maxLength (number): maximum raw length the user can type, counted before formatting.isAllowed ((value: number | null) => boolean): predicate that gates every change. Return false to reject the keystroke before it reaches onChange.animateOnValueChange (boolean, default true): when false, external value updates snap instantly — no digit-roll, no separator slide, no flow animation. Typing and format / locale toggles still animate. See External value updates.autoFocus (boolean): focus the contenteditable on mount.placeholder (string): shown when the input is empty, exposed as data-placeholder on the contenteditable for styling.className / style: applied to the root wrapper <span>.ref (Ref<HTMLElement>): forwarded to the contenteditable <span>.The implementation of this component uses contentEditable in order to be able to animate the digits as they are typed. Relying on contentEditable allows the component to use markup per character, contrary to a regular input, but this comes with quite a few caveats.
Since the browser will insert content as the user types, the children of the content editable have to be managed outside of React. We are responsible for DOM manipulation, which can be a little tedious.
Content editable is designed to allow rich text formatting and arbitrary markup to be inserted; for our input we need to prevent this. This is done via event.preventDefault(), simple yet effective.
In rich text, cursor positions move between nodes, this means that when you insert a span the cursor position can be inside or outside of the span. There are therefore more than one logical cursor position mapping to the same visual position, which means the user would have to press the arrow keys twice to move the cursor to the next digit. This is definitely not ideal.
We need to handle cursor positioning ourselves. This also means we need to implement things such as alt+arrow or cmd+arrow in order to move the cursor to the beginning or end of the input (depending on which arrow the user pressed).
Because of this, we also need to implement a custom selection, too.
Since we manipulate the content ourselves and manipulate the DOM directly, we can’t rely on the native history for the content editable, we have to implement history on our own. The implementation here is pretty naive, but it works and restores cursor position as the user undoes/redoes.
Since we have rich text markup in our input and we want to copy a raw number to the clipboard, we also need to handle copy operations ourselves in order to clean the markup.
I wanted an input that animates in a similar way to the Number Flow component, and the Family crypto wallet inputs. Number Flow is great, but it doesn’t really have an input mode. What they do in their examples is that they basically overlay the number flow component on top of an input, but I wanted more animation options than that. In the end, using content editable is quite tricky, because you have to re-implement many things that are a given with a regular input if you want it to feel right and provide a decent UX. The result is available as an open-source package on GitHub.