Morphs and swaps
intermediate·7 min·Markdown for agents
What this lesson teaches
Change a label, an icon or an image in place so it reads as the same thing changing, not two things trading places.
Press the button in each mode and watch the first three letters.
Press the button a few times in each mode. Watch the first three letters.
In the crossfade, the whole word fades out and a new word fades in. Your brain sees two buttons. In the shared-letter version, "Con" stays put and only the ending changes, so it reads as one button changing its mind: "got it, now confirm". That's the last lesson's rule applied to text. What stays the same should stay still; only the difference moves.
Shared-letter text
This is a Benji Taylor pattern from Family. The recipe: split the label into characters, keep the characters that match at the same position, and animate only the ones that change (a short fade and an 8px slide, about 180ms, with a tiny stagger).
// inside Motion's AnimatePresence, mode="popLayout"
text.split("").map((char, i) => (
<motion.span key={`${i}-${char}`} initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -8 }}>
{char}
</motion.span>
));The key, made of the position and the character, is what keeps shared letters mounted. Good pairs share a start: Save → Saved, Connecting → Connected, 12 → 13. Skip it when nothing is shared (Cancel → Done: crossfade instead), for long sentences, and for translated text, where the shared letters change by language.
Icons that morph
Pick icons one after another and watch the big one.
Different shapes move their coordinates. The arrows only rotate, because they are the same shape pointing a different way.
Benji Taylor built the Claude app's animated icons on one constraint: every icon is three SVG lines. An icon that needs only two collapses its third line to an invisible point at the center. Because every icon has the same shape of data, any icon can into any other by moving coordinates.
Two strategies, depending on the pair:
- Different shapes (menu → close, plus → check): interpolate each line's coordinates.
- Same shape, different direction (the four arrows, or plus → × by 45°): just rotate. It's cheaper and reads more clearly as the same object turning.
It works for a small active set (play/pause, expand/collapse, chevrons, a dot → ring → check status), roughly 5 to 20 icons. It's stroke-only, so filled icons and big marketing sets belong to a normal icon pack.
When two stock icons can't share a schema, like copy → check, swap them with a deep scale instead: scale 0.3 → 1, opacity 0 → 1 and blur(3px) → 0, a 0.35s spring with no bounce. A timid 0.8–0.9 scale looks like a glitch.
When you must crossfade, add blur
Some swaps are between things that can't morph: two photos, two illustrations, a sun and a moon. Tap the icon several times in each mode.
Tap the icon a few times in each mode. The plain one shows two ghosts halfway; the blurred one shows one shape.
Jakub Antalik's most reused trick: pair the with a small blur, 2px on desktop and 4px on phones, on the same timing.
.swap {
opacity: 0;
filter: blur(2px);
transition: opacity 250ms var(--ease-out), filter 250ms var(--ease-out);
}
.swap[data-state="visible"] { opacity: 1; filter: blur(0); }At the midpoint of a plain crossfade, both images sit at half opacity, a double exposure your eye reads as two objects. Blur replaces that moment with one out-of-focus shape that resolves into the next. Don't blur text (it reads as broken focus), don't use it on hover, and test anything bigger than ~400px on a slow phone.
Blur is cheap to move but expensive to draw on big surfaces.Animated icons: hover by default, a trigger for everything else
If you ship animated icons as components, the pattern from lucide-animated.com is worth copying. The icon plays on :hover of its button with zero setup, which is fast, needs no JavaScript and can fire on keyboard focus too. But touch screens have no hover, and some moments aren't hovers at all (a "saved" celebration, an onboarding sequence). So the component also exposes an imperative handle:
const ref = useRef(null);
// the like button: onClick={() => ref.current?.trigger()}
// the heart icon component receives ref={ref} and plays when trigger() is calledGate the hover rule behind @media (hover: hover) so taps don't leave icons stuck mid-animation. Don't add trigger() to icons that don't animate, and don't wrap a permanently spinning loader in a ref.
For the submit button that changes from "Continue" to "Confirm", keep the shared letters ("Con") in place and animate only the changing characters (opacity and 8px translateY, ~180ms) using AnimatePresence mode="popLayout" with keys of index+char. For icon swaps that can't morph (copy → check), use scale 0.3 → 1, opacity, and blur(3px) → 0 with a spring { duration: 0.35, bounce: 0 }. For image crossfades, add filter: blur(2px) (4px on mobile) on the same timing as the opacity. Never blur text.Quick check
A theme toggle swaps a sun icon for a moon. What's the best transition?
In the skill
The nodes your agent reads when it does this work.
- Shared-letter text morphsText-state morphs that share letters animate the difference. "Continue" → "Confirm" via the shared "Con".
motion/shared-letter-morph - Morphing icons — the constraint-driven approachConstraint-driven icon system — every icon = N SVG lines, unused lines collapse to invisible center points, same-shape icons rotate.
motion/morphing-icons - Cross-blur transitions
motion/cross-blur-transitions - Hover by default, imperative for everything elseIcon-animation components animate on :hover by default. Expose an imperative trigger() for touch and programmatic use.
motion/hover-default-imperative
Connected lessons
What this lesson builds on and leads to, across every course.