Icon Morph
Icons that change state, copy to check, play to pause, menu to close, swap in a single frame by default. It works, but the eye registers a jump. A full crossfade is the usual fix, and it is too slow and mushy. The sweet spot is a short blur, scale and fade, driven by a spring.
Click both buttons.
The blur is what sells it. As the old icon shrinks it goes out of focus, and the new one sharpens as it grows. The two shapes never sit crisply on top of each other, so it reads as one thing changing rather than two things being swapped. The whole move takes about 300ms.
Tuning it
There are two numbers that matter: how much blur, and how small the icon starts. Drag the sliders and keep toggling.
With no blur the crossfade shows both icons at once for a few frames, and
that overlap is the mushy look you are trying to avoid. Around 4px the
overlap disappears. A start scale near 0.25 gives the new icon somewhere
to come from. At 1 it is only a fade, at 0 the icon grows from nothing,
which looks like an effect.
The spring has bounce: 0, so it is critically damped. Icon swaps want
snap, not wobble. A duration of 0.3 on a spring is the time it takes to
settle, which is what you want to reason about.
When the shapes are related
Some pairs share geometry. A hamburger and a close icon are both made of lines, so you can move the lines instead of crossfading the icons. The top and bottom bars rotate into an X while the middle one shrinks away.
This only pays off when the shapes really are related. Forcing a morph between a copy icon and a check would mean animating path data, and a blur crossfade gets you most of the effect for a fraction of the effort.
Usage
import { AnimatePresence, motion } from "motion/react";
<span className="relative inline-flex">
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={copied ? "check" : "copy"}
initial={{ opacity: 0, scale: 0.25, filter: "blur(4px)" }}
animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
exit={{ opacity: 0, scale: 0.25, filter: "blur(4px)" }}
transition={{ type: "spring", duration: 0.3, bounce: 0 }}
>
{copied ? <CheckIcon /> : <CopyIcon />}
</motion.span>
</AnimatePresence>
</span>Two details to keep. mode="popLayout" takes the leaving icon out of the
layout so the two overlap instead of stacking, which is why the wrapper is
relative. And initial={false} stops the animation from running on the
first render, when there is nothing to morph from. The copy link button in
this site's header is exactly this component.
Resources
AnimatePresence - The Motion component that keeps an element around long enough to animate it out.
Invisible details of interaction design - Rauno Freiberg on transitions that explain state changes instead of just decorating them.
Great animations - Why blur helps a crossfade read as one object changing rather than two objects swapping.
Spring transitions - The duration and bounce options used in the recipe below.