Labels That Dance
Character-by-character text animation in Swift where shared characters slide, exiting ones fade away, and entering ones spring in.
I came across posts from a couple people I really respect, Lochie and Raphael. Both were showing off morphing text components for the web, and the results looked great. I wanted the same thing in UIKit, so I built AnimatedLabel, a Swift Package that animates text changes character-by-character using spring physics.
How It Works
Three steps: diff, layout, animate.
The diff figures out which characters stick around, which ones leave, and which ones are new. Morph mode uses a two-level approach that pulls the best ideas from both Torph by Lochie Axon and Calligraph by Raphael Salaja.
Two-Level Diffing
When text has multiple words, we diff at the word level first, matching whole words using LCS (Longest Common Subsequence). This comes from Torph's idea of treating words as first-class units. So "Send Action" to "Action Sent" matches "Action" as a whole word and slides it into its new position. "Send" exits, "Sent" enters. They're in different gaps around the matched word, so no characters scatter across word boundaries.
Between matched words, any leftover characters in the same gap get character-level LCS. So "Backing up" to "Backed up" matches "up" at word level, then within the gap before it, character LCS finds "Back" persisting with "ing" fading out and "ed" fading in.
For single words (no spaces), it goes straight to character-level LCS, the approach from Calligraph. "Creative" to "Create" matches all six shared characters: C, r, e, a, t, e.
static func morphDiff(
oldBlocks: [CharacterBlock],
newText: String
) -> (newBlocks: [CharacterBlock], result: TextDiffResult) {
let oldText = String(oldBlocks.map(\.character))
let oldHasWords = oldText.contains(" ")
let newHasWords = newText.contains(" ")
if oldHasWords || newHasWords {
return wordLevelDiff(oldBlocks: oldBlocks, oldText: oldText, newText: newText)
} else {
return characterLevelDiff(oldBlocks: oldBlocks, newText: newText)
}
}Change Ratio
This one's from Calligraph. The diff computes a changeRatio, basically the proportion of characters entering and exiting relative to the total. That ratio scales drift distance and stagger timing in morph mode. A single character change feels subtle, a full word swap gets the full effect. There's a floor of 0.3 on stagger so even tiny edits still get some cascade.
Anchor-Following
From Torph. Exiting characters don't just animate in isolation, they find their nearest persistent neighbor and follow that character's movement during exit. Two animators per exiting view: one for the exit effect (scale + fade), one spring-driven to drift along with the reflow. Keeps everything feeling connected.
Entering characters get the same treatment in reverse. They start offset near where persistent characters currently are and spring into their final position, so nothing overlaps mid-animation.
Morph vs Replace
Two modes. Morph uses the two-level diff above, so shared words and characters slide to new positions while the rest fades in and out.
Replace matches by position instead. Every slot runs a full exit/enter regardless of whether the character actually changed. This gives you that slot-machine rolling effect, better for counters, tickers, or cycling through unrelated text.
Transitions
Three transition types for how entering and exiting characters look:
- Scale - fade with a subtle scale. Clean and minimal.
- Rolling - slide vertically with scale and fade. Direction auto-detects from numeric values, so counting up rolls differently from counting down.
- Slide - slide horizontally with fade.
Mix and match with either mode. .morph + .scale is a good default. .replace + .rolling works well for number tickers.
Spring Physics
All motion uses UIViewPropertyAnimator with spring timing. duration: 0 lets the spring physics decide when to settle, no hardcoded durations.
Entering characters spring in with a staggered delay that ripples across the word. Two animators per character: a spring for position and a quick ease-out for opacity. The fade is fast so the character appears quickly, but the spring keeps moving longer for that satisfying overshoot.
Three presets ship built in:
label.style = .snappy // tight, responsive (default)
label.style = .smooth // softer spring, longer stagger
label.style = .bouncy // visible overshoot, playfulOr pass your own mass, stiffness, damping, stagger, and fade duration.
Rapid-Fire Interruption
If text changes mid-animation, setText snapshots layer.presentation() positions (where characters visually are right now, mid-flight), cancels all running animators, and re-diffs from that snapshot. Characters smoothly redirect from wherever they are. No jumps.
Reduce Motion
If the user has Reduce Motion turned on, AnimatedLabel skips all animation and does an instant text swap. You can override this per-label:
label.reduceMotion = .system // follows system setting (default)
label.reduceMotion = .alwaysAnimate // ignore reduce motion
label.reduceMotion = .neverAnimate // always instant swapUsage
let label = AnimatedLabel()
label.font = .systemFont(ofSize: 22, weight: .bold)
label.textColor = .white
label.setText("Creative")
// Characters morph in place
label.setText("Create")AnimatedLabel is a UIView that sizes itself via intrinsicContentSize. Drop it into a stack view or constrain it like any label. When it's inside a container that needs to resize, pass an alongside closure to sync your layout with the spring:
label.setText("New text") {
self.view.layoutIfNeeded()
}Get It
AnimatedLabel is a Swift Package. iOS 15+.
dependencies: [
.package(url: "https://github.com/ioscraft/AnimatedLabel.git", from: "0.0.3")
]Big thanks to Lochie Axon (Torph) for the word-level matching and anchor-following ideas, and Raphael Salaja (Calligraph) for the character-level LCS and change ratio scaling. AnimatedLabel pulls from both and brings it all together in UIKit.