XGitHub

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 - separators animate out on focus, back in on blur. in progress... (cursor jumping)

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 TextMorph bound to a sibling span that animates the formatted string.
  • Keyboard layer: the vanilla NumoraInput positioned on top with color: transparent and a visible caret. It still owns focus, keystrokes, undo, IME, and mobile inputmode.

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.

Experimental pattern. This works well for short, append-mostly numeric fields. It has known caveats around caret precision, mid-string editing, and mobile selection handles - documented below.

Installation

pnpm add numora torph
# or
npm install numora torph

Pattern 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">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::selection {
  background: rgba(255, 255, 255, 0.25);
}

.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 part matters

  • display.textContent = '' - a placeholder text node (e.g. SSR 0) is not an element child. Torph's createTextGroup only replaces element children, so it would append animated spans beside a stuck 0 instead 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 in beforeinput, so mirror it with a beforeinput microtask sync plus an input listener for undo/redo and paste. FormatOn.Blur also works, but the focus event silently strips separators (no input event fires for it) - to use Blur mode also bind a focus listener that calls morph.update(numora.value).
  • color: transparent + caret-color: 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.
  • font: inherit on the input - the caret position is computed from the input's text layout. If the wrapper and input use different font metrics, the caret drifts away from where the visible character renders.
  • margin: 0; padding: 0; border: 0 on the input - browser defaults shift the text origin. Zero padding aligns the input's text rendering with the overlay span's text rendering.
  • ::selection { background: rgba(255, 255, 255, 0.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.
  • aria-hidden on the display span - screen readers should announce the <input>, not the visible decoration.

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 it's barely noticeable; with slow easings it becomes obvious.

Mid-string editing is limited. Clicking the overlay doesn't always position the caret on the visually-correct character - the browser's hit-test runs against the input's invisible text and the overlay's character widths can diverge during animations. 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. Test on real devices before shipping.

Pattern 2 - Morph a value the user can't edit

For values not bound to an editable input (live prices, balances, oracle feeds), format with formatValueForDisplay and call morph.update() when the source changes:

import { formatValueForDisplay, ThousandStyle } from 'numora'
import { TextMorph } from 'torph'

const displayEl = document.getElementById('balance')!
const morph = new TextMorph({
  element: displayEl,
  ease: { stiffness: 180, damping: 18 },
})

function setBalance(rawValue: string) {
  const formatted = formatValueForDisplay(rawValue, {
    thousandSeparator: ',',
    decimalSeparator: '.',
    thousandStyle: ThousandStyle.Thousand,
  })
  morph.update(formatted)
}

priceFeed.on('update', (raw) => setBalance(raw))

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.value into Torph. That string always equals the input's current display. With FormatOn.Change, sync on beforeinput (microtask) plus input for undo/redo and paste. With FormatOn.Blur also bind a focus listener to catch the silent focus-strip; the blur reformat already fires input.
  • Match typography. Use font: inherit on the input so wrapper and input share font metrics. Set margin: 0; padding: 0; border: 0 on the input so its text origin aligns with the overlay span.
  • 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 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.