A rig 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.
Every icon in a rig is divided into the same fixed number of slots. A slot 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 correspondence 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.
Each slot declares how many cubic segments it holds, and that count is fixed for the whole
rig. A rig of straight-line icons is arity: [1, 1, 1]. One holding a circle and a line
might be [4, 1].
This is the first code in the book, 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.
function line(x0, y0, x1, y1) {
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.
function lineSlot(x0, y0, x1, y1) {
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.
function parked(host) {
return { segs: [], park: host };
}Nine lines, and two of them are the cubic 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 book 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 interpolate rather than swap.
<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.