NumberFlow Vanilla Integration with Numora
NumberFlow by Maxwell Barvian ships a vanilla <number-flow> custom element alongside its React wrapper. This guide layers it on top of a transparent-text vanilla NumoraInput so the editable surface itself appears animated. The real <input> still handles every keystroke; NumberFlow just renders what the user sees.
Using React? See the Numora React NumberFlow integration - it wires the overlay through onChange instead of manual DOM listeners.
FormatOn.Change - digits animate on every keystroke.
The demo above is rendered inside this React docs site, but the code below is plain TypeScript - no React. It uses the vanilla NumoraInput class and the <number-flow> custom element directly.
How the overlay works
Native <input> elements render their value as a string with no child DOM, so animation primitives can't inject animated spans into them directly. The overlay sidesteps that by stacking two layers in the same box:
- Visible layer: a
<number-flow>custom element that animates the formatted display number. - Keyboard layer: the vanilla
NumoraInputpositioned on top withcolor: transparentand a visible caret. It still owns focus, keystrokes, undo, IME, and mobileinputmode.
Both layers render the same formatted number. The vanilla NumoraInput class applies formatting in beforeinput via setRangeText, but only runs listeners when an input event follows - which is not guaranteed on every path. The bridge therefore syncs NumberFlow via a beforeinput microtask (typing) and an input listener (undo/redo; paste, since Numora's paste handler dispatches a synthetic input after sanitizing). Each call converts numora.value to a Number, sets flow.format with a matching minimumFractionDigits derived from the typed string, and calls flow.update(). NumberFlow tweens each digit into place. The input itself never animates - but because its text is transparent, you only see the NumberFlow layer.
Experimental pattern. This works well for short, append-mostly numeric fields. Unlike string-based morph libraries, NumberFlow animates numbers, which introduces fidelity caveats around trailing decimals, partial input states, and very large values - documented below.
Installation
pnpm add numora number-flow
# or
npm install numora number-flowPattern 1 - Overlay (animated input surface)
Two DOM nodes in a relative-positioned wrapper, one stylesheet, and a small sync bridge between the two libraries.
<label class="numora-overlay">
<span class="numora-overlay-display" aria-hidden="true"></span>
<div class="numora-overlay-host"></div>
</label>.numora-overlay {
position: relative;
display: inline-flex;
align-items: center;
font: 2.25rem/1 ui-monospace, SFMono-Regular, monospace;
color: white;
}
.numora-overlay-display {
pointer-events: none;
white-space: pre;
font-variant-numeric: tabular-nums;
}
.numora-overlay-host {
position: absolute;
inset: 0;
}
/* Style the <input> that NumoraInput creates inside the host */
.numora-overlay-host input {
width: 100%;
height: 100%;
margin: 0;
padding: 0;
border: 0;
background: transparent;
color: transparent;
caret-color: white;
outline: none;
font: inherit;
}
.numora-overlay-host input::selection {
background: rgba(255, 255, 255, 0.25);
}
.numora-overlay-host input::placeholder {
color: transparent;
}import { FormatOn, NumoraInput, ThousandStyle } from 'numora'
import 'number-flow' // registers the <number-flow> custom element
const display = document.querySelector<HTMLSpanElement>('.numora-overlay-display')!
const host = document.querySelector<HTMLDivElement>('.numora-overlay-host')!
const flow = document.createElement('number-flow')
display.appendChild(flow)
const numora = new NumoraInput(host, {
formatOn: FormatOn.Change,
decimalMaxLength: 2,
thousandSeparator: ',',
value: '1234567',
thousandStyle: ThousandStyle.Thousand,
})
const input = numora.getElement()
input.setAttribute('aria-label', 'Amount')
function syncFlow() {
const raw = numora.value
const decimals = raw.includes('.') ? raw.split('.')[1].length : 0
// Mirror typed decimals so trailing zeros stay aligned with the caret.
flow.format = {
useGrouping: true,
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
}
flow.update(raw === '' ? 0 : Number(raw))
}
syncFlow()
// Numora mutates via setRangeText in beforeinput; the input event isn't guaranteed
// on every path, so sync from both.
input.addEventListener('beforeinput', () => queueMicrotask(syncFlow))
input.addEventListener('input', syncFlow)Why each line matters
import 'number-flow'- the side-effect import registers<number-flow>as a custom element oncustomElements. The library guards the registration call so it's safe to import in SSR bundles.formatOn: FormatOn.Change- keeps the input's display as the formatted string at all times, matching what NumberFlow shows.- Dynamic
minimumFractionDigits-Number("1.20") = 1.2, so without this NumberFlow renders"1.2"while the input contains"1.20". Computingdecimalsfrom the raw string and feeding it to NumberFlow keeps the visible width and the caret aligned. useGrouping: true- NumberFlow's grouping must match the input's thousand separator. The default en-US locale uses",", lining up withthousandSeparator=","andThousandStyle.Thousand.beforeinputmicrotask sync - numora applies its formatted value insidebeforeinputviasetRangeText. By the next microtasknumora.valuereflects the post-format string. Aninputlistener alone misses some paths (notably native browser cancellation cases on certain devices).- Transparent text + visible caret on the input - hides the input's own rendering while letting the caret show. The caret is the only thing the user sees from the real input.
- Matching typography on both layers - the caret position is computed from the input's text layout. If fonts differ between the input and the NumberFlow output, the caret drifts.
tabular-numson the overlay span - keeps digit widths stable while NumberFlow animates, reducing layout shift during transitions.aria-hiddenon the overlay span - screen readers should announce the<input>, not the visible decoration. The input hasaria-label.
Caveats
Partial-input states. NumberFlow takes a number. While the user is typing "1." (a digit then a dot, no decimals yet), Number("1.") is 1 and NumberFlow renders "1" with no dot. The caret hovers just past the (invisible) input's dot, which is one character beyond the visible "1". Brief and self-correcting once a fractional digit is typed.
Precision ceiling. Numora handles strings of any length. Number() is safe up to 15 significant digits. For DeFi token amounts at 18-decimal precision the overlay loses tail digits at the display layer - but the editable string (numora.value, what you pass to your math and contracts) is untouched. The overlay is a display lie, not a state lie.
Caret drift during animation. The caret's pixel position is computed from the input's invisible text layout, which jumps to the new value instantly. NumberFlow animates digits into that final position over ~150ms. Mid-flight, the caret briefly floats next to characters that haven't arrived yet.
Mid-string editing is limited. Clicking the overlay span passes the click through to the input, but the browser's hit-test runs against the input's invisible text. During NumberFlow's mid-animation width transitions, click-to-caret lands on the wrong character. For append-only fields this doesn't matter; for fields where users edit mid-number, this pattern isn't a fit.
Mobile selection handles. iOS draws magnifier loupes and selection handles anchored to the input's text. With the text invisible, the loupe shows transparent characters. Not a blocker for short numeric entry, but test on real devices.
Pattern 2 - Read-only animated values
For dashboards or oracle-fed prices that update outside the input, skip NumoraInput entirely and update <number-flow> directly from your data source:
import 'number-flow'
const flow = document.querySelector<HTMLElement & {
format?: Intl.NumberFormatOptions
update(value: number): void
}>('number-flow')!
flow.format = { style: 'currency', currency: 'USD' }
// Then whenever your data changes:
flow.update(newBalance)This isn't really an integration - it's just the right tool for read-only animated numbers. Use it wherever the value is a number that already exists in your state and you don't need string-precision math at the display point.
Reducing motion
NumberFlow respects prefers-reduced-motion by default. If a user opts out of animations at the OS level the digit morph becomes an instant swap - no extra code needed.
Key points
- NumoraInput stays the source of truth. Read
numora.valuefor anything that needs precision (math libraries, API calls, blockchain transactions). The overlay'sNumber()conversion is for display only. - The input still owns the keyboard. Undo, redo, IME, paste, mobile
inputmode="decimal", native form submission all keep working. The overlay is a display layer. - Mirror typed decimals into
flow.format.minimumFractionDigits. Without this NumberFlow drops trailing zeros and the caret drifts off the end of the rendered number. - Sync from
beforeinputviaqueueMicrotask. Aninputlistener alone misses some paths in the vanilla pipeline; the microtask catches the post-setRangeTextstate. - Not a free upgrade. The overlay trades caret precision and mid-string editing fidelity for an animated surface. For append-only inputs the trade is reasonable; for general numeric fields, weigh the caveats above against the visual payoff.
FAQ
What is the NumberFlow vanilla web component?
NumberFlow is an animated number transition library by Maxwell Barvian. The vanilla number-flow package registers a <number-flow> custom element that tweens between two numeric values by morphing each digit, with Intl.NumberFormat options for currency and grouping. No framework required.
Does NumberFlow work without React?
Yes. This guide uses the <number-flow> web component from the number-flow package together with the vanilla NumoraInput class - no React in the runtime. The custom element is a standard browser primitive and works in any framework or none at all.
How does the NumberFlow + Numora overlay work?
A transparent-text NumoraInput sits on top of a <number-flow> element. The input owns the keyboard, undo, IME, and mobile inputmode. A beforeinput microtask plus an input listener convert numora.value to a Number, set flow.format with a matching minimumFractionDigits, and call flow.update() so NumberFlow animates the visible digits.
Where can I install NumberFlow?
NumberFlow lives at number-flow.barvian.me/vanilla and is published on npm as number-flow (vanilla web component) and @number-flow/react (React). Install the vanilla pair with npm install numora number-flow.