morphtext
Text that morphs letter by letter, outline into outline, and into icons.
A light word turns bold, a serif word turns sans, a small word grows, one
word becomes another, and it stays one word the whole way: no crossfade
between two copies, no ghosted double name in the middle. Each letter's real
outline is read from your bundled fonts and morphed into its counterpart, and
at either end it is plain text, pixel for pixel what a Text draws.

morphtext is a companion to morphnext by KickNext, which morphs icon glyphs read from icon fonts. morphnext is the engine here: its OpenType reader decodes each letter's outline and its topology-aware planner pairs the contours of one letter with those of the other. morphtext adds the text layer on top: laying out both ends, deciding which letter becomes which, carrying every letter's em square from one layout to the other, and handing back to plain text at the ends.
What morphs
| From → to | How |
|---|---|
| Weight, size, colour, slant | Two TextStyles in one family |
| One font into another | Two TextStyles in different families (serif → sans, any bundled pair) |
| One word into another | toText, or a new text on AnimatedMorphText |
| A word into an icon, an icon into another | MorphContent.icon on AnimatedMorph or MorphBetween |
| All of it at once | Any combination of the above |
Why
Most fonts ship a few static weights, not a variable axis, and no font
interpolates into a different font. Animating between two TextStyles can
only crossfade (or snap), and a crossfade between Amethyst light and
Amethyst bold reads as two names stacked on each other. Morphing each
letter's outline gives the in-between shapes no font ever shipped.
Install
dependencies:
morphtext: ^0.1.0
Requires Dart >=3.9.0 and Flutter >=3.32.0.
Bundle every font face you morph between in pubspec.yaml, as you would for
Text. morphtext reads the outlines from those files:
flutter:
fonts:
- family: Fraunces
fonts:
- asset: fonts/Fraunces-300-normal.ttf
weight: 300
- asset: fonts/Fraunces-600-normal.ttf
weight: 600
- family: RedHatDisplay
fonts:
- asset: fonts/RedHatDisplay-Black.otf
weight: 900
Implicit morph
Change text, style or both; AnimatedMorphText morphs from what's on
screen to the new word in the new style. Turning back mid-flight reverses
from where the word is.
import 'package:morphtext/morphtext.dart';
const serif = TextStyle(fontFamily: 'Fraunces', fontWeight: FontWeight.w300, fontSize: 52);
const sans = TextStyle(fontFamily: 'RedHatDisplay', fontWeight: FontWeight.w900, fontSize: 52);
GestureDetector(
onTap: () => setState(() => stone = (stone + 1) % stones.length),
child: AnimatedMorphText(
stones[stone], // a new word morphs from the old one
style: loud ? sans : serif, // a new style morphs too
),
)
Each morph is driven by a spring, as morphnext's AnimatedMorphIcon is, and
by the same default: MorphSprings.snappy (re-exported), which moves at once
and overshoots a touch. Pass MorphSprings.smooth for no overshoot, or any
SpringDescription. A spring keeps its velocity when you turn back
mid-flight. For a fixed duration instead, pass a curve (and a duration,
350 ms by default), as Flutter's implicit animations run. onEnd fires when
a morph ends. With MediaQuery.disableAnimations, changes apply at once.
At rest the word is plain text, pixel for pixel a Text. Hinted text never
quite matches its own outline (on the web, light text renders a little
thinner, its baseline snapped to the pixel), so the text fades in over the
outlines in the last part of the morph, while the word is still moving, and
nothing changes once it has landed. handoff sets how much of the morph's
end that fade spans: 0.1 (the last tenth) by default, chosen by eye on the
web, where the difference is largest; 0 swaps at the end.
Controlled morph
Drive progress yourself, as morphnext's MorphIcon takes it: an
Animation<double>, clamped to 0–1. An AnimationController through a
CurvedAnimation gives any curve; AlwaysStoppedAnimation(value) pins it to
a page position, a scroll offset or a gesture.
final controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 900),
);
MorphText(
'Rose Quartz',
from: serif.copyWith(fontSize: 22),
to: serif.copyWith(fontSize: 48),
progress: CurvedAnimation(parent: controller, curve: Curves.easeInOutCubic),
)
MorphText(
'Amethyst',
toText: 'Citrine', // optional: another word at progress 1
from: serif,
to: sans,
progress: AlwaysStoppedAnimation(scrollFraction),
)
Both styles are merged onto the ambient DefaultTextStyle, like Text.
Fonts from a package are named the Flutter way:
TextStyle(fontFamily: 'Fraunces', package: 'my_fonts'). Don't write the
packages/my_fonts/Fraunces string yourself: merging with the default style
would prefix it twice.
A controlled morph is exact by default: outlines until progress is 1, then
plain text. Pass handoff (as on AnimatedMorphText) to fade the text in over
the end instead, when you play it along a curve rather than hand it to a
Text at a precise frame.
Icons
Flutter draws an icon as one character of an icon font, so morphtext reads
it the same way it reads a letter. MorphContent holds either end, text or
icon, and AnimatedMorph and MorphBetween morph between any two:
const send = MorphContent.text('Send', style: label);
const sent = MorphContent.icon(Icons.check_rounded, semanticsLabel: 'Sent');
AnimatedMorph(done ? sent : send) // the label flows into a tick
MorphBetween(
from: const MorphContent.icon(Icons.menu_rounded),
to: const MorphContent.icon(Icons.close_rounded),
progress: controller,
)
Any icon font the app bundles works: Material Icons
(uses-material-design: true), Cupertino Icons, morphnext's Morphicons, your
own. An icon's size and colour default to the ambient IconTheme, and at
either end it is pixel for pixel an Icon. When a word meets an icon, every
letter flows into it (or out of it) and they meet as one shape.
As with Icon, release builds keep only the icons your code names as
constants (--tree-shake-icons), so name each one (Icons.check), not a
code point computed at runtime.
AnimatedMorphText and MorphText remain the shorthand for text only.
Which letter becomes which
Between two words, letters pair by a weighted edit distance: a letter kept as
itself costs nothing, a letter morphing into another costs a little less than
one appearing or vanishing, and letters never pair with spaces. So most
letters morph into the letter at the same place (Amethyst into Citrine),
as few as possible appear or vanish (usually just the difference in length),
and a word slides only when that keeps many letters as themselves (Ruby
into bRuby). Letters left over shrink to a point in the first half of the
morph, or grow from one in the second.
Between two fonts or two weights, the same letter pairs with itself, and
morphnext pairs its contours: the bowl of a serif a with the bowl of a
sans a. When the two designs differ in structure (a two-storey g into a
single-storey one), the extra contour shrinks to a point.
How it works
- Flutter lays out both ends with
TextPainter: each letter's pen position, kerning and baseline. - For each style, the font file is found through
FontManifest.json(the family's nearest declared weight, upright or italic as asked) and parsed once by morphnext's OpenType reader (TrueType or CFF outlines). Each letter's outline is sampled into contours. - Letters are paired (above), and morphnext plans each pair's morph.
- Each frame, every pair's outline is interpolated to the progress and drawn into its em square, carried from its place in the first layout to its place in the second.
- At
t = 0andt = 1it draws the text withTextPainterinstead, so resting text is real text: hinting, antialiasing and all.
Fonts, plans and layouts are cached: a font is parsed once per asset bundle,
and letter plans are shared by every MorphText on the bundle.
Limitations
- The outlines must be readable from the bundled font file. TrueType
(
glyf) and CFF outlines in.ttf/.otf, declared inpubspec.yaml. Fonts loaded at runtime (FontLoader, google_fonts downloads) or system fonts aren't inFontManifest.json. ThenMorphTextdraws plain text and switches at the middle (a debug build says why, once). - Plain-text ends. Only progress strictly between 0 and 1 draws outlines. The outline is filled geometry, without hinting, so on small low-DPI text the first frame of a morph can look a hair softer than the text at rest.
- In-between shapes of different letters are abstract. Weight, size and
font morphs keep each letter recognisable; one letter morphing into
another (
yintoi) passes through shapes that are neither, as morphnext's icons do. At animation speed it reads as motion. - One line, one direction, no shaping. Letters morph code point by code
point at the positions Flutter laid out, so scripts that shape glyphs
contextually (Arabic, Indic) and ligatures don't morph faithfully. If your
font has ligatures in the word (
fi,ffl), turn them off in both styles withfontFeatures: [FontFeature.disable('liga')]. - Letters missing from the font (fallback glyphs, emoji) are drawn as text at their moving position, the nearer end's letter, rather than morphed.
- Static faces morph; variable axes don't yet. A single variable font is read at its default instance (see Ideas).
- A third target mid-flight starts from whichever of the two ends the text is nearer, not from the in-between shape.
- Semantics. The text is painted, not laid out as a
Text, so it is exposed as one semantics node labelled with the nearer word (override withsemanticsLabel). It isn't selectable.
Ideas
- Variable fonts: read each style's
wghtfromfontVariationsand morph between two instances of one file (morphnext's reader already supports the axes). - Interrupting with a third target from the in-between shape, as
AnimatedMorphIcondoes. - Multi-line text.
Example
example/ is the playground at morphtext.drei.gg:
type a word and switch its face, size, colour and motion (morphnext's springs
or a curve, with the line of code each one is); press buttons whose labels
flow into a tick, or into a cross when the upload fails; flip words into their icons and back; drive MorphText
by hand or play it along a curve.
flutter run -d chrome in example/ runs it locally.
The hero above follows the shape of morphnext's own, so the two read as a
pair. It is rendered frame by frame in a widget test
(tool/record_demo_test.dart, 1280x720 at 60 fps) and encoded by
tool/record_demo.sh. tool/morph_strip_test.dart and
tool/icon_strip_test.dart render word, font and icon morphs side by side
for a quick look.
Reels
The hero above, as a video: demo.mp4.
And a nod to a certain anthology's title card, which people thought of when morphnext launched:

ldr.mp4. Both
are rendered frame by frame in widget tests: tool/record_demo.sh demo|ldr.
License and attribution
morphtext is MIT licensed.
It is built on morphnext (MIT,
© 2026 KickNext), whose outline reader and morph planner do the heavy
lifting; morphnext in turn adapts MIT-licensed work from Morphicons and
icon_font_generator (see its THIRD_PARTY_NOTICES.md). morphtext is an
independent project, not affiliated with or endorsed by KickNext.
The example and tests bundle Fraunces (© 2020 The Fraunces Project Authors) and Red Hat Display (© 2021 Red Hat, Inc., Reserved Font Name "Red Hat", included unmodified), both under the SIL Open Font License 1.1.
Libraries
- morphtext
- Text that morphs letter by letter, outline into outline, and into and out of icons. A companion to morphnext, whose outline engine it uses.