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.

Swap
Morph

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.

Swap
Morph

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

app.tsx
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

Search concepts

Search for a command to run...