Hand-authoring is worth it for a small set of glyphs you control, because you choose the 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.
. Segments per . All straight lines means [1, 1, ...]. This is fixed for the
whole , 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, 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.