A is a set of icons that agree on how they are built, which is what lets any of them morph into any other without a search.
This is the page to read with an editor open. By the end of it you should be able to write a rig for two of your own icons and say, for each stroke, what it becomes. That is all correspondence is once it is written down, and it is why nothing has to search on a visitor's device.
Every icon in a rig is divided into the same fixed number of slots. A is one
<path> element, and it keeps its identity across the whole set: slot 0 of one icon
always becomes slot 0 of another.
That is the entire mechanism. There is no matching step, no cost function at runtime, and no possibility of it choosing differently on someone else's machine.
Coloured in, it stops being bookkeeping. Hover a slot to follow it across the set.
This is the first code in the manual, and it introduces three small functions. Rather than ask you to take them on trust, here they are in full. There is nothing else to them.
/**
* A straight line, stored as a cubic. The two controls sit a third and two thirds of the
* way along, which is exactly the conversion from Part 1.
*/
export function line(x0: number, y0: number, x1: number, y1: number): Seg {
const dx = x1 - x0;
const dy = y1 - y0;
return [x0, y0, x0 + dx / 3, y0 + dy / 3, x0 + (2 * dx) / 3, y0 + (2 * dy) / 3, x1, y1];
}
/** A slot is an object holding its segments. This is the one-segment case. */
export function lineSlot(x0: number, y0: number, x1: number, y1: number): Slot {
return { segs: [line(x0, y0, x1, y1)] };
}
/**
* A slot with no geometry of its own, naming the sibling it hides on. Part 5 is about why
* it names one rather than simply being absent.
*/
export function parked(host: number): Slot {
return { segs: [], park: host };
}Nine lines of body, and two of them are the you already met. They live in
lib/morph/core/geometry.ts in this repository, which is source to read rather than a
package to install, and every other name in this manual is the same kind of thing: a few
lines given somewhere to live. Build your own is the map.
A rig itself is a plain object. assertRig in the snippet below is not doing anything
clever to it: it checks the invariants this chapter describes and hands the same object
straight back, so that a typo fails while you are building rather than on a reader's
screen.
import { assertRig, lineSlot, parked } from "@/lib/morph";
export const arrowsRig = assertRig({
name: "arrows",
// 24 matches Lucide, Tabler, Heroicons outline and Iconoir, so imports need no rescale.
grid: 24,
slots: 3,
// Segments per slot, fixed for the whole rig. This is what keeps every icon's `d`
// command structure identical, which is the condition CSS interpolation demands.
arity: [1, 1, 1],
icons: {
"arrow-right": [
lineSlot(6, 12, 18, 12),
lineSlot(13.2, 7.2, 18, 12),
lineSlot(13.2, 16.8, 18, 12),
],
"arrow-down": [
lineSlot(12, 6, 12, 18),
lineSlot(7.2, 13.2, 12, 18),
lineSlot(16.8, 13.2, 12, 18),
],
// Fewer strokes than the rig has slots, so the spare parks on slot 0. It paints
// nothing and sits on its host, so it is born by splitting off that stroke rather
// than growing out of the middle of the canvas.
minus: [lineSlot(6, 12, 18, 12), parked(0), parked(0)],
},
});Everything is a cubic, including straight lines. That looks redundant and is not: it keeps
the serialised d identical in structure across every icon, which is the condition CSS
imposes before it will rather than swap.
You have now seen arity twice without being told what it is. Each slot declares how many
cubic segments it holds, and that count is its , fixed for the whole rig.
The rig above is arity: [1, 1, 1], three slots of one segment each, because everything in
it is a straight line. One holding a circle and a line might be [4, 1].
arity?<path>, holding its identity across the whole set. Slot 0 of any icon becomes slot 0 of any other.The consequence worth dwelling on is that correspondence becomes a thing you can read, diff, review and correct. When a pairing is wrong you edit the rig and commit the fix.
A runtime solver has no equivalent. It re-derives the answer for every visitor, so a wrong answer is wrong forever and there is nowhere to put the correction.
A rig declares its coordinate space. Use 24 unless you have a reason not to: Lucide, Tabler, Heroicons outline and Iconoir all draw on it, so importing from any of them needs no rescale.
A slot count, an arity, a grid, and one path per slot per icon. Nothing in it is computed and nothing in it is clever, which is the point: the decision Part 2 said no machine can make is now four lines you can read, review and correct.
Two cases need more care than a single number, and they are the next two chapters. A slot with no stroke to draw has to go somewhere, which is Part 5. And a pair related by a rotation is the one place slot index is right and straight interpolation is still wrong, which is Part 6.
How to write correspondence down so no solver is needed.