Skip to main content

Overview

<MorphText> animates text changes with stable token identity. Matching characters/words smoothly interpolate position, new ones enter, and removed ones exit.
import { MorphText } from "griffo/morph";
Bundle size: 7.95 kB (minified + brotli)

Basic Usage

import { MorphText } from "griffo/morph";
import { useState } from "react";

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

  return (
    <>
      <MorphText>{text}</MorphText>
      <button onClick={() => setText("World")}>Change</button>
    </>
  );
}

How It Works

<MorphText> maintains stable identity for each character/word across text changes:
  1. Persist: Matching tokens animate to new positions
  2. Enter: New tokens fade/slide in
  3. Exit: Removed tokens fade/slide out
1

Initial render

"Hello" → 5 character tokens created
2

Text changes

"World" → Compares with previous “Hello”
3

Reconciliation

  • "o" persists (moves to new position)
  • "l" persists (appears twice, reuses existing)
  • "H", "e" exit
  • "W", "r", "d" enter

Split By Characters

Default mode splits text into individual characters:
<MorphText splitBy="chars">{text}</MorphText>

Character-level Transitions

import { MorphText } from "griffo/morph";
import { useState } from "react";

function CountdownTimer() {
  const [count, setCount] = useState(10);

  useEffect(() => {
    const timer = setInterval(() => setCount((c) => c - 1), 1000);
    return () => clearInterval(timer);
  }, []);

  return <MorphText>{count.toString()}</MorphText>;
}

Split By Words

Split text into words instead of characters:
<MorphText splitBy="words">{text}</MorphText>

Word-level Transitions

import { MorphText } from "griffo/morph";
import { useState } from "react";

const statuses = ["Loading...", "Processing data", "Almost there", "Complete!"];

function StatusText() {
  const [index, setIndex] = useState(0);

  return <MorphText splitBy="words">{statuses[index]}</MorphText>;
}

Animation States

Initial (Enter)

Applied to new tokens when they first appear:
<MorphText
  initial={{ opacity: 0, y: -20 }}
  animate={{ opacity: 1, y: 0 }}
  splitBy="chars"
>
  {text}
</MorphText>

Animate (Persist)

Applied to all tokens (both entering and persisting):
<MorphText
  initial={{ opacity: 0, scale: 0.5 }}
  animate={{ opacity: 1, scale: 1 }}
  splitBy="chars"
>
  {text}
</MorphText>

Exit

Applied to tokens being removed:
<MorphText
  initial={{ opacity: 0, y: 20 }}
  animate={{ opacity: 1, y: 0 }}
  exit={{ opacity: 0, y: -20 }}
  splitBy="chars"
>
  {text}
</MorphText>

Function Variants

Access token index and total count:
<MorphText
  initial={({ index, count }) => ({
    opacity: 0,
    x: index < count / 2 ? -75 : 75,
  })}
  animate={{ opacity: 1, x: 0 }}
  exit={({ index, count }) => ({
    opacity: 0,
    x: index < count / 2 ? 75 : -75,
  })}
  splitBy="chars"
>
  {text}
</MorphText>

MorphVariantInfo

index
number
Index of this token among all animated tokens
count
number
Total number of animated tokens

Transition Options

<MorphText
  initial={{ opacity: 0, scale: 0.8 }}
  animate={{ opacity: 1, scale: 1 }}
  transition={{
    type: "spring",
    bounce: 0.3,
    duration: 0.6,
  }}
  splitBy="chars"
>
  {text}
</MorphText>

Stagger Delays

Stagger entering tokens:
<MorphText
  initial={{ opacity: 0, y: 20 }}
  animate={{ opacity: 1, y: 0 }}
  stagger={0.03} // 30ms delay between each entering token
  splitBy="chars"
>
  {text}
</MorphText>
Stagger only applies to entering tokens. Persisting tokens animate immediately to their new positions.

Animate Initial

By default, the first render skips entrance animations:
// First render: no animation
<MorphText initial={{ opacity: 0 }} animate={{ opacity: 1 }}>
  {text}
</MorphText>

// First render: animated
<MorphText
  animateInitial
  initial={{ opacity: 0 }}
  animate={{ opacity: 1 }}
>
  {text}
</MorphText>

Morph Complete Callback

<MorphText
  onMorphComplete={() => {
    console.log("All entrance and exit animations finished");
  }}
  initial={{ opacity: 0 }}
  animate={{ opacity: 1 }}
>
  {text}
</MorphText>
onMorphComplete fires when all entering tokens finish animating AND all exiting tokens complete their exit.

Font Loading

// Wait for fonts (default)
<MorphText waitForFonts>{text}</MorphText>

// Split immediately
<MorphText waitForFonts={false}>{text}</MorphText>

Reduced Motion

<MorphText
  reducedMotion="user" // Respects prefers-reduced-motion
  initial={{ opacity: 0, y: 20 }}
  animate={{ opacity: 1, y: 0 }}
>
  {text}
</MorphText>
reducedMotion
'user' | 'always' | 'never'
default:"user"
  • "user": Disable animations if prefers-reduced-motion is set
  • "always": Always disable animations
  • "never": Always enable animations

Wrapper Element

<MorphText
  as="h1"
  className="morph-heading"
  style={{ fontSize: "2rem" }}
>
  {text}
</MorphText>
Renders:
<h1 class="morph-heading" style="font-size: 2rem" aria-label="{text}">
  <!-- morphing tokens -->
</h1>

Complete Examples

Loading States

import { MorphText } from "griffo/morph";
import { useState, useEffect } from "react";

const messages = [
  "Loading...",
  "Fetching data",
  "Processing",
  "Almost there",
  "Complete!",
];

function LoadingStatus() {
  const [index, setIndex] = useState(0);

  useEffect(() => {
    const timer = setInterval(() => {
      setIndex((i) => (i < messages.length - 1 ? i + 1 : i));
    }, 2000);
    return () => clearInterval(timer);
  }, []);

  return (
    <MorphText
      splitBy="words"
      initial={{ opacity: 0, x: 20 }}
      animate={{ opacity: 1, x: 0 }}
      exit={{ opacity: 0, x: -20 }}
      transition={{ duration: 0.4 }}
    >
      {messages[index]}
    </MorphText>
  );
}

Counter with Directional Animation

import { MorphText } from "griffo/morph";
import { useState, useRef } from "react";

function Counter() {
  const [count, setCount] = useState(0);
  const prevCountRef = useRef(0);
  const direction = count > prevCountRef.current ? 1 : -1;

  const handleChange = (delta: number) => {
    prevCountRef.current = count;
    setCount(count + delta);
  };

  return (
    <>
      <MorphText
        splitBy="chars"
        initial={{ opacity: 0, y: -20 * direction }}
        animate={{ opacity: 1, y: 0 }}
        exit={{ opacity: 0, y: 20 * direction }}
        transition={{ duration: 0.3 }}
      >
        {count.toString()}
      </MorphText>
      <button onClick={() => handleChange(1)}>+</button>
      <button onClick={() => handleChange(-1)}>-</button>
    </>
  );
}

Word Scramble Effect

import { MorphText } from "griffo/morph";
import { useState } from "react";

const words = ["Design", "Develop", "Deploy", "Deliver"];

function RotatingWords() {
  const [index, setIndex] = useState(0);

  useEffect(() => {
    const timer = setInterval(() => {
      setIndex((i) => (i + 1) % words.length);
    }, 2000);
    return () => clearInterval(timer);
  }, []);

  return (
    <h1>
      We{" "}
      <MorphText
        splitBy="chars"
        initial={({ index, count }) => ({
          opacity: 0,
          y: 20,
        })}
        animate={{ opacity: 1, y: 0 }}
        exit={({ index, count }) => ({
          opacity: 0,
          y: -20,
        })}
        transition={{ duration: 0.4 }}
        stagger={0.03}
      >
        {words[index]}
      </MorphText>
    </h1>
  );
}

Technical Details

Stable Token Identity

Griffo uses a diffing algorithm to track tokens across renders:
  1. Each unique character/word gets a stable ID on first appearance
  2. When text changes, matching tokens keep their IDs
  3. AnimatePresence tracks these IDs for smooth layout animations

Layout Animations

All tokens use Motion’s layout="position" for automatic position interpolation when text reflows.

Props Reference

children
string
required
Text content to morph
splitBy
'chars' | 'words'
default:"chars"
Split text by characters or words
initial
MotionInitialProp | ((info: MorphVariantInfo) => MotionInitialProp)
Initial state for entering tokens
animate
MotionAnimateProp | ((info: MorphVariantInfo) => MotionAnimateProp)
Target state for all tokens
exit
MotionExitProp | ((info: MorphVariantInfo) => MotionExitProp)
Exit state for removed tokens
transition
AnimationOptions
Motion transition options
stagger
number
Delay between entering tokens (in seconds)
animateInitial
boolean
default:false
Animate on first render
onMorphComplete
() => void
Called when all entrance/exit animations finish
waitForFonts
boolean
default:true
Wait for document.fonts.ready before splitting
reducedMotion
'user' | 'always' | 'never'
default:"user"
Reduced motion handling
as
keyof HTMLElementTagNameMap
default:"span"
Wrapper element type
className
string
Wrapper class name
style
CSSProperties
Wrapper styles

Motion

SplitText with Motion variants

React

Callback-based React component

Accessibility

Screen reader support

Performance

Optimization tips

Build docs developers (and LLMs) love