Morphs without spikes
Most icon morphs look generated for the same few reasons: points paired by accident, moved in straight lines, and corners that snap in. Pair by shape, move strokes whole, and round the corners halfway.
An icon that turns into another one is a small thing to get right and an easy thing to get wrong. The two ends are always fine: they're the icons, drawn by someone who cared. What gives a morph away is the frames in between, which nobody draws and few people look at one by one. That's where the spikes are.
Icon morphs in the Lab plays seven changes three ways at once: in the order the paths were written, as most libraries do; evenly resampled, as flubber does; and softly, the way this post describes. Its Trace switch lays every frame over the others, which is the quickest way to see a morph for what it is.
In order
Evenly
Softly
Two strokes turn an eighth. In order they fold through each other; point by point they cut across the turn.
// A stroke is moved as a whole: where it is, how big it is and which way it
// faces are interpolated apart from its shape. A plus turns into an x; it
// doesn't shrink through the middle, as points moving in straight lines do.
const size = Math.exp(lerp(Math.log(fromSize), Math.log(toSize), t));
const centre = lerpVec(fromCentre, toCentre, t);
const points = fromShape.map((p, i) => {
const [x, y] = rotate(lerpVec(p, toShape[i], t), turn * t);
return [centre[0] + size * x, centre[1] + size * y];
});
// Then it's softened along its length: a Gaussian blur over its points that
// peaks halfway and is gone at either end. Corners appear rounded and tighten
// as they arrive, instead of snapping in as kinks.
const soft = soften(points, closed, SOFTEST * Math.sin(Math.PI * t));
// Drawn as one smooth curve, sharp only at an end's own corners, near that end.
const ease = (i) => 1 - Math.max(cornerFrom[i] ? (1 - t) ** 3 : 0, cornerTo[i] ? t ** 3 : 0);
path.setAttribute("d", curve(soft, closed, ease));
Where the spikes come from
Three things, mostly. The first is pairing by accident. A morph has to decide which point of one icon becomes which point of the other, and the cheapest answer is the order the paths happen to be written in. Nothing in that order says which parts look alike, so a stroke can become any other, and shapes fold through each other on the way.
The second is moving every point in a straight line. That's right for a point that slides, and wrong for one on a stroke that turns: a bar turning into the arm of an x should sweep round, and its points, each taking the short cut, cut across the arc instead, so the bar shrinks as it turns and grows back.
The third is the one that reads as spiky. A corner the second icon has isn't in the first, and moving points in straight lines makes it appear the only way it can, as a kink, sharp from the first frame and only its angle changing. The eye reads a sharp corner as a thing, and a thing that pops in is a glitch. Two smaller tells come with it: drawing the frames as polygons, and strokes with no partner shrinking to a dot that vanishes on the last frame.
Pair by shape
Lay each stroke out as evenly spaced points, and keep its corners among them exactly. Then, for each pair of strokes, try every starting point and both directions, and score each by how alike the two shapes are once position, size and turn are set aside, with a little extra for turning further. The pairing of the strokes themselves works the same way across the whole icon, and turns that disagree cost more, so the two strokes of a plus pair with the two of an x so that they turn together, the plus turning into the x as one piece.
Move strokes whole
Instead of moving points, move strokes. Each has a place, a size and, if it's drawn out like a bar, a way it faces. Interpolate those on their own (the size by ratio, so it doesn't sag halfway), and blend only what's left of the shape point by point:
const size = Math.exp(lerp(Math.log(fromSize), Math.log(toSize), t));
const centre = lerpVec(fromCentre, toCentre, t);
const points = fromShape.map((p, i) => {
const [x, y] = rotate(lerpVec(p, toShape[i], t), turn * t);
return [centre[0] + size * x, centre[1] + size * y];
});Only drawn-out strokes turn. A round shape, a heart or a star, has no way it faces, and fitting it a turn anyway spins the whole icon for no reason. Stroke by stroke, a bar turns and a blob doesn't.
Round the corners halfway
This is the part that makes it look soft. Blur each stroke's points along its length, with a Gaussian whose width follows the morph: nothing at either end, about a unit (on Lucide's 24-unit grid) halfway. A corner the next icon needs now arrives rounded and tightens as it lands, and one the first icon had loosens before it goes. Mirror an open stroke's points through its ends before blurring, and its straight lines stay straight and its ends stay put.
Then draw the points as one smooth curve, not a polygon, sharp only at an icon's own corners and only near that icon, so the last frames close in on the real thing instead of jumping to it. A stroke with no partner gathers into a dot and the dot shrinks away, so nothing vanishes on the last frame.
When to bother
Matching costs a few milliseconds once per pair of icons, and each frame after that is a few thousand points, which is nothing. The cost is attention, and the motion rule that runs this site applies: how often will it be seen? An icon swapped on every hover should crossfade, quickly, and does here. A morph earns its place where an icon changes what it means, play to pause, copy to check, menu to close, a few times a visit. There, it's worth doing well.