morphicons 0.1.1 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -6
- package/dist/dom.js +1 -1
- package/dist/index.d.ts +12 -1
- package/dist/index.js +2 -2
- package/dist/react.js +1 -1
- package/dist/{spring-Dbj7NCw2.js → spring-BR0BCAfU.js} +65 -29
- package/package.json +11 -2
package/README.md
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
<a href="https://www.morphicons.com"><strong>morphicons.com</strong></a> — live playground
|
|
14
14
|
</p>
|
|
15
15
|
|
|
16
|
-
Universal morphing for stroke-based icons (Lucide,
|
|
16
|
+
Universal morphing for stroke-based icons (Lucide, Tabler, Heroicons, Iconoir, or your own paths): **any icon morphs into any other** with spring physics. Rotations are never declared by hand — they emerge from the math (2D Procrustes + polar interpolation). Zero runtime dependencies.
|
|
17
17
|
|
|
18
18
|
```tsx
|
|
19
19
|
import { MorphIcon } from "morphicons/react";
|
|
@@ -86,7 +86,7 @@ interpPolar(plan, 0.5, out); // t ∈ [0, 1]
|
|
|
86
86
|
const d = serialize(out, plan.items.map(it => it.closed)); // `d` attribute
|
|
87
87
|
```
|
|
88
88
|
|
|
89
|
-
Accepts `IconNode` (Lucide's data format, structurally typed — Lucide is neither a dependency nor a peer) or a raw `d` attribute.
|
|
89
|
+
Accepts `IconNode` (Lucide's data format, structurally typed — Lucide is neither a dependency nor a peer) or a raw `d` attribute. Tabler, Heroicons, Iconoir and custom paths work the same.
|
|
90
90
|
|
|
91
91
|
## Icon library compatibility
|
|
92
92
|
|
|
@@ -94,10 +94,21 @@ morphicons has no per-library adapters — any icon set that meets these require
|
|
|
94
94
|
|
|
95
95
|
1. **Stroke-drawn icons.** The geometry must be the stroked centerline (`fill="none"`, color via `stroke`). The whole pipeline — resampling, correspondence, in-flight polylines — assumes strokes; filled or outlined-fill glyphs (Material Symbols, Bootstrap Icons, Remix Icon, Phosphor, Heroicons *solid*) parse fine but won't read correctly in transit.
|
|
96
96
|
2. **Geometry available as data.** Either a raw `d` attribute per icon, or a `[tag, attrs][]` node list (Lucide's IconNode shape) using only `path`, `line`, `circle`, `ellipse`, `rect`, `polyline`, `polygon`. No `<g>` wrappers and no `transform` attributes — coordinates must be literal. Any other tag throws a clear error.
|
|
97
|
-
3. **A shared coordinate space per pair.** Both endpoints of a morph must live on the same grid. Lucide,
|
|
97
|
+
3. **A shared coordinate space per pair.** Both endpoints of a morph must live on the same grid. Lucide, Tabler, Heroicons and Iconoir all draw on 24×24 — that's why cross-library morphs just work. For a pack on another canvas (Heroicons *solid* on 20, Carbon on 32, Teenyicons on 15), re-grid it once with `fitIcon`:
|
|
98
|
+
|
|
99
|
+
```ts
|
|
100
|
+
import { fitIcon } from "morphicons";
|
|
101
|
+
|
|
102
|
+
const search = fitIcon(carbonSearch, 32); // 32×32 → the 24 grid
|
|
103
|
+
const bell = fitIcon(teenySmallBell, 15); // also "0 0 15 15" or [0, 0, 15, 15]
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
It returns a plain `d`, so it goes anywhere an icon is accepted. Call it at module scope, not per render. Skipping it doesn't throw — Procrustes is similarity-invariant, so the grid mismatch never reads as false rotation; it lands in σ as an unwanted zoom, and the target draws outside the canvas. The React binding defaults to `viewBox="0 0 24 24"`, overridable via props.
|
|
98
107
|
4. **Uniform stroke.** Cosmetic rather than structural: a consistent stroke width and round caps/joins make in-flight shapes look native. These live on the `<svg>`, not in morphicons.
|
|
99
108
|
|
|
100
|
-
Beyond the
|
|
109
|
+
Beyond the libraries in the playground, sets known to qualify on the 24×24 grid — no fitting needed: **Heroicons** (outline style), **Iconoir** (1.5px stroke), **Akar Icons**, **Untitled UI** and **Hugeicons** (stroke style). Off-grid packs work through `fitIcon`: **Teenyicons** (15×15), **Carbon** (32×32), **Heroicons solid** (20×20).
|
|
110
|
+
|
|
111
|
+
This also covers the [shadcn registry](https://www.shadcn.io/icons), which re-publishes 200+ icon libraries as React components with an inline `<path d>` — pass that `d` straight in, adding `fitIcon` when the pack's `viewBox` isn't 24.
|
|
101
112
|
|
|
102
113
|
## Architecture
|
|
103
114
|
|
|
@@ -244,7 +255,7 @@ CI budget (size-limit, gzip) as an anti-regression tripwire — gates carry ~10%
|
|
|
244
255
|
|
|
245
256
|
| entry | measured | gate |
|
|
246
257
|
|---|---|---|
|
|
247
|
-
| `morphicons` (core) | 6.
|
|
258
|
+
| `morphicons` (core) | 6.45 KB | 7 KB |
|
|
248
259
|
| `morphicons/dom` (core + driver) | 6.92 KB | 7.5 KB |
|
|
249
260
|
| `morphicons/react` (all, react external) | 7.65 KB | 8.5 KB |
|
|
250
261
|
|
|
@@ -259,7 +270,7 @@ bun run play # → http://localhost:3000
|
|
|
259
270
|
## Development
|
|
260
271
|
|
|
261
272
|
```bash
|
|
262
|
-
bun test #
|
|
273
|
+
bun test # 83 tests / ~13,400 asserts
|
|
263
274
|
bun run typecheck # strict ×3: core+dom without lib DOM, playground, react
|
|
264
275
|
bun run format # biome
|
|
265
276
|
bun run build # tsdown → dist/ (entries index, dom, react)
|
package/dist/dom.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { a as
|
|
1
|
+
import { a as buildPlan, c as cubicsToPathD, f as interpPolar, l as serialize, n as Spring, r as resampleIcon, s as iconToCubics, t as SPRING_PRESETS, u as allocOutputs } from "./spring-BR0BCAfU.js";
|
|
2
2
|
//#region src/dom/index.ts
|
|
3
3
|
const tickers = /* @__PURE__ */ new Set();
|
|
4
4
|
let rafId = 0;
|
package/dist/index.d.ts
CHANGED
|
@@ -38,6 +38,17 @@ declare function interpLinear(plan: MorphPlan, t: number, out: Float64Array[]):
|
|
|
38
38
|
//#region src/core/normalize.d.ts
|
|
39
39
|
/** Icon (IconNode or `d` string) → list of cubic subpaths. */
|
|
40
40
|
declare function iconToCubics(input: IconInput): CubicPath[];
|
|
41
|
+
/** A source viewBox: `24`, `"0 0 20 20"` or `[minX, minY, w, h]`. */
|
|
42
|
+
type ViewBox = number | string | readonly [number, number, number, number];
|
|
43
|
+
/** Re-grids an icon drawn on `viewBox` onto the shared `grid` (24 by default),
|
|
44
|
+
* centred and preserving aspect ratio — the SVG `xMidYMid meet` rule.
|
|
45
|
+
*
|
|
46
|
+
* Both endpoints of a morph must live on the same coordinate space. Lucide and
|
|
47
|
+
* Tabler already draw on 24×24; packs on 20 (Heroicons solid) or 32 (Carbon)
|
|
48
|
+
* do not, and mixing them unfitted makes Procrustes read the scale/offset gap
|
|
49
|
+
* as rotation. Apply once at module scope (not per render) and pass the
|
|
50
|
+
* resulting `d` anywhere an icon is accepted. */
|
|
51
|
+
declare function fitIcon(input: IconInput, viewBox: ViewBox, grid?: number): string;
|
|
41
52
|
//#endregion
|
|
42
53
|
//#region src/core/resample.d.ts
|
|
43
54
|
/** Samples a cubic subpath at N points equidistant by arc length, anchoring
|
|
@@ -57,4 +68,4 @@ declare function serialize(subs: readonly Float64Array[], closed?: readonly bool
|
|
|
57
68
|
/** Cubic subpaths → canonical `d` at full precision (round-trip). */
|
|
58
69
|
declare function cubicsToPathD(paths: readonly CubicPath[]): string;
|
|
59
70
|
//#endregion
|
|
60
|
-
export { type CubicPath, type IconInput, type IconNode, type IconNodeAttrs, type MorphPlan, type PlanItem, SPRING_PRESETS, type Sampled, Spring, type SpringPreset, allocOutputs, buildPlan, cubicsToPathD, iconToCubics, interpLinear, interpPolar, resampleIcon, resamplePath, serialize };
|
|
71
|
+
export { type CubicPath, type IconInput, type IconNode, type IconNodeAttrs, type MorphPlan, type PlanItem, SPRING_PRESETS, type Sampled, Spring, type SpringPreset, type ViewBox, allocOutputs, buildPlan, cubicsToPathD, fitIcon, iconToCubics, interpLinear, interpPolar, resampleIcon, resamplePath, serialize };
|
package/dist/index.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { a as
|
|
2
|
-
export { SPRING_PRESETS, Spring, allocOutputs, buildPlan, cubicsToPathD, iconToCubics, interpLinear, interpPolar, resampleIcon, resamplePath, serialize };
|
|
1
|
+
import { a as buildPlan, c as cubicsToPathD, d as interpLinear, f as interpPolar, i as resamplePath, l as serialize, n as Spring, o as fitIcon, r as resampleIcon, s as iconToCubics, t as SPRING_PRESETS, u as allocOutputs } from "./spring-BR0BCAfU.js";
|
|
2
|
+
export { SPRING_PRESETS, Spring, allocOutputs, buildPlan, cubicsToPathD, fitIcon, iconToCubics, interpLinear, interpPolar, resampleIcon, resamplePath, serialize };
|
package/dist/react.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { a as
|
|
1
|
+
import { a as buildPlan, f as interpPolar, l as serialize, r as resampleIcon, u as allocOutputs } from "./spring-BR0BCAfU.js";
|
|
2
2
|
import { canonicalD, createMorph } from "./dom.js";
|
|
3
3
|
import { forwardRef, useEffect, useImperativeHandle, useLayoutEffect, useRef, useState } from "react";
|
|
4
4
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
@@ -279,6 +279,34 @@ function parsePath(d) {
|
|
|
279
279
|
return subs.filter((s) => s.segs.length > 0);
|
|
280
280
|
}
|
|
281
281
|
//#endregion
|
|
282
|
+
//#region src/core/serialize.ts
|
|
283
|
+
function fmt(v) {
|
|
284
|
+
return String(Math.round(v * 100) / 100);
|
|
285
|
+
}
|
|
286
|
+
/** Sampled subpaths → polyline `d` attribute. `closed[k]` appends Z to
|
|
287
|
+
* subpath k (closed loops in flight); without flags everything is open. */
|
|
288
|
+
function serialize(subs, closed) {
|
|
289
|
+
let d = "";
|
|
290
|
+
for (let k = 0; k < subs.length; k++) {
|
|
291
|
+
const o = subs[k];
|
|
292
|
+
const n = o.length / 2;
|
|
293
|
+
d += `M${fmt(o[0])} ${fmt(o[1])}`;
|
|
294
|
+
for (let i = 1; i < n; i++) d += `L${fmt(o[2 * i])} ${fmt(o[2 * i + 1])}`;
|
|
295
|
+
if (closed?.[k]) d += "Z";
|
|
296
|
+
}
|
|
297
|
+
return d;
|
|
298
|
+
}
|
|
299
|
+
/** Cubic subpaths → canonical `d` at full precision (round-trip). */
|
|
300
|
+
function cubicsToPathD(paths) {
|
|
301
|
+
let d = "";
|
|
302
|
+
for (const { pts, closed } of paths) {
|
|
303
|
+
d += `M${pts[0]} ${pts[1]}`;
|
|
304
|
+
for (let i = 2; i < pts.length; i += 6) d += `C${pts[i]} ${pts[i + 1]} ${pts[i + 2]} ${pts[i + 3]} ${pts[i + 4]} ${pts[i + 5]}`;
|
|
305
|
+
if (closed) d += "Z";
|
|
306
|
+
}
|
|
307
|
+
return d;
|
|
308
|
+
}
|
|
309
|
+
//#endregion
|
|
282
310
|
//#region src/core/normalize.ts
|
|
283
311
|
/** Control-point offset for a quarter circle: (4/3)·tan(π/8) ≈ 0.5523. */
|
|
284
312
|
const KAPPA = 4 / 3 * Math.tan(Math.PI / 8);
|
|
@@ -505,6 +533,42 @@ function iconToCubics(input) {
|
|
|
505
533
|
}
|
|
506
534
|
return out;
|
|
507
535
|
}
|
|
536
|
+
function parseViewBox(vb) {
|
|
537
|
+
const v = typeof vb === "number" ? [
|
|
538
|
+
0,
|
|
539
|
+
0,
|
|
540
|
+
vb,
|
|
541
|
+
vb
|
|
542
|
+
] : typeof vb === "string" ? vb.trim().split(/[\s,]+/).map(Number) : vb;
|
|
543
|
+
const [minX, minY, w, h] = v;
|
|
544
|
+
if (v.length !== 4 || !(w > 0) || !(h > 0) || !Number.isFinite(minX) || !Number.isFinite(minY)) throw new Error(`morphicons: invalid viewBox "${String(vb)}"`);
|
|
545
|
+
return [
|
|
546
|
+
minX,
|
|
547
|
+
minY,
|
|
548
|
+
w,
|
|
549
|
+
h
|
|
550
|
+
];
|
|
551
|
+
}
|
|
552
|
+
/** Re-grids an icon drawn on `viewBox` onto the shared `grid` (24 by default),
|
|
553
|
+
* centred and preserving aspect ratio — the SVG `xMidYMid meet` rule.
|
|
554
|
+
*
|
|
555
|
+
* Both endpoints of a morph must live on the same coordinate space. Lucide and
|
|
556
|
+
* Tabler already draw on 24×24; packs on 20 (Heroicons solid) or 32 (Carbon)
|
|
557
|
+
* do not, and mixing them unfitted makes Procrustes read the scale/offset gap
|
|
558
|
+
* as rotation. Apply once at module scope (not per render) and pass the
|
|
559
|
+
* resulting `d` anywhere an icon is accepted. */
|
|
560
|
+
function fitIcon(input, viewBox, grid = 24) {
|
|
561
|
+
const [minX, minY, w, h] = parseViewBox(viewBox);
|
|
562
|
+
const s = Math.min(grid / w, grid / h);
|
|
563
|
+
const tx = (grid - w * s) / 2 - minX * s;
|
|
564
|
+
const ty = (grid - h * s) / 2 - minY * s;
|
|
565
|
+
const paths = iconToCubics(input);
|
|
566
|
+
for (const { pts } of paths) for (let i = 0; i < pts.length; i += 2) {
|
|
567
|
+
pts[i] = pts[i] * s + tx;
|
|
568
|
+
pts[i + 1] = pts[i + 1] * s + ty;
|
|
569
|
+
}
|
|
570
|
+
return cubicsToPathD(paths);
|
|
571
|
+
}
|
|
508
572
|
//#endregion
|
|
509
573
|
//#region src/core/plan.ts
|
|
510
574
|
/** Weight of |ΔL| in the subpath pairing cost. */
|
|
@@ -1021,34 +1085,6 @@ function resampleIcon(input, N = 64) {
|
|
|
1021
1085
|
}));
|
|
1022
1086
|
}
|
|
1023
1087
|
//#endregion
|
|
1024
|
-
//#region src/core/serialize.ts
|
|
1025
|
-
function fmt(v) {
|
|
1026
|
-
return String(Math.round(v * 100) / 100);
|
|
1027
|
-
}
|
|
1028
|
-
/** Sampled subpaths → polyline `d` attribute. `closed[k]` appends Z to
|
|
1029
|
-
* subpath k (closed loops in flight); without flags everything is open. */
|
|
1030
|
-
function serialize(subs, closed) {
|
|
1031
|
-
let d = "";
|
|
1032
|
-
for (let k = 0; k < subs.length; k++) {
|
|
1033
|
-
const o = subs[k];
|
|
1034
|
-
const n = o.length / 2;
|
|
1035
|
-
d += `M${fmt(o[0])} ${fmt(o[1])}`;
|
|
1036
|
-
for (let i = 1; i < n; i++) d += `L${fmt(o[2 * i])} ${fmt(o[2 * i + 1])}`;
|
|
1037
|
-
if (closed?.[k]) d += "Z";
|
|
1038
|
-
}
|
|
1039
|
-
return d;
|
|
1040
|
-
}
|
|
1041
|
-
/** Cubic subpaths → canonical `d` at full precision (round-trip). */
|
|
1042
|
-
function cubicsToPathD(paths) {
|
|
1043
|
-
let d = "";
|
|
1044
|
-
for (const { pts, closed } of paths) {
|
|
1045
|
-
d += `M${pts[0]} ${pts[1]}`;
|
|
1046
|
-
for (let i = 2; i < pts.length; i += 6) d += `C${pts[i]} ${pts[i + 1]} ${pts[i + 2]} ${pts[i + 3]} ${pts[i + 4]} ${pts[i + 5]}`;
|
|
1047
|
-
if (closed) d += "Z";
|
|
1048
|
-
}
|
|
1049
|
-
return d;
|
|
1050
|
-
}
|
|
1051
|
-
//#endregion
|
|
1052
1088
|
//#region src/core/spring.ts
|
|
1053
1089
|
var Spring = class {
|
|
1054
1090
|
x = 1;
|
|
@@ -1096,4 +1132,4 @@ const SPRING_PRESETS = {
|
|
|
1096
1132
|
}
|
|
1097
1133
|
};
|
|
1098
1134
|
//#endregion
|
|
1099
|
-
export {
|
|
1135
|
+
export { buildPlan as a, cubicsToPathD as c, interpLinear as d, interpPolar as f, resamplePath as i, serialize as l, Spring as n, fitIcon as o, resampleIcon as r, iconToCubics as s, SPRING_PRESETS as t, allocOutputs as u };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "morphicons",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "Universal morphing for stroke-based icons with spring physics — zero runtime dependencies",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Guillermo (https://guillermolg.com)",
|
|
@@ -14,11 +14,20 @@
|
|
|
14
14
|
"icons",
|
|
15
15
|
"svg",
|
|
16
16
|
"morphing",
|
|
17
|
+
"morph",
|
|
18
|
+
"morph-animation",
|
|
17
19
|
"animation",
|
|
20
|
+
"animated-icons",
|
|
21
|
+
"icon-morphing",
|
|
22
|
+
"svg-morph",
|
|
18
23
|
"spring",
|
|
19
24
|
"lucide",
|
|
20
|
-
"
|
|
25
|
+
"lucide-react",
|
|
21
26
|
"tabler",
|
|
27
|
+
"heroicons",
|
|
28
|
+
"iconoir",
|
|
29
|
+
"shadcn",
|
|
30
|
+
"feather",
|
|
22
31
|
"react",
|
|
23
32
|
"icon-animation"
|
|
24
33
|
],
|