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 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, Feather, Tabler, 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.
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. Feather, Tabler and custom paths work the same.
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, Feather and Tabler all draw on 24×24 — that's why cross-library morphs just work. The React binding defaults to `viewBox="0 0 24 24"`, overridable via props.
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 three libraries in the playground, sets known to qualify: **Heroicons** (outline style, 24×24), **Iconoir** (24×24, 1.5px stroke), **Akar Icons** (24×24), **Untitled UI** (24×24) and **Hugeicons** (stroke style, 24×24). **Teenyicons** qualifies too but draws on a 15×15 grid keep both endpoints on that grid and pass the matching `viewBox`.
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.32 KB | 7 KB |
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 # 74 tests / ~13,400 asserts
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 resampleIcon, c as iconToCubics, d as interpPolar, i as serialize, l as allocOutputs, n as Spring, r as cubicsToPathD, s as buildPlan, t as SPRING_PRESETS } from "./spring-Dbj7NCw2.js";
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 resampleIcon, c as iconToCubics, d as interpPolar, i as serialize, l as allocOutputs, n as Spring, o as resamplePath, r as cubicsToPathD, s as buildPlan, t as SPRING_PRESETS, u as interpLinear } from "./spring-Dbj7NCw2.js";
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 resampleIcon, d as interpPolar, i as serialize, l as allocOutputs, s as buildPlan } from "./spring-Dbj7NCw2.js";
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 { resampleIcon as a, iconToCubics as c, interpPolar as d, serialize as i, allocOutputs as l, Spring as n, resamplePath as o, cubicsToPathD as r, buildPlan as s, SPRING_PRESETS as t, interpLinear as u };
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": "0.1.1",
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
- "feather",
25
+ "lucide-react",
21
26
  "tabler",
27
+ "heroicons",
28
+ "iconoir",
29
+ "shadcn",
30
+ "feather",
22
31
  "react",
23
32
  "icon-animation"
24
33
  ],