Torph Library Integration with Numora React
Torph by Lochie Axon is an animated text-morphing library. This guide layers a Torph TextMorph on top of a transparent-text Numora React NumoraInput so the editable surface itself appears animated. The real <input> still handles every keystroke; Torph just renders what the user sees.
Using vanilla JS? See the core Numora Torph integration — it uses NumoraInput and TextMorph directly with a DOM event bridge.
FormatOn.Change - separators animate on every keystroke.
FormatOn.Blur - separators animate out on focus, back in on blur. in progress... (cursor jumping)
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
<TextMorph>that animates the formatted display string. - Keyboard layer: the real
NumoraInputpositioned on top withcolor: transparentand a visible caret. It still owns focus, keystrokes, selection, undo, IME, and mobileinputmode.
Both layers render the same formatted string. As the user types, numora's onChange fires; the formatted value flows into the TextMorph; Torph diffs the old and new strings and animates each digit / separator into place. The input itself never animates - but because its text is transparent, you only see the Torph layer.
Experimental pattern. This works well for short, append-mostly numeric fields (swap amounts, donation amounts, single-line entry). It has known caveats around caret precision, mid-string editing, and mobile selection handles - documented below.
Installation
pnpm add numora-react torph
# or
npm install numora-react torphPattern 1 - Overlay (animated input surface)
Implementation is short. The key constraints: identical typography on both layers, FormatOn.Change so the input's text matches the morph's text on every keystroke, and zero padding/border on the input so its text origin lines up with the overlay span.
import { FormatOn } from 'numora'
import { NumoraInput, type NumoraInputChangeEvent } from 'numora-react'
import { useState } from 'react'
import { TextMorph } from 'torph/react'
function AnimatedInput() {
const [value, setValue] = useState('')
const [formatted, setFormatted] = useState('')
return (
<label className="relative inline-flex items-center text-4xl font-mono leading-none text-white">
{/* Visible layer: Torph renders the animated text */}
<span aria-hidden="true" className="pointer-events-none whitespace-pre">
<TextMorph ease={{ stiffness: 400, damping: 30 }}>
{formatted || '0'}
</TextMorph>
</span>
{/* Invisible layer: real input owns the keyboard */}
<NumoraInput
value={value}
onChange={(e: NumoraInputChangeEvent) => {
setValue(e.target.value)
setFormatted(e.target.formattedValue || '')
}}
formatOn={FormatOn.Change}
decimalMaxLength={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(used here) - keeps the input's display as the formatted string at all times, so passingformattedValueto Torph on everyonChangeis enough.FormatOn.Bluralso works, but the focus event silently strips separators (noonChangefires for it) - to use Blur mode addonFocus={(e) => setFormatted(e.target.value)}so Torph stays in sync.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 away from where the visible character renders. 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 span's text rendering.selection:bg-white/25- the input's text is transparent, so a default opaque selection rect would obscure the morph layer below. A translucent selection lets the morph text show through while still signaling that text is selected.placeholder-transparent- the input's own placeholder would otherwise be visible. The overlay shows"0"as a fallback instead.aria-hiddenon the overlay span - screen readers should announce the<input>, not the visible decoration. The input hasaria-label.
Caveats
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. Torph animates characters into that final position over ~150ms. Mid-flight, the caret briefly floats next to characters that haven't arrived yet. With a high-stiffness spring (as in the demo) it's barely noticeable; with slow easings it becomes obvious.
Mid-string editing is limited. Clicking the overlay span doesn't directly position the caret - clicks pass through to the input, but the browser's hit-test runs against the input's invisible text. If the visible character widths in the overlay differ from the input's (during Torph's mid-animation width transitions), the click-to-caret position 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 - Morph a live read-only value
For dashboards or oracle-fed prices that update outside the input, format with formatValueForDisplay and pass to <TextMorph>:
import { formatValueForDisplay, ThousandStyle } from 'numora'
import { TextMorph } from 'torph/react'
function LiveBalance({ rawBalance }: { rawBalance: string }) {
const formatted = formatValueForDisplay(rawBalance, {
thousandSeparator: ',',
decimalSeparator: '.',
thousandStyle: ThousandStyle.Thousand,
})
return (
<div>
Balance:{' '}
<TextMorph ease={{ stiffness: 180, damping: 18 }}>
{formatted}
</TextMorph>
</div>
)
}Reducing motion
Torph respects prefers-reduced-motion by default. If a user opts out of animations at the OS level the morph becomes an instant swap - no extra code needed.
Key points
- No precision boundary. Torph operates on strings; numora hands you the formatted string. Pipe it through without ever calling
Number(). - 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 only. - Pass
formattedValueto Torph. That value always equals the input's current display string. WithFormatOn.Changeno extra wiring is needed. WithFormatOn.Blur, add anonFocushandler to mirror the silent focus-strip - the blur reformat itself firesonChangeand needs nothing. - Match typography. Font family, size, line-height, letter-spacing, and zero padding/border on the input - the caret is computed from the input's text layout, so any difference shows up as caret drift.
- 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 Torph library?
Torph is an animated text-morphing library by Lochie Axon. It diffs an old string and a new string and animates each character between them. Torph ships a React component at torph/react and a vanilla TextMorph class - this page uses the React component.
How do I use Torph with React?
Import TextMorph from torph/react and render it as the visible layer of a transparent-text NumoraInput overlay. Pass formattedValue from Numora's onChange as the TextMorph child and the digits animate on every keystroke.
Does Torph respect prefers-reduced-motion?
Yes. Torph respects the OS-level prefers-reduced-motion setting by default. If the user opts out of animations, TextMorph becomes an instant swap - no extra code needed.
Where can I install the Torph library?
The Torph library lives at torph.lochie.me and is published on npm as torph. Install both packages with npm install numora-react torph.