Hand-authoring is worth it for a small set of glyphs you control, because you choose the correspondence yourself and it will be right by construction.
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)],
},
});Three things to decide, in order.
Grid. Use 24 unless you have a reason. Every stroke pack worth importing from draws on it.
Slots. The maximum number of strokes any icon in the set needs. Icons with fewer park the difference.
Arity. Segments per slot. All straight lines means [1, 1, ...]. This is fixed for the
whole rig, so pick it for the most complex icon in the set.
This is the part that takes thought. Slot 0 should mean the same thing in every icon: the shaft, the bar, the long arm. That is what makes the morph read as a transformation of one shape rather than as a crossfade.
For the arrow set above, slot 0 is always the shaft and slots 1 and 2 are always the barbs.
A slot can hold any chain of cubics, and line is the collinear case.
import { line } from "@/lib/morph";
// A slot can hold any chain of cubics. `line` is the collinear case, and everything is a
// cubic so that command structure stays uniform.
export const curvySlot = { segs: [line(4, 4, 20, 4), line(20, 4, 20, 20)] };import { validateRig } from "@/lib/morph";
// `assertRig` throws. `validateRig` returns every problem, which is what a build script
// or an editor integration wants.
export const problems = validateRig(arrowsRig);assertRig throws on a malformed rig, which is what you want at module scope. It catches
slot count mismatches, arity violations, non-finite coordinates, parking cycles and parks
pointing at slots that do not exist.
If the icons already exist, do not retype them. Importing parses real path data and proposes a rig, with a confidence report on every slot it is unsure about.