Torph Library Integration with Numora
Torph by Lochie Axon is an animated text-morphing library. This guide layers a Torph TextMorph on top of a transparent-text vanilla NumoraInput so the editable surface itself appears animated. The real <input> still handles every keystroke; Torph just renders what the user sees.
Using React? See the Numora React Torph integration - it wires the overlay through onChange instead of manual DOM listeners.
FormatOn.Change - separators animate on every keystroke.
FormatOn.Blur - minimal integration; the caret floats during the focus-strip morph.
The demos above are rendered inside this React docs site, but the code below is plain TypeScript - no React. It uses the vanilla NumoraInput class and Torph's vanilla TextMorph directly.
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 by stacking two layers in the same box:
- Visible layer: a
TextMorphbound to a sibling span that animates the formatted string. - 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 string. The vanilla NumoraInput class applies formatting in beforeinput via setRangeText, but only runs onChange (or your listeners) when an input event follows - which is not guaranteed on every path. The bridge therefore syncs Torph via a beforeinput microtask (typing) and an input listener (undo/redo; paste, since Numora's paste handler dispatches a synthetic input after sanitizing). Each calls morph.update(numora.value). Torph diffs old vs new and animates each digit / separator into place. The input itself never animates - but because its text is transparent, you only see the Torph layer.
Installation
pnpm add numora torph
# or
npm install numora torphBuilding the overlay
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">0</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;
}
.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::placeholder {
color: transparent;
}import { NumoraInput, FormatOn, ThousandStyle } from 'numora'
import { TextMorph } from 'torph'
const wrap = document.querySelector<HTMLElement>('.numora-overlay')!
const display = wrap.querySelector<HTMLElement>('.numora-overlay-display')!
const host = wrap.querySelector<HTMLElement>('.numora-overlay-host')!
// SSR placeholder is a text node; Torph only replaces element children.
display.textContent = ''
const numora = new NumoraInput(host, {
formatOn: FormatOn.Change,
decimalMaxLength: 2,
thousandSeparator: ',',
thousandStyle: ThousandStyle.Thousand,
})
const input = numora.getElement()
input.setAttribute('aria-label', 'Amount')
const morph = new TextMorph({
element: display,
ease: { stiffness: 400, damping: 30 },
})
morph.update('0')
const syncMorph = () => {
// numora.value mirrors what the input shows. With FormatOn.Change that's
// the formatted string - exactly what Torph should display.
morph.update(numora.value || '0')
}
// Numora applies formatted values in beforeinput (setRangeText). Sync after that
// handler runs; an input listener alone is not guaranteed in every environment.
const scheduleMorphSync = () => {
queueMicrotask(syncMorph)
}
input.addEventListener('beforeinput', scheduleMorphSync)
// Undo/redo, paste (Numora dispatches synthetic input), and other paths that skip beforeinput.
input.addEventListener('input', syncMorph)Why each line matters
display.textContent = ''- a placeholder text node (e.g. SSR0) is not an element child. Torph'screateTextGrouponly replaces element children, so it would append animated spans beside a stuck0instead of owning the span.formatOn: FormatOn.Change(used here) - keeps the input's display as the formatted string at all times. Numora applies that string inbeforeinput, so mirror it with abeforeinputmicrotask sync plus aninputlistener for undo/redo and paste.FormatOn.Bluralso works, but the focus event silently strips separators - bind afocuslistener that callsmorph.update(numora.value)too.- Match typography -
font: inheritplusmargin: 0; padding: 0; border: 0on the input. The caret is computed from the input's invisible text layout, so any difference in font metrics or text origin shows up as caret drift.
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.
Cleanup
On teardown remove the bridge listeners, call morph.destroy() to detach Torph's observers, then remove the input element. The vanilla NumoraInput doesn't expose an explicit destroy method - its internal listeners are bound to the input it created, so removing that element from the DOM is sufficient for Numora itself.
input.removeEventListener('beforeinput', scheduleMorphSync)
input.removeEventListener('input', syncMorph)
morph.destroy()
input.remove()Key points
- No precision boundary. Torph operates on strings; numora hands you the formatted string. No
Number()conversion at the display seam. - 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. - Mirror
numora.valueinto Torph. That string always equals the input's current display. WithFormatOn.Change, sync onbeforeinput(microtask) plusinputfor undo/redo and paste. WithFormatOn.Bluralso bind afocuslistener to catch the silent focus-strip; the blur reformat already firesinput. - Match typography. Use
font: inheriton the input so wrapper and input share font metrics. Setmargin: 0; padding: 0; border: 0on the input so its text origin aligns with the overlay span.
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 both a React component and a vanilla TextMorph class - this page uses the vanilla one.
Does Torph work without React?
Yes. The code on this page uses the vanilla TextMorph class from the torph package together with the vanilla NumoraInput class. No React in the runtime.
How does Torph fit with Numora?
Numora formats the display string in beforeinput; your bridge mirrors numora.value into morph.update() and Torph animates it. The two libraries compose because Numora never crosses the string→number boundary, and Torph operates on strings directly.
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 torph.