morphicons 1.1.0 → 1.2.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
@@ -36,7 +36,7 @@ The usual icon morphs either interpolate raw coordinates (shapes shrink and shea
36
36
  bun add morphicons # or npm install / pnpm add
37
37
  ```
38
38
 
39
- ESM only. `react` is an optional peer (>= 18) — only needed for `morphicons/react`.
39
+ ESM only. `react` (>= 18) and `vue` (>= 3.3) are optional peers — only needed for `morphicons/react` and `morphicons/vue`.
40
40
 
41
41
  ## Usage
42
42
 
@@ -60,6 +60,34 @@ ref.current?.set(X); // jumps without animating
60
60
 
61
61
  Drop-in replacement for lucide-react: `size`, `strokeWidth`, `absoluteStrokeWidth`, `color`, `className` and the rest of the `<svg>` props pass straight through. Correct accessibility by default: `aria-hidden` unless you pass `label` (→ `role="img"` + `<title>`). Clean SSR: the server emits the exact static SVG (zero flash, zero layout shift); the runtime is born on hydration. `prefers-reduced-motion` degrades to an instant swap.
62
62
 
63
+ ### Vue — same three modes
64
+
65
+ ```vue
66
+ <script setup lang="ts">
67
+ import { ref } from "vue";
68
+ import { MorphIcon, type MorphHandle } from "morphicons/vue";
69
+ import { Menu, X, Check } from "lucide"; // data, not components
70
+
71
+ const open = ref(false);
72
+ const morph = ref<MorphHandle | null>(null);
73
+ // morph.value?.morphTo(Check) → animates
74
+ // morph.value?.set(X) → jumps without animating
75
+ </script>
76
+
77
+ <template>
78
+ <!-- 1. Uncontrolled (90% of uses): change the prop and morphicons animates -->
79
+ <MorphIcon :icon="open ? X : Menu" spring="snappy" />
80
+
81
+ <!-- 2. Controlled (gestures, scroll): explicit progress, no spring -->
82
+ <MorphIcon :from="Menu" :to="X" :progress="dragProgress" />
83
+
84
+ <!-- 3. Imperative (sequences): template ref → morphTo / set -->
85
+ <MorphIcon ref="morph" :icon="Menu" />
86
+ </template>
87
+ ```
88
+
89
+ Same surface as the React binding: presentation props (`size`, `strokeWidth`, `absoluteStrokeWidth`, `color`; `class`, `style` and the rest of the `<svg>` attrs fall through), the same accessibility defaults (`aria-hidden` unless you pass `label`) and the same clean SSR — works with Nuxt out of the box: the server emits the exact static SVG and the runtime is born on hydration. The binding is a plain render function; no SFC compiler or JSX involved.
90
+
63
91
  ### Vanilla (no React)
64
92
 
65
93
  ```ts
@@ -103,7 +131,7 @@ morphicons has no per-library adapters — any icon set that meets these require
103
131
  const bell = fitIcon(teenySmallBell, 15); // also "0 0 15 15" or [0, 0, 15, 15]
104
132
  ```
105
133
 
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.
134
+ 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 and Vue bindings default to `viewBox="0 0 24 24"`, overridable via props.
107
135
  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.
108
136
 
109
137
  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).
@@ -114,7 +142,7 @@ This also covers the [shadcn registry](https://www.shadcn.io/icons), which re-pu
114
142
 
115
143
  ```
116
144
  ┌─────────────────────────────────────────────┐
117
- │ bindings react │ thin: ref + createMorph
145
+ │ bindings react · vue │ thin: ref + createMorph
118
146
  ├─────────────────────────────────────────────┤
119
147
  │ drivers dom (setAttribute + rAF) │ singleton scheduler
120
148
  ├─────────────────────────────────────────────┤
@@ -125,7 +153,7 @@ This also covers the [shadcn registry](https://www.shadcn.io/icons), which re-pu
125
153
  └─────────────────────────────────────────────┘
126
154
  ```
127
155
 
128
- The hard rule: **the core never touches the DOM**. Pure functions consume icon data and produce `d` strings. Direct consequence: a React Native driver over `react-native-svg` + Reanimated is just another adapter, not a rewrite. One package with subpath exports (`.` core, `./dom`, `./react`), ESM only, `sideEffects: false`.
156
+ The hard rule: **the core never touches the DOM**. Pure functions consume icon data and produce `d` strings. Direct consequence: a React Native driver over `react-native-svg` + Reanimated is just another adapter, not a rewrite. One package with subpath exports (`.` core, `./dom`, `./react`, `./vue`), ESM only, `sideEffects: false`.
129
157
 
130
158
  The plan is the central artifact:
131
159
 
@@ -208,7 +236,7 @@ Let `aᶜᵢ = aᵢ − c_A` and `b̃ᵢ = R(−θ*)·(bᵢ − c_B)/σ*` (B bro
208
236
  ```
209
237
  P(t) = c(t) + σ*ᵗ · R(t·θ*) · [(1−t)·aᶜᵢ + t·b̃ᵢ]
210
238
 
211
- c(t) = lerp(c_A, c_B, t) linear translation
239
+ c(t) = lerp(c_A, c_B, t) linear translation (block transport under the global hybrid — below)
212
240
  t·θ* = linear angle (θ* is already in (−π, π], short path)
213
241
  σ*ᵗ = exp(t·ln σ*) log-linear scale (geodesic in ℝ⁺)
214
242
  ```
@@ -220,6 +248,14 @@ Exact at both endpoints (max error ~3.6e-15 in tests). Properties:
220
248
  - Mixed cases do both simultaneously and continuously.
221
249
  - With spring overshoot (t > 1) the formula **extrapolates** naturally: rotation and scale overshoot slightly and come back — free juice.
222
250
 
251
+ **Block transport (global hybrid).** When the whole icon is congruent (§4), lerping each subpath centroid reintroduces the chord disease one level up: every part spins with the shared θ*, but an off-center part travels the chord — inside the arc — and drifts toward the center mid-flight. An arrow's head sagged ~1 px toward its shaft at t = 0.5 (`r·(1 − cos(θ/2))` with r = 3.5, θ = 90°) while both parts rotated perfectly. Under the hybrid, each centroid rides the shared similarity around the global centroid `g_A` instead:
252
+
253
+ ```
254
+ c_k(t) = c_{A,k} + t·d_k + (σ*ᵗ·R(t·θ*) − I)·(c_{A,k} − g_A)
255
+ ```
256
+
257
+ `d_k` is fixed once so that `c_k(1) = c_{B,k}` exactly (it absorbs the tiny global residual). Every point of every subpath then moves under ONE similarity per frame: congruent icons are rigid through the whole flight, not only at the endpoints. With θ* = 0, or centroids sitting on `g_A`, or no hybrid at all, the formula degrades continuously to the plain lerp — no thresholds, no special cases.
258
+
223
259
  ### 6. Serialization and canonical snap
224
260
 
225
261
  In flight, each subpath is emitted as `M x y L x y …` (+ `Z` if the pair is closed↔closed; closed→open flies open, the loop opening at the cut chosen by the circular correspondence) with 2 decimals — invisible at 24px, and the frame's only allocation. On settle, the driver **snaps to the canonical `d`** of the target icon: exact fidelity at rest (real curves, not polylines), subpath count reset after duplications, and the DOM ends up identical to a static icon's. The jump is < 0.02px — imperceptible.
@@ -255,9 +291,10 @@ CI budget (size-limit, gzip) as an anti-regression tripwire — gates carry ~10%
255
291
 
256
292
  | entry | measured | gate |
257
293
  |---|---|---|
258
- | `morphicons` (core) | 6.45 KB | 7 KB |
259
- | `morphicons/dom` (core + driver) | 6.92 KB | 7.5 KB |
260
- | `morphicons/react` (all, react external) | 7.65 KB | 8.5 KB |
294
+ | `morphicons` (core) | 6.60 KB | 7 KB |
295
+ | `morphicons/dom` (core + driver) | 7.04 KB | 7.5 KB |
296
+ | `morphicons/react` (all, react external) | 7.77 KB | 8.5 KB |
297
+ | `morphicons/vue` (all, vue external) | 7.81 KB | 8.5 KB |
261
298
 
262
299
  ## Playground
263
300
 
@@ -270,15 +307,27 @@ bun run play # → http://localhost:3000
270
307
  ## Development
271
308
 
272
309
  ```bash
273
- bun test # 83 tests / ~13,400 asserts
274
- bun run typecheck # strict ×3: core+dom without lib DOM, playground, react
310
+ bun test # 92 tests / ~13,500 asserts
311
+ bun run typecheck # strict ×4: core+dom without lib DOM, playground, react, vue
275
312
  bun run format # biome
276
- bun run build # tsdown → dist/ (entries index, dom, react)
313
+ bun run build # tsdown → dist/ (entries index, dom, react, vue)
277
314
  bun run size # size gates
278
315
  ```
279
316
 
280
317
  Icons shown in the playground belong to their authors: [Lucide](https://lucide.dev) (ISC), [Feather](https://feathericons.com) (MIT), [Tabler](https://tabler.io/icons) (MIT).
281
318
 
319
+ ## Star history
320
+
321
+ <p align="center">
322
+ <a href="https://star-history.com/#guillermolg00/morphicons&Date">
323
+ <picture>
324
+ <source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=guillermolg00/morphicons&type=Date&theme=dark">
325
+ <source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=guillermolg00/morphicons&type=Date">
326
+ <img alt="Star history chart of morphicons" src="https://api.star-history.com/svg?repos=guillermolg00/morphicons&type=Date" width="640">
327
+ </picture>
328
+ </a>
329
+ </p>
330
+
282
331
  ---
283
332
 
284
333
  MIT © [Guillermo](https://guillermolg.com) — made by [guillermolg.com](https://guillermolg.com)
package/dist/dom.js CHANGED
@@ -1,4 +1,4 @@
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";
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-BTcHiHR5.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
@@ -18,6 +18,13 @@ interface PlanItem {
18
18
  /** true if both endpoints are closed loops: the subpath flies with Z.
19
19
  * Closed → open flies open: the loop opens at the chosen cut. */
20
20
  closed: boolean;
21
+ /** Block transport (set by the global hybrid, else null): mid-flight the
22
+ * centroid rides the shared similarity around the global centroid instead
23
+ * of lerping — off = c_A − g_A; drift closes c(1) = c_B exactly. */
24
+ block: {
25
+ off: readonly [number, number];
26
+ drift: readonly [number, number];
27
+ } | null;
21
28
  }
22
29
  interface MorphPlan {
23
30
  items: PlanItem[];
package/dist/index.js CHANGED
@@ -1,2 +1,2 @@
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";
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-BTcHiHR5.js";
2
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 buildPlan, f as interpPolar, l as serialize, r as resampleIcon, u as allocOutputs } from "./spring-BR0BCAfU.js";
1
+ import { a as buildPlan, f as interpPolar, l as serialize, r as resampleIcon, u as allocOutputs } from "./spring-BTcHiHR5.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";
@@ -8,12 +8,21 @@ function interpPolar(plan, t, out) {
8
8
  const it = plan.items[k];
9
9
  const o = out[k];
10
10
  const n = plan.n;
11
- const cx = it.ca[0] + (it.cb[0] - it.ca[0]) * t;
12
- const cy = it.ca[1] + (it.cb[1] - it.ca[1]) * t;
13
11
  const s = Math.exp(it.lnSigma * t);
14
12
  const ang = it.theta * t;
15
13
  const cos = Math.cos(ang) * s;
16
14
  const sin = Math.sin(ang) * s;
15
+ let cx;
16
+ let cy;
17
+ if (it.block) {
18
+ const [ox, oy] = it.block.off;
19
+ const [dx, dy] = it.block.drift;
20
+ cx = it.ca[0] + dx * t + (ox * cos - oy * sin - ox);
21
+ cy = it.ca[1] + dy * t + (ox * sin + oy * cos - oy);
22
+ } else {
23
+ cx = it.ca[0] + (it.cb[0] - it.ca[0]) * t;
24
+ cy = it.ca[1] + (it.cb[1] - it.ca[1]) * t;
25
+ }
17
26
  for (let i = 0; i < n; i++) {
18
27
  const px = it.aC[2 * i] + (it.bT[2 * i] - it.aC[2 * i]) * t;
19
28
  const py = it.aC[2 * i + 1] + (it.bT[2 * i + 1] - it.aC[2 * i + 1]) * t;
@@ -806,7 +815,8 @@ function applyGlobal(items, n) {
806
815
  ga.set(it.a, 2 * n * k);
807
816
  gb.set(it.bO, 2 * n * k);
808
817
  });
809
- const g = procrustes(ga, gb, centroid(ga), centroid(gb));
818
+ const gca = centroid(ga);
819
+ const g = procrustes(ga, gb, gca, centroid(gb));
810
820
  if (g.res >= GLOBAL_EPS) return;
811
821
  const cos = Math.cos(-g.theta);
812
822
  const sin = Math.sin(-g.theta);
@@ -828,6 +838,17 @@ function applyGlobal(items, n) {
828
838
  it.theta = g.theta;
829
839
  it.lnSigma = Math.log(g.sigma);
830
840
  it.res = nb > 1e-12 ? Math.sqrt(e2 / nb) : 0;
841
+ const s1 = Math.exp(it.lnSigma);
842
+ const c1 = Math.cos(it.theta) * s1;
843
+ const n1 = Math.sin(it.theta) * s1;
844
+ const ox = it.ca[0] - gca[0];
845
+ const oy = it.ca[1] - gca[1];
846
+ const rx = ox * c1 - oy * n1 - ox;
847
+ const ry = ox * n1 + oy * c1 - oy;
848
+ it.block = {
849
+ off: [ox, oy],
850
+ drift: [it.cb[0] - it.ca[0] - rx, it.cb[1] - it.ca[1] - ry]
851
+ };
831
852
  }
832
853
  }
833
854
  /** Builds the morph plan between two lists of sampled subpaths. The plan is
@@ -879,7 +900,8 @@ function buildPlan(srcSubs, dstSubs) {
879
900
  theta: al.theta,
880
901
  lnSigma: Math.log(al.sigma),
881
902
  res: al.res,
882
- closed: srcSubs[si].closed && dstSubs[di].closed
903
+ closed: srcSubs[si].closed && dstSubs[di].closed,
904
+ block: null
883
905
  };
884
906
  });
885
907
  if (items.length > 1) applyGlobal(items, n);
package/dist/vue.d.ts ADDED
@@ -0,0 +1,121 @@
1
+ import { a as Sampled, c as SpringPreset, n as IconInput, r as IconNode } from "./types-JAF3s76Y.js";
2
+ import { Morph, MorphOptions, PathEl } from "./dom.js";
3
+ import { PropType } from "vue";
4
+ //#region src/vue/index.d.ts
5
+ /** Imperative surface exposed on the component instance (template ref). */
6
+ interface MorphHandle {
7
+ morphTo(icon: IconInput, spring?: SpringPreset | MorphOptions): void;
8
+ set(icon: IconInput): void;
9
+ }
10
+ declare const MorphIcon: import("vue").DefineComponent<import("vue").ExtractPropTypes<{
11
+ /** Uncontrolled mode: the current icon; changing the prop animates. */
12
+ icon: {
13
+ type: PropType<IconInput>;
14
+ default: undefined;
15
+ };
16
+ /** Controlled mode: source endpoint of the pair. */
17
+ from: {
18
+ type: PropType<IconInput>;
19
+ default: undefined;
20
+ };
21
+ /** Controlled mode: target endpoint of the pair. */
22
+ to: {
23
+ type: PropType<IconInput>;
24
+ default: undefined;
25
+ };
26
+ /** Controlled mode: 0..1 progress of the frozen morph (no spring). */
27
+ progress: {
28
+ type: NumberConstructor;
29
+ default: undefined;
30
+ };
31
+ /** Physics for uncontrolled/imperative mode: preset or custom spring. */
32
+ spring: {
33
+ type: PropType<SpringPreset | MorphOptions>;
34
+ default: undefined;
35
+ };
36
+ size: {
37
+ type: (NumberConstructor | StringConstructor)[];
38
+ default: number;
39
+ };
40
+ color: {
41
+ type: StringConstructor;
42
+ default: string;
43
+ };
44
+ strokeWidth: {
45
+ type: (NumberConstructor | StringConstructor)[];
46
+ default: number;
47
+ };
48
+ /** Like lucide: stroke width does not scale with `size`. */
49
+ absoluteStrokeWidth: {
50
+ type: BooleanConstructor;
51
+ default: boolean;
52
+ };
53
+ /** Accessibility: with label → role="img" + <title>; without → aria-hidden. */
54
+ label: {
55
+ type: StringConstructor;
56
+ default: undefined;
57
+ };
58
+ }>, () => import("vue").VNode<import("vue").RendererNode, import("vue").RendererElement, {
59
+ [key: string]: any;
60
+ }>, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<import("vue").ExtractPropTypes<{
61
+ /** Uncontrolled mode: the current icon; changing the prop animates. */
62
+ icon: {
63
+ type: PropType<IconInput>;
64
+ default: undefined;
65
+ };
66
+ /** Controlled mode: source endpoint of the pair. */
67
+ from: {
68
+ type: PropType<IconInput>;
69
+ default: undefined;
70
+ };
71
+ /** Controlled mode: target endpoint of the pair. */
72
+ to: {
73
+ type: PropType<IconInput>;
74
+ default: undefined;
75
+ };
76
+ /** Controlled mode: 0..1 progress of the frozen morph (no spring). */
77
+ progress: {
78
+ type: NumberConstructor;
79
+ default: undefined;
80
+ };
81
+ /** Physics for uncontrolled/imperative mode: preset or custom spring. */
82
+ spring: {
83
+ type: PropType<SpringPreset | MorphOptions>;
84
+ default: undefined;
85
+ };
86
+ size: {
87
+ type: (NumberConstructor | StringConstructor)[];
88
+ default: number;
89
+ };
90
+ color: {
91
+ type: StringConstructor;
92
+ default: string;
93
+ };
94
+ strokeWidth: {
95
+ type: (NumberConstructor | StringConstructor)[];
96
+ default: number;
97
+ };
98
+ /** Like lucide: stroke width does not scale with `size`. */
99
+ absoluteStrokeWidth: {
100
+ type: BooleanConstructor;
101
+ default: boolean;
102
+ };
103
+ /** Accessibility: with label → role="img" + <title>; without → aria-hidden. */
104
+ label: {
105
+ type: StringConstructor;
106
+ default: undefined;
107
+ };
108
+ }>> & Readonly<{}>, {
109
+ icon: IconInput;
110
+ from: IconInput;
111
+ to: IconInput;
112
+ progress: number;
113
+ spring: "bouncy" | "smooth" | "snappy" | MorphOptions;
114
+ size: string | number;
115
+ color: string;
116
+ strokeWidth: string | number;
117
+ absoluteStrokeWidth: boolean;
118
+ label: string;
119
+ }, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
120
+ //#endregion
121
+ export { type IconInput, type IconNode, type Morph, MorphHandle, MorphIcon, type MorphOptions, type PathEl, type Sampled, type SpringPreset };
package/dist/vue.js ADDED
@@ -0,0 +1,158 @@
1
+ import { a as buildPlan, f as interpPolar, l as serialize, r as resampleIcon, u as allocOutputs } from "./spring-BTcHiHR5.js";
2
+ import { canonicalD, createMorph } from "./dom.js";
3
+ import { defineComponent, h, onBeforeUnmount, onMounted, shallowRef, watch } from "vue";
4
+ //#region src/vue/index.ts
5
+ /** Frozen shape of the from→to pair at t, using the pure core (SSR-safe).
6
+ * At exact endpoints returns the canonical `d` (real curves, not polyline). */
7
+ function frozenD(from, to, t) {
8
+ if (t <= 0) return canonicalD(from);
9
+ if (t >= 1) return canonicalD(to);
10
+ const plan = buildPlan(resampleIcon(from), resampleIcon(to));
11
+ const out = allocOutputs(plan);
12
+ interpPolar(plan, t, out);
13
+ return serialize(out, plan.items.map((it) => it.closed));
14
+ }
15
+ const MorphIcon = defineComponent({
16
+ name: "MorphIcon",
17
+ props: {
18
+ /** Uncontrolled mode: the current icon; changing the prop animates. */
19
+ icon: {
20
+ type: [String, Array],
21
+ default: void 0
22
+ },
23
+ /** Controlled mode: source endpoint of the pair. */
24
+ from: {
25
+ type: [String, Array],
26
+ default: void 0
27
+ },
28
+ /** Controlled mode: target endpoint of the pair. */
29
+ to: {
30
+ type: [String, Array],
31
+ default: void 0
32
+ },
33
+ /** Controlled mode: 0..1 progress of the frozen morph (no spring). */
34
+ progress: {
35
+ type: Number,
36
+ default: void 0
37
+ },
38
+ /** Physics for uncontrolled/imperative mode: preset or custom spring. */
39
+ spring: {
40
+ type: [String, Object],
41
+ default: void 0
42
+ },
43
+ size: {
44
+ type: [Number, String],
45
+ default: 24
46
+ },
47
+ color: {
48
+ type: String,
49
+ default: "currentColor"
50
+ },
51
+ strokeWidth: {
52
+ type: [Number, String],
53
+ default: 2
54
+ },
55
+ /** Like lucide: stroke width does not scale with `size`. */
56
+ absoluteStrokeWidth: {
57
+ type: Boolean,
58
+ default: false
59
+ },
60
+ /** Accessibility: with label → role="img" + <title>; without → aria-hidden. */
61
+ label: {
62
+ type: String,
63
+ default: void 0
64
+ }
65
+ },
66
+ setup(props, { expose }) {
67
+ const initialD = (() => {
68
+ const { icon, from, to, progress } = props;
69
+ if (from !== void 0 && to !== void 0) return frozenD(from, to, progress ?? 0);
70
+ const first = icon ?? from ?? to;
71
+ return first !== void 0 ? canonicalD(first) : "";
72
+ })();
73
+ const pathEl = shallowRef(null);
74
+ let morph = null;
75
+ let based = false;
76
+ let pair = null;
77
+ onMounted(() => {
78
+ const el = pathEl.value;
79
+ const { icon, from, to, progress } = props;
80
+ const controlled = from !== void 0 && to !== void 0;
81
+ const initialIcon = icon ?? from ?? to;
82
+ if (!el || initialIcon === void 0) return;
83
+ const m = createMorph(el, controlled ? from : initialIcon);
84
+ morph = m;
85
+ if (controlled) {
86
+ pair = [from, to];
87
+ const t = progress ?? 0;
88
+ if (t <= 0) m.set(from);
89
+ else if (t >= 1) m.set(to);
90
+ else {
91
+ m.seek(to, t);
92
+ based = true;
93
+ }
94
+ }
95
+ });
96
+ onBeforeUnmount(() => {
97
+ morph?.destroy();
98
+ morph = null;
99
+ based = false;
100
+ pair = null;
101
+ });
102
+ watch(() => props.icon, (icon) => {
103
+ if (icon !== void 0) morph?.morphTo(icon, props.spring);
104
+ });
105
+ watch(() => [
106
+ props.from,
107
+ props.to,
108
+ props.progress
109
+ ], () => {
110
+ const m = morph;
111
+ const { from, to, progress } = props;
112
+ if (!m || from === void 0 || to === void 0) return;
113
+ const t = progress ?? 0;
114
+ if (!pair || pair[0] !== from || pair[1] !== to) {
115
+ pair = [from, to];
116
+ based = false;
117
+ }
118
+ if (t <= 0) {
119
+ m.set(from);
120
+ based = false;
121
+ } else if (t >= 1) {
122
+ m.set(to);
123
+ based = false;
124
+ } else {
125
+ if (!based) {
126
+ m.set(from);
127
+ based = true;
128
+ }
129
+ m.seek(to, t);
130
+ }
131
+ });
132
+ expose({
133
+ morphTo: (i, s) => morph?.morphTo(i, s ?? props.spring),
134
+ set: (i) => morph?.set(i)
135
+ });
136
+ return () => {
137
+ const sw = props.absoluteStrokeWidth ? Number(props.strokeWidth) * 24 / Number(props.size) : props.strokeWidth;
138
+ return h("svg", {
139
+ xmlns: "http://www.w3.org/2000/svg",
140
+ width: props.size,
141
+ height: props.size,
142
+ viewBox: "0 0 24 24",
143
+ fill: "none",
144
+ stroke: props.color,
145
+ "stroke-width": sw,
146
+ "stroke-linecap": "round",
147
+ "stroke-linejoin": "round",
148
+ role: props.label ? "img" : void 0,
149
+ "aria-hidden": props.label ? void 0 : "true"
150
+ }, [props.label ? h("title", props.label) : null, h("path", {
151
+ ref: pathEl,
152
+ d: initialD
153
+ })]);
154
+ };
155
+ }
156
+ });
157
+ //#endregion
158
+ export { MorphIcon };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "morphicons",
3
- "version": "1.1.0",
3
+ "version": "1.2.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)",
@@ -29,6 +29,8 @@
29
29
  "shadcn",
30
30
  "feather",
31
31
  "react",
32
+ "vue",
33
+ "lucide-vue-next",
32
34
  "icon-animation"
33
35
  ],
34
36
  "type": "module",
@@ -48,14 +50,22 @@
48
50
  "./react": {
49
51
  "types": "./dist/react.d.ts",
50
52
  "default": "./dist/react.js"
53
+ },
54
+ "./vue": {
55
+ "types": "./dist/vue.d.ts",
56
+ "default": "./dist/vue.js"
51
57
  }
52
58
  },
53
59
  "peerDependencies": {
54
- "react": ">=18"
60
+ "react": ">=18",
61
+ "vue": ">=3.3"
55
62
  },
56
63
  "peerDependenciesMeta": {
57
64
  "react": {
58
65
  "optional": true
66
+ },
67
+ "vue": {
68
+ "optional": true
59
69
  }
60
70
  },
61
71
  "files": [
@@ -63,7 +73,7 @@
63
73
  ],
64
74
  "scripts": {
65
75
  "test": "bun test",
66
- "typecheck": "tsc --noEmit && tsc -p playground --noEmit && tsc -p tsconfig.react.json --noEmit",
76
+ "typecheck": "tsc --noEmit && tsc -p playground --noEmit && tsc -p tsconfig.react.json --noEmit && tsc -p tsconfig.vue.json --noEmit",
67
77
  "play": "bun ./playground/index.html",
68
78
  "lint": "biome check .",
69
79
  "format": "biome check --write .",
@@ -95,6 +105,16 @@
95
105
  ],
96
106
  "limit": "8.5 KB",
97
107
  "gzip": true
108
+ },
109
+ {
110
+ "name": "core + dom + vue (MorphIcon, vue external)",
111
+ "path": "dist/vue.js",
112
+ "import": "*",
113
+ "ignore": [
114
+ "vue"
115
+ ],
116
+ "limit": "8.5 KB",
117
+ "gzip": true
98
118
  }
99
119
  ],
100
120
  "devDependencies": {
@@ -110,6 +130,7 @@
110
130
  "react-dom": "^19.2.8",
111
131
  "size-limit": "^13.0.3",
112
132
  "tsdown": "^0.22.14",
113
- "typescript": "^7.0.2"
133
+ "typescript": "^7.0.2",
134
+ "vue": "^3.5.40"
114
135
  }
115
136
  }