torph

Dependency-free animated text component.
2,453,935 1,495
Install
npm install torph
Latest Version:0.1.3
Vue:3
License:MIT
Last Updated:Sep 7, 2026
Links: GitHub  ·  npm
Maintainer: lochieaxon

Torph

Dependency-free animated text morphing component for React, Vue, Svelte, and vanilla JavaScript.

Installation

npm install torph
# or
pnpm add torph
# or
yarn add torph

Framework Usage

React

import { TextMorph } from "torph/react";

function App() {
  const [text, setText] = useState("Hello World");

  return (
    <TextMorph
      duration={400}
      ease="cubic-bezier(0.19, 1, 0.22, 1)"
      locale="en"
      onAnimationComplete={() => console.log("Animation done!")}
      className="my-text"
      as="h1"
    >
      {text}
    </TextMorph>
  );
}
React Hook
import { useTextMorph } from "torph/react";

function CustomComponent() {
  const { ref, update } = useTextMorph({
    duration: 400,
    ease: "cubic-bezier(0.19, 1, 0.22, 1)",
  });

  useEffect(() => {
    update("Hello World");
  }, []);

  return <div ref={ref} />;
}

Vue

<script setup>
import { ref } from "vue";
import { TextMorph } from "torph/vue";

const text = ref("Hello World");

const handleComplete = () => {
  console.log("Animation done!");
};
</script>

<template>
  <TextMorph
    :text="text"
    :duration="400"
    ease="cubic-bezier(0.19, 1, 0.22, 1)"
    locale="en"
    :onAnimationComplete="handleComplete"
    class="my-text"
    as="h1"
  />
</template>

Svelte

<script>
  import { TextMorph } from 'torph/svelte';

  let text = $state('Hello World');

  const handleComplete = () => {
    console.log('Animation done!');
  };
</script>

<TextMorph
  {text}
  duration={400}
  ease="cubic-bezier(0.19, 1, 0.22, 1)"
  locale="en"
  onAnimationComplete={handleComplete}
  class="my-text"
  as="h1"
/>

Vanilla JS

import { TextMorph } from "torph";

const morph = new TextMorph({
  element: document.getElementById("morph"),
  duration: 400,
  ease: "cubic-bezier(0.19, 1, 0.22, 1)",
  locale: "en",
  onAnimationStart: () => console.log("Starting..."),
  onAnimationComplete: () => console.log("Done!"),
});

morph.update("Hello World");

Spring Animations

Pass spring parameters to ease for physics-based easing. The duration is computed automatically from the spring physics.

import { TextMorph } from "torph/react";

function App() {
  const [text, setText] = useState("Hello World");

  return (
    <TextMorph ease={{ stiffness: 200, damping: 20 }}>{text}</TextMorph>
  );
}

Spring Parameters

Parameter Type Default Description
stiffness number 100 Spring stiffness coefficient
damping number 10 Damping coefficient
mass number 1 Mass of the spring
precision number 0.001 Threshold for determining settled position

Numbers

Numeric words morph by place value: digits slide along the block axis, and the symbols around them — currency, separators, signs, suffixes — travel with the places they belong to. It is on by default, so any value that contains a number already animates this way. Pass numbers={false} to fall back to the character-level text morph.

import { TextMorph } from "torph/react";

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

A value passed as a number rather than a string is formatted for you, so locale and decimals apply:

<TextMorph decimals={2} locale="de-DE">
  {1234.5}
</TextMorph>

Editable fields

Place matching is the right default for a value that changes on its own — a counter, a total, a chart readout. It is the wrong one for a field somebody is typing in, where the character that just changed is known and place value is not the point: typing 1 in front of 20 should insert a digit, not renumber the column.

Pass cursorIndex to switch that update from place matching to caret matching. It is available on the React component and as the second argument to update(); a caret position is a DOM concern, so there is no Vue or Svelte prop for it.

const [value, setValue] = useState("");
const [caret, setCaret] = useState<number>();

<input
  value={value}
  onChange={(e) => {
    setCaret(e.target.selectionStart ?? undefined);
    setValue(e.target.value);
  }}
/>
<TextMorph cursorIndex={caret}>{value}</TextMorph>
morph.update("$120", 2);

API

Options

All components accept the following props/options:

  • text / children: string - The text to display (required)
  • duration?: number - Animation duration in milliseconds (default: 400). Ignored when ease is a spring, which settles on its own physics
  • ease?: string | SpringParams - CSS easing function or spring parameters (default: "cubic-bezier(0.19, 1, 0.22, 1)")
  • scale?: boolean - Enable scale animation on exiting segments (default: true)
  • numbers?: boolean - Morph numeric words by place value, sliding digits along the block axis. Off falls back to the character-level text morph (default: true)
  • decimals?: number - Fraction digits to format a numeric value to. Applies when the value is a number (React children, or update(number)); ignored for strings
  • locale?: Intl.LocalesArgument - Locale for text segmentation, and for formatting a numeric value (default: "en")
  • debug?: boolean - Enable debug mode with visual indicators
  • disabled?: boolean - Disable all morphing animations (default: false)
  • respectReducedMotion?: boolean - Respect user's prefers-reduced-motion setting (default: true)
  • onAnimationStart?: () => void - Callback fired when animation begins
  • onAnimationComplete?: () => void - Callback fired when animation completes
  • onAnimationCancel?: () => void - Callback fired when a morph is interrupted by the next one. Exactly one of onAnimationComplete and onAnimationCancel runs per morph
  • className?: string - CSS class name (React/Vue: class)
  • style?: object | string - Inline styles
  • as?: string - HTML element type (default: "span")

Multi-line text

Newlines in a value are rendered as line breaks, and text morphs normally across them. The React component includes them in its server-rendered markup; Vue and Svelte render them once the component mounts.

Utilities

The text matching torph runs on is exported for building your own behaviour on top of it:

  • segmentText(value, locale): Segment[] - Split a value into segments
  • diffSegments(oldSegments, newText, locale): DiffResult - Match a new value against existing segments, returning the segments that persist, enter and exit

Found this useful?

Follow me on Twitter.

Other projects

You might also like:

Acknowledgements

  • Thanks to Alex for assistance with the site design.
  • Thanks to Pugson for putting up with my bullshit.
  • Thanks to Benji for coining the Torph name and outlining the method in Family Values.

License

MIT

Related Packages

@formkit/auto-animate

Add motion to your apps with a single line of code.

42,768,396 13,935
cube-motion

Four motions, no dials. rise, leave, morph and reveal on the Web Animations API,...

8,583 235
vue-typed-text

A vue plugin for rendering text-typing animation

243
dialkit

Live controls for tuning interfaces in React, Solid, Svelte, Vue, and plain Java...

4,094,012 1,273
@loader-kit/web

LoaderKit loading and progress indicators for the web: a canvas renderer, and cu...

843 1

Version History

Version Released Vue License
0.1.3 >=3 MIT
0.1.2 >=3 MIT
0.1.1 >=3 MIT
0.1.0 >=3 MIT
0.0.10 >=3 MIT