text_morph_flutter
Shape-level text morphing for Flutter: instead of cross-fading, Morph
animates one string's glyph outlines into another's (or into an arbitrary
vector shape), built on top of glyph_path and glyph_path_flutter.
Demos
Two things to notice across the demos below: glyphs morph as vector outlines instead of cross-fading, and the same engine drives text→text, text→shape, and shape→shape alike.
Text ↔ shape
The clearest look at what sets this apart from a typical text transition.
![]()
Word → icon — text morphing into a multi-contour shape with a hole, via
ShapeSource.fromPath.

App logo reveal — a shape-cloud burst settling into a large
ShapeSource mark. A template for a splash screen or
empty-state flourish.
Everyday UI states
Drop-in replacements for interactions you're already building.
|
Send → Sending → done — the state change itself becomes the animation, morphing the label straight into a checkmark instead of swapping icons. |
Dashboard ticker — only the digits that actually changed animate
( |
|
Search suggestions — the shared prefix stays put; only the diverging suffix morphs in as the query changes. |
Quote carousel — line count changes between quotes, and the paragraph reflows instead of jump-cutting. |
Getting oriented

Onboarding walkthrough — start here to see multi-line
TextSource, textAlign, and color morph
working together before diving into the API.
All seven demos live in example/ — see
Running the example app to try them yourself.
Installation
flutter pub add text_morph_flutter
Or add to pubspec.yaml manually:
dependencies:
text_morph_flutter: ^1.0.0
Morph renders text via a glyph_path Font, so most apps also want
glyph_path directly (to parse font bytes into a Font) — flutter pub add glyph_path if it isn't already a dependency.
Quick start
import 'package:flutter/material.dart';
import 'package:glyph_path/glyph_path.dart';
import 'package:text_morph_flutter/text_morph_flutter.dart';
class Example extends StatefulWidget {
const Example({super.key, required this.font});
final Font font;
@override
State<Example> createState() => _ExampleState();
}
class _ExampleState extends State<Example> {
String _text = 'Hello';
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () => setState(() => _text = _text == 'Hello' ? 'World' : 'Hello'),
child: SizedBox(
width: 300,
height: 120,
child: Morph(
font: widget.font,
target: TextSource(font: widget.font, text: _text),
fontSize: 64,
),
),
);
}
}
font is a glyph_path Font — parse one once (e.g. from a loaded font
asset's bytes via Font.parse) and reuse it; see glyph_path's own docs for
details on loading fonts.
Changing target (here, via setState) triggers the morph animation
automatically — Morph diffs the new target against what's
currently displayed and animates between them.
Key concepts
MorphSource— a morph endpoint.target(and, implicitly, whatever was displayed before it) is one. Two implementations ship today:TextSource— renders a string in aFont.textmay contain explicit\nline breaks, andmaxWidthadditionally word-wraps against a fixed width — both flow through the same per-glyph alignment/ stagger/caching pipeline as single-line text.rtlScriptopts a pure, non-cursive right-to-left string (e.g. Hebrew) into mirrored glyph order and punctuation — see Known limitations for exactly what it does and doesn't cover.ShapeSource— renders an arbitrary vector shape (a list ofglyph_pathContours, e.g. parsed from SVG path data or built by hand) fitted to the surrounding text's size.targetcan switch betweenTextSourceandShapeSourcefreely — morphing text into a shape and back uses the exact same mechanism as morphing text into different text.ShapeSource.fromPathbuilds one directly from adart:uiPathinstead, sampling it viaPathMetricsincePathdoesn't expose the drawing commands it was built from.
MorphOptions— tuning knobs layered underMorphStyleand stagger; every field defaults to a value that reproduces the look as if the option didn't exist. See MorphOptions reference below for what each one does.TextMorph/PathMorph— the lower-level, widget-free geometry engineMorphis built on, exported for callers who want to drive their ownCustomPainterinstead of using the widget directly.Morph.textAlign— horizontal alignment (left/right/center;start/endresolve against the ambientDirectionality, andjustifyfalls back tostart, matching howTextitself treats a single/last line). For multi-lineTextSourcecontent, the same value also controls how the lines align relative to each other, so a paragraph's internal alignment always matches how the whole block is placed in the box.MorphCache— an opt-in,cacherine-backed cache forPathMorph's most expensive per-transition work (contour pairing/alignment). Pass one instance toMorph.cache(orTextMorph.between'scacheparameter) when your content replays the same glyph-pair transitions often — a dashboard numeric ticker (the same digit-to-digit transitions recur every tick) or a search-suggestion list (the same candidate substrings recur as a query is edited back and forth) are the two cases it's aimed at. Construct and reuse one instance the same way you reuse aFont.null(the default) never caches.
MorphOptions reference
Two enums it builds on, first:
MorphStyle—shape(always morph outlines, the default look),crossFade(never morph outlines; fade in place instead), orauto(shape-morph glyph pairs similar enough per the two thresholds below, cross-fade the rest).GlyphAlignment(MorphOptions.alignment) — how glyphs from the two strings are paired when they don't correspond one-to-one:byIndexpairs purely by position;diff(the default) minimizes pop-in/out glyphs and prefers keeping identical characters in place;wholePathskips per-glyph pairing entirely and matches the two strings' contours as one pool by area rank — useful for text↔complex-shape morphs (see the "word → icon" demo above), at the cost ofstaggerhaving no effect.
| Field | Default | Effect |
|---|---|---|
staggerCurve |
Curves.linear |
Distributes per-glyph start times across the string when TextMorph.stagger > 0. Must be a const Curve — a freshly-built non-const curve breaks MorphOptions's value-equality change detection. |
contourTimingOffset |
0.0 (-1..1) |
Shifts a glyph's hole/counter contours' timing relative to its solid contour's. Positive delays holes so the outer silhouette settles first; negative has holes settle first. |
holeAreaRatioThreshold |
0.02 (0..1) |
Minimum area (relative to the glyph's largest contour) for a nested opposite-winding contour to count as a real hole/counter rather than a hinting artifact. |
dissimilarityThreshold |
0.6 (0..1) |
Under MorphStyle.auto: the fraction of a pair's contours allowed to end up unmatched (grow/shrink from a point) before falling back to cross-fade. Measures pairing coverage only, not shape similarity. |
matchedShapeDissimilarityThreshold |
null (0..1) |
Under MorphStyle.auto: an additional check on how different a pair's matched contours actually look (silhouette overlap once centered/normalized). Opt-in — catches cases like "O" -> "L" that dissimilarityThreshold alone can't, since both sides have one contour each. |
lengthMismatchFallback |
null (int) |
If the "from"/"to" glyph counts differ by at least this many, the whole transition renders as MorphStyle.crossFade regardless of the requested style. |
useAreaCorrectedPop |
true |
Unmatched contours grow/shrink from a point at a constant rate of visible-area change, instead of a plain vertex lerp (which pops in suddenly near the end). |
alignment |
GlyphAlignment.diff |
See GlyphAlignment above. |
idlePulseAmount |
0.0 |
A uniform scale "breath" applied to every glyph (1 + amount * sin(pi * t)), so glyphs diff leaves shape-unchanged still get shared motion instead of sitting frozen. 0.05–0.1 is a subtle pulse. |
selfWobbleDetour |
0.0 |
Warps every matched contour pair into a genuinely different wavy shape at the animation's midpoint before settling into to — unlike idlePulseAmount, this isn't just a scaled copy. 0.1–0.3 is already a strong wobble. |
selfWobbleSeed |
0 |
Seeds the wobble's random lobe count/phase per contour pair, so glyphs don't all trace the identical wobble. Vary it (e.g. a counter incremented per transition) to avoid replaying the same pattern every time. |
API Reference
| Type | Description |
|---|---|
Morph |
The widget — a StatefulWidget that animates a shape-morph transition whenever its target changes. |
MorphSource |
Abstract base for a morph endpoint (text or shape); resolves into positioned glyph-like elements plus a reference size/label. |
TextSource |
A MorphSource rendering a string in a glyph_path Font, with optional word-wrap (maxWidth) and RTL mirroring (rtlScript). |
ShapeSource |
A MorphSource for one arbitrary vector shape, built from Contours or sampled from a dart:ui Path via ShapeSource.fromPath. |
MorphOptions |
Configuration bundle for TextMorph/Morph — see the reference table above. |
MorphStyle |
Enum selecting whether glyphs render as direct outline morphs, cross-fades, or a similarity-based mix of both (auto). |
GlyphAlignment |
Enum for how "from" glyphs pair with "to" glyphs when string lengths differ — byIndex/diff/wholePath. |
TextMorph |
The core, widget-free engine interpolating a whole string's/shape's glyph outlines between two MorphSources; exposes pathAt/frameAt per animation t. |
PathMorph |
Builds/holds the contour pairing and interpolation state for morphing one glyph outline into another. |
MorphFrame |
Result of TextMorph.frameAt(t) — a combined shapePath for directly-morphed glyphs plus a crossFades list. |
CrossFadeGlyph |
One glyph slot's cross-fade at a frame — fromPath/toPath (positioned outlines) and blend alpha (0..1). |
MorphCache |
Opt-in, cacherine-backed cache of built PathMorphs, reused across repeated transitions between the same glyph pairs. |
Running the example app
cd example
flutter run -d macos # or chrome, ios, etc.
main.dart's app bar links out to every demo shown above, plus a
free-form playground (presets, alignment modes, idle pulse/wobble sliders,
shape morphing).
Known limitations
-
LTR text order only, plus opt-in mirroring for pure RTL scripts. Glyph order follows the font's left-to-right advance widths by default.
TextSource.rtlScriptreverses glyph order and swaps paired punctuation (()/[]/{}/<>/«»/‹›) for a line — correct for a pure, non-cursive RTL script like Hebrew — andTextSource.embedLtrlets a substring (e.g. a Latin brand name) keep its own glyph order within an RTL line, though the caller must mark it manually (no automatic script detection, no nesting). This isn't full Unicode Bidi (UAX #9) reordering or contextual shaping, so cursive/joining scripts like Arabic, Farsi, or Urdu — which need each letter substituted with its own initial/medial/ final/isolated form — aren't supported. -
Accessibility.
Morphexposes its currenttarget's text as aSemanticslabel (or an explicitsemanticLabelfor aShapeSource), respects the ambientMediaQuery.textScaler, and honorsMediaQuery.disableAnimations("reduce motion") by completing a transition in a single frame. An opt-inannounceChangesflag setsSemantics.liveRegionfor content that changes off-focus. ThehighContrastColor/highContrastStrokeColor/highContrastStrokeWidthfields, when set, replacecolor/strokeColor/strokeWidthwhileMediaQuery.highContrastis on; they default tonullsinceMorphhas no way to judge a given color's contrast on its own. -
Performance scales with contour/vertex count. An actively transitioning contour re-runs its cubic lerp, winding computation, and path-boolean compositing from scratch every frame — fine for the short strings and moderate shape complexity typical UI morphs involve, but a very long
wholePathstring or high-vertexShapeSourcehas no incremental-update path for whatever is genuinely mid-transition. Settled endpoints (a glyph done with itsstaggerwindow, a contour pair settled at0/1) are cached and reused instead of recomposited each frame, and aPathMorph's one-time construction cost can itself be cached across repeated transitions viaMorphCache. A few internal ceilings also bound the worst case for inputs well past typical use, falling back to a cheaper approximation once exceeded:Ceiling Bound Fallback once exceeded TextSourceUTF-16 code units laid out4096 Excess characters (and any dangling surrogate/combining mark at the cut point) are dropped rather than laid out. Glyphs per side, per-glyph slot alignment ( stagger/GlyphAlignment.byIndex/.diff)2000 Falls back to GlyphAlignment.wholePathinstead of building per-glyph slots.Glyphs per side, GlyphAlignment.diff400 Skips the O(n·m) edit-distance alignment and pairs by index ( byIndex) instead.Contours per pool, hole classification 300 Skips the O(n²) nesting test; every contour in the pool is treated as solid. Contours per role group, proximity match 200 Skips the O(m·n) cost-matrix search and pairs purely by area rank instead. Matched pairs per role group 2000 Excess contours (however many more a pool has) fall back to the same cheap grow/shrink-from-centroid treatment an outright from/to count mismatch already gets, instead of each paying full per-pair alignment and silhouette-dissimilarity cost — the two ceilings above only cheapen how a large pool is searched, not how many pairs a search of it produces. Vertices per contour 2000, shrinking (to a floor of 8) as contour count on either side rises past 200 Decimates the contour to a lower-fidelity, straight-segment version with the same winding. Scaling the per-contour cap down keeps total alignment cost (pool size × cap²) bounded even for a pool with many high-vertex contours, which the count-based ceilings above don't limit on their own. Sampled vertices per sub-path, ShapeSource.fromPath2000 Widens the effective sample spacing past what was requested, instead of letting vertex count grow further. Sub-paths sampled, ShapeSource.fromPath500 Drops remaining sub-paths instead of letting total sampled vertices grow without limit.
See CHANGELOG.md for the full history of features and fixes.
Contributing
After cloning, flutter pub get sets up all dependencies, including the
example app's own (cd example && flutter pub get).
# 1. Format the code
dart format lib test example
# 2. Run static analysis
flutter analyze --fatal-infos
# 3. Run the test suite (with coverage)
flutter test --coverage
This mirrors what CI (.github/workflows/ci.yml) checks on every pull
request. Feel free to open an issue or submit a pull request for any
suggestions or bug fixes.
License
BSD 3-Clause — see LICENSE.
Dependencies
| Package | License | Notes |
|---|---|---|
glyph_path |
BSD-3-Clause | Runtime dependency — font parsing and glyph outline generation. |
glyph_path_flutter |
BSD-3-Clause | Runtime dependency — converts glyph_path output into dart:ui Paths. |
cacherine |
MIT | Runtime dependency — backs MorphCache. |
flutter_test is a dev-only dependency (bundled with the Flutter SDK) and
is not included in the published package.
Libraries
- text_morph_flutter
- Shape-level text morphing animations for Flutter, built on
glyph_pathandglyph_path_flutter.



