# torph, and the trick of morphing text by place value

> Torph is a dependency-free animated text morphing component that works in React, Vue, Svelte and vanilla JavaScript from one core. Its most interesting behaviour is not the effect itself but the way numbers are matched: by place value for display, by position for a field somebody is typing in.

**lochie/torph** — Dependency-free animated text component.

- Repository: https://github.com/lochie/torph
- Website: https://torph.lochie.me
- Stars: 1,494 · Forks: 30
- Language: TypeScript
- License: MIT
- Published: 2026-10-16 · Updated: 2026-10-16 · Language: en
- Canonical page: https://hysenlabs.com/projects/lochie-torph

## One core, four frameworks, zero dependencies

Torph is a dependency-free animated text morphing component for React, Vue, Svelte and vanilla JavaScript, and the phrase dependency-free is the headline.

The framing matters for a UI component. Almost every animation library in the JavaScript ecosystem arrives with a runtime, a peer dependency tree and a version compatibility question, and a small effect that depends on a large library is a bad trade for the person installing it. A component that morphs text between two states does not need a physics engine, a tweening library or a virtual DOM abstraction, so a project that ships its own implementation can be installed without pulling anything else in.

The multi framework support is the other half of the design. The same behaviour is available in four worlds, and the import path tells you which one you are in.

```tsx
import { TextMorph } from "torph/react";

<TextMorph>Hello world</TextMorph>;
```

The React import is a subpath of the package, and the repository carries a site with live examples for React, Svelte, an Svelte SSR example, a TypeScript example and a Vue example, so the ports are demonstrably running rather than merely claimed.

The audience is front end developers who want a specific effect and do not want a framework to get it. The licence is MIT, the project publishes no GitHub releases, and the last push was on 2026-09-07.

The homepage is a project site, and the site is part of the same monorepo, which is how the live examples and the documentation are built from one repository.

## Numbers morph by place value, and that is the whole idea

The number behaviour is the feature that makes this component more than an effect, and the documentation explains the rule precisely.

Numeric words morph by place value: digits slide along the block axis and the symbols around them travel with the places they belong to. It is on by default, so any value containing a number already animates this way.

```tsx
import { TextMorph } from "torph/react";

// 1,204 → 1,318 rolls the hundreds and tens, leaves the thousands alone
<TextMorph>{`$${total.toLocaleString("en")}`}</TextMorph>;
```

The example says it in one line: going from 1,204 to 1,318 rolls the hundreds and the tens and leaves the thousands alone. That is the behaviour you want from a counter, because the eye follows the digits that changed and the digits that did not stay put. A generic character diff would animate every changed position rather than following place values, and the effect would be noise.

The rule extends to the symbols around the digits. The comma and the currency symbol are not decoration, they are attached to the place they belong to, so a currency symbol moves with the thousands group rather than staying in character position.

So the component is not animating characters, it is animating positions, and the number case is where that distinction is visible.

## A field somebody is typing in needs position matching, not place value

There is one important exception to the place value rule, and the documentation is clear that it is a different case rather than a preference.

For a field somebody is typing in, you pass the caret position so the update matches by position rather than by place value.

```tsx
<TextMorph cursorIndex={caret}>{value}</TextMorph>;
```

The reasoning is the opposite of the display case. When a person is typing, the character they just typed is at the caret, and that is the character whose change they should see. Matching by place value in a text field would animate a digit in the wrong place, because in an input the digits do not sit in fixed place value slots once the text is arbitrary. A phone number, a partial date or a quantity being typed are all strings where the leftmost character is not the thousands.

So the component has two matching modes and the caller chooses by passing the caret position. That is a small API and a real design decision, and it is the sort of detail that separates a component written by someone who has shipped the effect from one written from a description of it.

For a developer evaluating the library, the question to ask is which of the two cases your number field is. A read only total, a balance, a timer, is a display value and gets the place value behaviour. An input the user is typing into gets the cursor index.

## Spring easing through a single ease prop

The animation parameters are passed through a prop called ease, and spring easing is opted into by passing spring parameters to that prop.

```tsx
import { TextMorph } from "torph/react";

<TextMorph ease={{ stiffness: 200, damping: 20 }}>Hello world</TextMorph>;
```

A spring has two parameters that matter, stiffness and damping, and the pair in the example is the familiar one. Stiffness sets how hard the animation is pulled toward its target and damping sets how much the overshoot is absorbed. Together they produce a movement that settles rather than arriving, which is what makes a spring feel physical on a value that changes while you are looking at it.

The choice to offer a spring is the interesting part, and it connects to the number behaviour. A number that is being updated continuously, by a live feed or a ticking counter, is exactly the case where a fixed duration animation looks wrong, because the animation restarts on every update and the value never settles. A spring handles an interrupted animation gracefully, since the current velocity carries into the new target.

The full API is in the package README, in the packages/torph directory, so the component package is a workspace member with its own documentation separate from the site.

## The monorepo behind the package

The repository root is a private pnpm workspace, and the scripts in it are a good map of the project.

The install:all script runs a recursive install across the workspace, and the dev script runs the library watch alongside the site dev server, so the documentation site and the library are developed together. The build script builds only the library package, and site:build does the library build, then runs a bundle-sizes script, then builds the site.

That bundle size step is the interesting one, and it is the check that keeps the dependency-free claim honest. A script that measures bundle sizes during the site build turns the claim into something the build enforces rather than something the README asserts, and it is a script rather than a badge because a badge can go stale.

The examples are separate workspace packages with their own dev scripts, one each for React, Svelte, Svelte with SSR, TypeScript and Vue. The Svelte SSR example is the interesting one to have, because server rendering is where a component that measures text in the browser tends to break, and having an example for it means the port is known to work in that mode.

The root also has a lint script that lints the library and the site, a pre-commit script, a test script, a typecheck script, and a release script that runs typecheck, then test, then build, then publishes with changesets, then pushes the tags.

The tooling is conventional and current: changesets for versioning, commitlint with the conventional config for commit messages, and lint-staged for the pre-commit hook.

## Changesets, a release script that tests first, and where the effect came from

The release process is visible in the scripts, and the order is the part that matters.

The release script runs the type check, then the tests, then the build, and only then publishes with changeset publish and pushes with the follow-tags flag. So a release cannot happen without a passing type check, a passing test run and a successful build, which is a stronger guarantee than most component libraries give.

The versioning itself is handled by changesets, with a .changeset directory at the top level and a version script that runs changeset version and stages the result. Changesets means the release notes are written by the person who made the change, in the pull request, rather than reconstructed from commit messages afterwards.

The acknowledgements section explains where the effect came from, and it is a better story than a design document would be. The name Torph was coined by Benji, who also outlined the method in a post called Family Values, and the effect is described there as being at half the scale of what this library does. There are thanks to Alex for the site design and to Pugson for putting up with the author's BS.

The author also points at two related projects worth knowing: number-flow, an animated number component by Maxwell Barvian, which is the closest thing to a prior art for the numeric behaviour, and easing.dev for creating custom easing graphs.

The repository carries AGENTS.md and CLAUDE.md at the top level, and a site directory that holds the documentation.

## Conclusion

Adopt torph if you are building a counter, a scoreboard or any interface where a number changes and you want the change to be readable rather than a flicker, since the place value matching is the part that makes the animation legible. Before you commit, read the package README for the full API, decide whether your numeric field is a display value or an input, since that determines whether you pass a cursor index, and check the bundle size script in the site build to see what the dependency-free claim actually weighs.

## FAQ

### How do I install and use torph?

Install it with `pnpm i torph`, then import the component for your framework, for example `import { TextMorph } from "torph/react";`, and wrap the text you want to morph. The library has no dependencies.

### How does torph animate numbers?

Numeric words morph by place value: digits slide along the block axis and the symbols around them travel with the places they belong to, so going from 1,204 to 1,318 rolls the hundreds and tens and leaves the thousands alone. It is on by default for any value containing a number.

### What does the cursorIndex prop do?

For a field somebody is typing in, you pass the caret position so the update matches by position rather than by place value, which is the behaviour that matches what the person typing is looking at.

### Can I use a different easing for the animation?

Yes. The ease prop takes spring parameters, for example `ease={{ stiffness: 200, damping: 20 }}`, and the package README linked from the repository documents the full API.

### Which frameworks does torph support?

React, Vue, Svelte and vanilla JavaScript, from one dependency-free core, with a separate import path per framework. The repository carries example projects for React, Svelte, Svelte with SSR, TypeScript and Vue.

## Sources

- [Issues](https://github.com/lochie/torph/issues)
- [License: MIT](https://github.com/lochie/torph/blob/main/LICENSE)
- [lochie/torph on GitHub](https://github.com/lochie/torph)
- [Project website](https://torph.lochie.me)
- [README](https://github.com/lochie/torph/blob/main/README.md)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/lochie-torph
