XGitHub

NumberFlow Integration with Numora React

NumberFlow by Maxwell Barvian is an animated number transition library. This guide layers a <NumberFlow> on top of a transparent-text Numora React NumoraInput so the editable surface itself appears animated. The real <input> still handles every keystroke; NumberFlow just renders what the user sees.

FormatOn.Change - digits animate on every keystroke.

How the overlay works

Native <input> elements render their value as a string with no child DOM, so animation libraries can't inject animated spans into them directly. The overlay sidesteps that constraint by stacking two layers in the same box:

  • Visible layer: a <NumberFlow> that animates the formatted display number.
  • Keyboard layer: the real NumoraInput positioned on top with color: transparent and a visible caret. It still owns focus, keystrokes, selection, undo, IME, and mobile inputmode.

Both layers render the same formatted number. As the user types, numora's onChange fires; the raw string is converted to a Number and passed to <NumberFlow>; 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. Unlike Torph (which animates strings), NumberFlow animates numbers. That crosses the string → number boundary at the display seam and introduces fidelity caveats around trailing decimals, partial input states, and very large values. Documented below.

Installation

pnpm add numora-react @number-flow/react
# or
npm install numora-react @number-flow/react

Pattern 1 - Overlay (animated input surface)

Implementation is short. The key constraints: identical typography on both layers, FormatOn.Change on the input, and a dynamic minimumFractionDigits on NumberFlow so the visible width tracks what the user typed (including trailing zeros).

import NumberFlow from '@number-flow/react'
import { FormatOn } from 'numora'
import { NumoraInput, type NumoraInputChangeEvent } from 'numora-react'
import { useEffect, useState } from 'react'

function AnimatedInput() {
  const [value, setValue] = useState('')
  const [mounted, setMounted] = useState(false)

  // NumberFlow is client-only (uses ResizeObserver). Defer until hydration.
  useEffect(() => { setMounted(true) }, [])

  // Mirror the input's typed decimals so trailing zeros line up under the caret.
  const decimals = value.includes('.') ? value.split('.')[1].length : 0
  const numericValue = value === '' ? 0 : Number(value)

  return (
    <label className="relative inline-flex items-center text-4xl font-mono leading-none text-white">
      {/* Visible layer: NumberFlow animates the digits */}
      <span aria-hidden="true" className="pointer-events-none whitespace-pre tabular-nums">
        {mounted ? (
          <NumberFlow
            value={numericValue}
            format={{
              useGrouping: true,
              minimumFractionDigits: decimals,
              maximumFractionDigits: decimals,
            }}
          />
        ) : '0'}
      </span>

      {/* Invisible layer: real input owns the keyboard */}
      <NumoraInput
        value={value}
        onChange={(e: NumoraInputChangeEvent) => setValue(e.target.value)}
        formatOn={FormatOn.Change}
        maxDecimals={2}
        thousandSeparator=","
        aria-label="Amount"
        className="absolute inset-0 w-full h-full m-0 p-0 border-0 bg-transparent
                   text-transparent placeholder-transparent caret-white
                   outline-none focus:outline-none selection:bg-white/25
                   text-4xl font-mono leading-none"
      />
    </label>
  )
}

Why each line matters

  • 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". Computing decimals from 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 ",", which lines up with thousandSeparator=",".
  • mounted gate - @number-flow/react is a client component (uses DOM APIs and ResizeObserver). Render a plain string at SSR; mount NumberFlow after hydration. In Next.js App Router, mark the page with 'use client' instead.
  • text-transparent + caret-white - hides the input's text but keeps the browser-rendered caret. The caret is the only thing the user sees from the real input.
  • Matching typography on both layers (text-4xl font-mono leading-none) - the caret position is computed from the input's text layout. If fonts differ, the caret drifts.
  • tabular-nums on the overlay - keeps digit widths stable while NumberFlow animates, reducing layout shift during transitions.
  • m-0 p-0 border-0 - browser default input padding shifts the text origin. Zero padding aligns the input's text rendering with the overlay's text rendering.
  • selection:bg-white/25 - the input's text is transparent, so a default opaque selection rect would obscure the NumberFlow layer. A translucent selection lets the animated text show through.
  • placeholder-transparent - the input's own placeholder would otherwise be visible. The overlay shows "0" as a fallback instead.
  • aria-hidden on the overlay span - screen readers should announce the <input>, not the visible decoration. The input has aria-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". This is unfixable without forking how NumberFlow renders. 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 (passed to your math / 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.

Pattern 2 - Display animation alongside the input

The conservative pattern. The user types into a plain NumoraInput; a separate NumberFlow elsewhere on the page (a converted amount, a running total, a portfolio balance) animates as the value changes. Both views share the same raw string state. No transparent-text trickery, no caret drift, no precision lies at the display layer.

import NumberFlow from '@number-flow/react'
import { NumoraInput, type NumoraInputChangeEvent } from 'numora-react'
import { useState } from 'react'

function SwapForm() {
  const [amount, setAmount] = useState('')

  const displayValue = amount === '' ? 0 : Number(amount)
  const exchangeRate = 1850.42
  const converted = displayValue * exchangeRate

  return (
    <div>
      <label>
        You pay (ETH)
        <NumoraInput
          value={amount}
          onChange={(e: NumoraInputChangeEvent) => setAmount(e.target.value)}
          maxDecimals={6}
          thousandSeparator=","
          placeholder="0.0"
        />
      </label>

      <div>
        You receive (USD):{' '}
        <NumberFlow
          value={converted}
          format={{ style: 'currency', currency: 'USD', maximumFractionDigits: 2 }}
        />
      </div>
    </div>
  )
}

The input itself snaps (no animation) - typing latency is unchanged. Only the converted-amount readout animates. This is the cheapest integration path and keeps the precision contract intact: the editable string never round-trips through Number.

Pattern 3 - Blur-mode swap

If you want the editable field itself to look animated when the user isn't typing, swap the input out for a NumberFlow display on blur. On focus, swap back. Cleaner than the overlay (no caret drift, no transparent text) at the cost of a focus/blur context switch.

import NumberFlow from '@number-flow/react'
import { FormatOn } from 'numora'
import { NumoraInput } from 'numora-react'
import { useState } from 'react'

function AnimatedField() {
  const [amount, setAmount] = useState('')
  const [editing, setEditing] = useState(false)

  if (editing) {
    return (
      <NumoraInput
        autoFocus
        value={amount}
        onChange={(e) => setAmount(e.target.value)}
        onBlur={() => setEditing(false)}
        formatOn={FormatOn.Blur}
        maxDecimals={2}
        thousandSeparator=","
      />
    )
  }

  return (
    <button type="button" onClick={() => setEditing(true)}>
      <NumberFlow
        value={amount === '' ? 0 : Number(amount)}
        format={{ minimumFractionDigits: 2 }}
      />
    </button>
  )
}

Tradeoff: the visual context-switch on focus/blur is more jarring than always-on display animation. Use FormatOn.Blur on the input so the value shown after editing matches what was just typed (separators applied on blur).

Pattern 4 - Read-only animated totals

For read-only sections of a page (a portfolio summary that updates from a live price feed, an aggregated total across several inputs), skip NumoraInput entirely and use NumberFlow directly.

import NumberFlow from '@number-flow/react'

function PortfolioTotal({ totalUsd }: { totalUsd: number }) {
  return (
    <div>
      Total balance:{' '}
      <NumberFlow
        value={totalUsd}
        format={{ style: 'currency', currency: 'USD' }}
      />
    </div>
  )
}

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. Keep the raw string in state and pass it to anything that needs precision (math libraries, API calls, blockchain transactions). The overlay's Number() conversion is for display only.
  • The input still owns the keyboard. Undo, redo, IME, paste, mobile inputmode="decimal", native form submission all keep working in Pattern 1. The overlay is a display layer.
  • Mirror typed decimals into minimumFractionDigits. Without this NumberFlow drops trailing zeros and the caret drifts off the end of the rendered number.
  • Pattern 2 is the safe default. Animate readouts, not the editable field. No precision compromise, no caret drift. Pick Pattern 1 only when "the input itself animates" is the visual identity you want.
  • RSC compatibility: @number-flow/react is a client component. Mark any Next.js App Router page using it with 'use client'.

FAQ

What is NumberFlow?

NumberFlow is an animated number transition library by Maxwell Barvian. It tweens between two numeric values by morphing each digit, supports Intl.NumberFormat options for currency and grouping, and ships both a React component (@number-flow/react) and a vanilla web component (number-flow).

How do I use NumberFlow with React?

Import NumberFlow from @number-flow/react and render it as the visible layer of a transparent-text NumoraInput overlay. Convert the raw string via Number(rawValue) for the value prop, and mirror the number of typed decimals through minimumFractionDigits so trailing zeros stay aligned with the caret.

Does NumberFlow respect prefers-reduced-motion?

Yes. NumberFlow respects the OS-level prefers-reduced-motion setting by default. If the user opts out, NumberFlow becomes an instant swap - no extra code needed.

Where can I install NumberFlow?

NumberFlow lives at number-flow.barvian.me and is published on npm as @number-flow/react (React) and number-flow (vanilla web component). Install with npm install numora-react @number-flow/react.