morphicons 1.2.0 → 1.4.1

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,14 @@
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, 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.
16
+ <p align="center">
17
+ <a href="https://github.com/sponsors/guillermolg00">
18
+ <img src="https://img.shields.io/badge/%E2%98%95%20Buy%20me%20a%20coffee-EA4AAA" alt="Buy me a coffee — sponsor morphicons on GitHub Sponsors">
19
+ </a>
20
+ </p>
21
+
22
+ Universal morphing for stroke-based icons (Lucide, Tab
23
+ ler, 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
24
 
18
25
  ```tsx
19
26
  import { MorphIcon } from "morphicons/react";
@@ -36,7 +43,7 @@ The usual icon morphs either interpolate raw coordinates (shapes shrink and shea
36
43
  bun add morphicons # or npm install / pnpm add
37
44
  ```
38
45
 
39
- ESM only. `react` (>= 18) and `vue` (>= 3.3) are optional peers — only needed for `morphicons/react` and `morphicons/vue`.
46
+ ESM only. `react` (>= 18), `vue` (>= 3.3), `svelte` (>= 5) and `react-native` (>= 0.71) + `react-native-svg` (>= 14) are optional peers — only needed for `morphicons/react`, `morphicons/vue`, `morphicons/svelte` and `morphicons/react-native`.
40
47
 
41
48
  ## Usage
42
49
 
@@ -88,6 +95,60 @@ const morph = ref<MorphHandle | null>(null);
88
95
 
89
96
  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
97
 
98
+ ### Svelte — same three modes
99
+
100
+ ```svelte
101
+ <script lang="ts">
102
+ import { MorphIcon, type MorphHandle } from "morphicons/svelte";
103
+ import { Menu, X, Check } from "lucide"; // data, not components
104
+
105
+ let open = $state(false);
106
+ let morph = $state<MorphHandle>();
107
+ // morph?.morphTo(Check) → animates
108
+ // morph?.set(X) → jumps without animating
109
+ </script>
110
+
111
+ <!-- 1. Uncontrolled (90% of uses): change the prop and morphicons animates -->
112
+ <MorphIcon icon={open ? X : Menu} spring="snappy" />
113
+
114
+ <!-- 2. Controlled (gestures, scroll): explicit progress, no spring -->
115
+ <MorphIcon from={Menu} to={X} progress={dragProgress} />
116
+
117
+ <!-- 3. Imperative (sequences): bind:this → morphTo / set -->
118
+ <MorphIcon bind:this={morph} icon={Menu} />
119
+ ```
120
+
121
+ Same surface again: presentation props, `class`/`style`/rest-attr fall-through — fully typed via `svelte/elements`, so SVG attrs, events and ARIA autocomplete and typos fail the build, like lucide-svelte — the same accessibility defaults and the same clean SSR — works with SvelteKit out of the box. Svelte 5 (runes); the component ships as `.svelte` source and your bundler compiles it via the `svelte` export condition, like every Svelte library.
122
+
123
+ ### React Native — same three modes
124
+
125
+ ```tsx
126
+ import { MorphIcon, type MorphHandle } from "morphicons/react-native";
127
+ import { Menu, X, Check } from "lucide"; // data, not components
128
+
129
+ // 1. Uncontrolled (90% of uses): change the prop and morphicons animates
130
+ <MorphIcon icon={open ? X : Menu} spring="snappy" />
131
+
132
+ // 2. Controlled (gestures, scroll): explicit progress, no spring
133
+ <MorphIcon from={Menu} to={X} progress={dragProgress} />
134
+
135
+ // 3. Imperative (sequences)
136
+ const ref = useRef<MorphHandle>(null);
137
+ <MorphIcon ref={ref} icon={Menu} />
138
+ ref.current?.morphTo(Check); // animates
139
+ ref.current?.set(X); // jumps without animating
140
+ ```
141
+
142
+ The DOM-free core beyond the browser: the dom driver is reused verbatim as the engine (React Native has a global `requestAnimationFrame`, and `PathEl` is structural), so the whole platform difference is a shim that forwards the per-frame `d` write to `Path.setNativeProps` of react-native-svg — outside the React render, exactly like the web mutation. Same surface as the React binding (`size`, `strokeWidth`, `absoluteStrokeWidth`, `color`, plus the native `Svg` props: `testID`, touch handlers…), same accessibility defaults (`aria-hidden` unless you pass `label` → `role="img"` + `aria-label`). Reduced motion comes from `AccessibilityInfo` (best-effort: the query is async; a `reduceMotionChanged` subscription keeps it exact from then on). Requires Metro with package `exports` resolution — default since React Native 0.79; on older versions enable `unstable_enablePackageExports`.
143
+
144
+ ### Lifecycle contract (all four bindings)
145
+
146
+ The four components share one contract, pinned by mirrored client-mount tests:
147
+
148
+ - **Lazy driver.** Mounting without any icon is fine — SSR emits `<path d="">` and the driver is born with the FIRST icon that shows up, whether a late `icon` prop (data that loads async), a late `from`/`to` pair, or an imperative `set`/`morphTo`. The first icon paints without animating; `morphTo` before the driver exists behaves as `set` (there is nothing to fly from).
149
+ - **Controlled wins.** While `from` AND `to` are both present, the pair owns the path: `icon` changes are ignored, no spring fires. Drop the pair and the current `icon` takes over (animated). Mixing the modes is not an error — the precedence is just explicit.
150
+ - **Clean re-entry.** Any exit from controlled mode (an imperative call or an icon takeover) invalidates the frozen pair, so returning to the same `from`/`to` re-bases on `from` and renders exactly like a clean mount at that `progress`.
151
+
91
152
  ### Vanilla (no React)
92
153
 
93
154
  ```ts
@@ -131,7 +192,7 @@ morphicons has no per-library adapters — any icon set that meets these require
131
192
  const bell = fitIcon(teenySmallBell, 15); // also "0 0 15 15" or [0, 0, 15, 15]
132
193
  ```
133
194
 
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.
195
+ 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, Vue and Svelte bindings default to `viewBox="0 0 24 24"`, overridable via props.
135
196
  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.
136
197
 
137
198
  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).
@@ -142,7 +203,7 @@ This also covers the [shadcn registry](https://www.shadcn.io/icons), which re-pu
142
203
 
143
204
  ```
144
205
  ┌─────────────────────────────────────────────┐
145
- │ bindings react · vue │ thin: ref + createMorph
206
+ │ bindings react · vue · svelte · rn │ thin: ref + createMorph
146
207
  ├─────────────────────────────────────────────┤
147
208
  │ drivers dom (setAttribute + rAF) │ singleton scheduler
148
209
  ├─────────────────────────────────────────────┤
@@ -153,7 +214,7 @@ This also covers the [shadcn registry](https://www.shadcn.io/icons), which re-pu
153
214
  └─────────────────────────────────────────────┘
154
215
  ```
155
216
 
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`.
217
+ The hard rule: **the core never touches the DOM**. Pure functions consume icon data and produce `d` strings. Direct consequence: the React Native binding over `react-native-svg` is just another adapter, not a rewrite — it reuses the dom driver verbatim through a `setNativeProps` shim. One package with subpath exports (`.` core, `./dom`, `./react`, `./react-native`, `./vue`, `./svelte`), ESM only, `sideEffects: false`.
157
218
 
158
219
  The plan is the central artifact:
159
220
 
@@ -258,7 +319,7 @@ c_k(t) = c_{A,k} + t·d_k + (σ*ᵗ·R(t·θ*) − I)·(c_{A,k} − g_A)
258
319
 
259
320
  ### 6. Serialization and canonical snap
260
321
 
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.
322
+ 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. The canonical `d` itself is emitted with 4 decimals, and that is a correctness choice, not a size one: arc→cubic conversion goes through trig whose last ulp differs across JS engines, and SSR hydration compares the server's bytes against the browser's — quantized emission keeps them identical, where full precision leaked each engine's ulp into the markup (a Next hydration mismatch on icons with non-cardinal arcs).
262
323
 
263
324
  ### 7. The spring
264
325
 
@@ -293,8 +354,11 @@ CI budget (size-limit, gzip) as an anti-regression tripwire — gates carry ~10%
293
354
  |---|---|---|
294
355
  | `morphicons` (core) | 6.60 KB | 7 KB |
295
356
  | `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 |
357
+ | `morphicons/react` (all, react external) | 7.89 KB | 8.5 KB |
358
+ | `morphicons/react-native` (all, react/rn/rnsvg external) | 8.25 KB | 9 KB |
359
+ | `morphicons/vue` (all, vue external) | 7.93 KB | 8.5 KB |
360
+ | `morphicons/svelte` TS half (all, svelte external) | 7.54 KB | 8 KB |
361
+ | `MorphIcon.svelte` (ships as source, consumer-compiled) | 1.25 KB | 1.4 KB |
298
362
 
299
363
  ## Playground
300
364
 
@@ -307,27 +371,15 @@ bun run play # → http://localhost:3000
307
371
  ## Development
308
372
 
309
373
  ```bash
310
- bun test # 92 tests / ~13,500 asserts
311
- bun run typecheck # strict ×4: core+dom without lib DOM, playground, react, vue
374
+ bun test # 156 tests / ~13,700 asserts
375
+ bun run typecheck # strict ×6: core+dom without lib DOM, playground, react, react-native, vue, svelte
312
376
  bun run format # biome
313
- bun run build # tsdown → dist/ (entries index, dom, react, vue)
377
+ bun run build # tsdown → dist/ + scripts/build-svelte.ts (svelte entry)
314
378
  bun run size # size gates
315
379
  ```
316
380
 
317
381
  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).
318
382
 
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
-
331
383
  ---
332
384
 
333
385
  MIT © [Guillermo](https://guillermolg.com) — made by [guillermolg.com](https://guillermolg.com)
@@ -0,0 +1,86 @@
1
+ <!-- Svelte 5 binding: MorphIcon with the three modes — uncontrolled (change
2
+ the `icon` prop and morphicons animates), controlled (`from`/`to`/`progress`,
3
+ no spring) and imperative (`bind:this` → { morphTo, set }).
4
+
5
+ Clean SSR by construction: the initial `d` is computed ONCE with the pure
6
+ core at init (server and client produce the same string → hydration without
7
+ mismatch) and stays a plain const, so Svelte never rewrites the attribute —
8
+ the morph mutates it externally via createMorph. Thin shell on purpose: all
9
+ logic lives in shared.ts (typechecked); this file is only compiled.
10
+ Drop-in with lucide-svelte: same presentation props. -->
11
+ <script lang="ts">
12
+ import { onMount, untrack } from "svelte";
13
+ import {
14
+ computeInitialD,
15
+ createController,
16
+ type IconInput,
17
+ type MorphIconProps,
18
+ type MorphOptions,
19
+ type SpringPreset,
20
+ } from "./svelte-shared.js";
21
+
22
+ let {
23
+ icon,
24
+ from,
25
+ to,
26
+ progress,
27
+ spring,
28
+ size = 24,
29
+ color = "currentColor",
30
+ strokeWidth = 2,
31
+ absoluteStrokeWidth = false,
32
+ label,
33
+ ...rest
34
+ }: MorphIconProps = $props();
35
+
36
+ // svelte-ignore state_referenced_locally — mount-time value on purpose: the
37
+ // initial d must stay a constant so Svelte never rewrites the attribute.
38
+ const initialD = computeInitialD({ icon, from, to, progress });
39
+
40
+ let pathEl: SVGPathElement | undefined = $state();
41
+ // svelte-ignore state_referenced_locally — init-time values on purpose: they
42
+ // seed the controller's watch baselines so the first $effect run is a no-op.
43
+ const ctrl = createController({ icon, from, to, progress });
44
+
45
+ onMount(() => {
46
+ if (pathEl) ctrl.mount(pathEl, { icon, from, to, progress });
47
+ return () => ctrl.destroy();
48
+ });
49
+
50
+ // Prop changes: one watcher owns the mode logic (controlled wins — change
51
+ // detection and precedence live in shared.ts).
52
+ $effect(() => {
53
+ const next = { icon, from, to, progress, spring };
54
+ untrack(() => ctrl.watch(next));
55
+ });
56
+
57
+ export function morphTo(i: IconInput, s?: SpringPreset | MorphOptions): void {
58
+ ctrl.morphTo(i, s ?? spring);
59
+ }
60
+
61
+ export function set(i: IconInput): void {
62
+ ctrl.set(i);
63
+ }
64
+
65
+ const sw = $derived(
66
+ absoluteStrokeWidth ? (Number(strokeWidth) * 24) / Number(size) : strokeWidth,
67
+ );
68
+ </script>
69
+
70
+ <svg
71
+ xmlns="http://www.w3.org/2000/svg"
72
+ width={size}
73
+ height={size}
74
+ viewBox="0 0 24 24"
75
+ fill="none"
76
+ stroke={color}
77
+ stroke-width={sw}
78
+ stroke-linecap="round"
79
+ stroke-linejoin="round"
80
+ role={label ? "img" : undefined}
81
+ aria-hidden={label ? undefined : "true"}
82
+ {...rest}
83
+ >
84
+ {#if label}<title>{label}</title>{/if}
85
+ <path bind:this={pathEl} d={initialD} />
86
+ </svg>
package/dist/dom.d.ts CHANGED
@@ -27,8 +27,9 @@ interface Morph {
27
27
  destroy(): void;
28
28
  }
29
29
  /** Canonical `d` of an icon: the input string verbatim, or the real cubics
30
- * at full precision (the at-rest snap). Exported because it is what a
31
- * binding renders at SSR/rest before any runtime exists. */
30
+ * quantized to 4 decimals (the at-rest snap; engine-stable bytes so SSR
31
+ * hydration matches, see fmtCanon in core/serialize). Exported because it
32
+ * is what a binding renders at SSR/rest before any runtime exists. */
32
33
  declare function canonicalD(icon: IconInput): string;
33
34
  /** Creates the morph instance over a `<path>` and paints the initial icon. */
34
35
  declare function createMorph(el: PathEl, icon: IconInput): Morph;
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-BTcHiHR5.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-CBynweFS.js";
2
2
  //#region src/dom/index.ts
3
3
  const tickers = /* @__PURE__ */ new Set();
4
4
  let rafId = 0;
@@ -41,8 +41,9 @@ function sampledOf(icon) {
41
41
  return s;
42
42
  }
43
43
  /** Canonical `d` of an icon: the input string verbatim, or the real cubics
44
- * at full precision (the at-rest snap). Exported because it is what a
45
- * binding renders at SSR/rest before any runtime exists. */
44
+ * quantized to 4 decimals (the at-rest snap; engine-stable bytes so SSR
45
+ * hydration matches, see fmtCanon in core/serialize). Exported because it
46
+ * is what a binding renders at SSR/rest before any runtime exists. */
46
47
  function canonicalD(icon) {
47
48
  if (typeof icon === "string") return icon;
48
49
  let d = canon.get(icon);
package/dist/index.d.ts CHANGED
@@ -72,7 +72,8 @@ declare function resampleIcon(input: IconInput, N?: number): Sampled[];
72
72
  /** Sampled subpaths → polyline `d` attribute. `closed[k]` appends Z to
73
73
  * subpath k (closed loops in flight); without flags everything is open. */
74
74
  declare function serialize(subs: readonly Float64Array[], closed?: readonly boolean[]): string;
75
- /** Cubic subpaths → canonical `d` at full precision (round-trip). */
75
+ /** Cubic subpaths → canonical `d`, quantized to 4 decimals (engine-stable
76
+ * bytes; see fmtCanon). */
76
77
  declare function cubicsToPathD(paths: readonly CubicPath[]): string;
77
78
  //#endregion
78
79
  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 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";
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-CBynweFS.js";
2
2
  export { SPRING_PRESETS, Spring, allocOutputs, buildPlan, cubicsToPathD, fitIcon, iconToCubics, interpLinear, interpPolar, resampleIcon, resamplePath, serialize };
@@ -0,0 +1,31 @@
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 { SvgProps } from "react-native-svg";
4
+ //#region src/react-native/index.d.ts
5
+ /** Imperative surface exposed via ref. */
6
+ interface MorphHandle {
7
+ morphTo(icon: IconInput, spring?: SpringPreset | MorphOptions): void;
8
+ set(icon: IconInput): void;
9
+ }
10
+ interface MorphIconProps extends Omit<SvgProps, "from" | "to"> {
11
+ /** Uncontrolled mode: the current icon; changing the prop animates. */
12
+ icon?: IconInput;
13
+ /** Controlled mode: source endpoint of the pair. */
14
+ from?: IconInput;
15
+ /** Controlled mode: target endpoint of the pair. */
16
+ to?: IconInput;
17
+ /** Controlled mode: 0..1 progress of the frozen morph (no spring). */
18
+ progress?: number;
19
+ /** Physics for uncontrolled/imperative mode: preset or custom spring. */
20
+ spring?: SpringPreset | MorphOptions;
21
+ size?: number | string;
22
+ color?: string;
23
+ strokeWidth?: number | string;
24
+ /** Like lucide: stroke width does not scale with `size`. */
25
+ absoluteStrokeWidth?: boolean;
26
+ /** Accessibility: with label → role="img" + aria-label; without → aria-hidden. */
27
+ label?: string;
28
+ }
29
+ declare const MorphIcon: import("react").ForwardRefExoticComponent<MorphIconProps & import("react").RefAttributes<MorphHandle>>;
30
+ //#endregion
31
+ export { type IconInput, type IconNode, type Morph, MorphHandle, MorphIcon, MorphIconProps, type MorphOptions, type PathEl, type Sampled, type SpringPreset };
@@ -0,0 +1,180 @@
1
+ import { a as buildPlan, f as interpPolar, l as serialize, r as resampleIcon, u as allocOutputs } from "./spring-CBynweFS.js";
2
+ import { canonicalD, createMorph } from "./dom.js";
3
+ import { forwardRef, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useRef, useState } from "react";
4
+ import { jsx } from "react/jsx-runtime";
5
+ import { AccessibilityInfo } from "react-native";
6
+ import Svg, { Path } from "react-native-svg";
7
+ //#region src/react-native/index.tsx
8
+ const useIsoLayoutEffect = typeof document === "undefined" ? useEffect : useLayoutEffect;
9
+ let reduced = false;
10
+ let rmInit = false;
11
+ function initReducedMotion() {
12
+ if (rmInit) return;
13
+ rmInit = true;
14
+ try {
15
+ AccessibilityInfo.isReduceMotionEnabled().then((value) => {
16
+ reduced = value;
17
+ }, () => {});
18
+ AccessibilityInfo.addEventListener("reduceMotionChanged", (value) => {
19
+ reduced = value;
20
+ });
21
+ } catch {}
22
+ }
23
+ /** Frozen shape of the from→to pair at t, using the pure core. At exact
24
+ * endpoints returns the canonical `d` (real curves, not polyline). */
25
+ function frozenD(from, to, t) {
26
+ if (t <= 0) return canonicalD(from);
27
+ if (t >= 1) return canonicalD(to);
28
+ const plan = buildPlan(resampleIcon(from), resampleIcon(to));
29
+ const out = allocOutputs(plan);
30
+ interpPolar(plan, t, out);
31
+ return serialize(out, plan.items.map((it) => it.closed));
32
+ }
33
+ const MorphIcon = forwardRef(function MorphIcon(props, ref) {
34
+ const { icon, from, to, progress, spring, size = 24, color = "currentColor", strokeWidth = 2, absoluteStrokeWidth, label, ...rest } = props;
35
+ const controlled = from !== void 0 && to !== void 0;
36
+ const initialIcon = icon ?? from ?? to;
37
+ const [initialD] = useState(() => {
38
+ if (controlled) return frozenD(from, to, progress ?? 0);
39
+ return initialIcon !== void 0 ? canonicalD(initialIcon) : "";
40
+ });
41
+ const pathRef = useRef(null);
42
+ const morphRef = useRef(null);
43
+ const springRef = useRef(spring);
44
+ springRef.current = spring;
45
+ const prevIcon = useRef(icon);
46
+ const prevControlled = useRef(controlled);
47
+ const dead = useRef(false);
48
+ const based = useRef(false);
49
+ const pair = useRef(null);
50
+ /** The PathEl shim: createMorph only ever writes `d`, and Path forwards
51
+ * it to the native view without re-rendering. Stable identity. */
52
+ const el = useRef({ setAttribute: (name, value) => {
53
+ if (name === "d") pathRef.current?.setNativeProps({ d: value });
54
+ } });
55
+ /** morphTo that honors the OS reduce-motion setting (best-effort). */
56
+ const fly = (m, i, s) => {
57
+ if (reduced) m.set(i);
58
+ else m.morphTo(i, s);
59
+ };
60
+ const flyRef = useRef(fly);
61
+ flyRef.current = fly;
62
+ /** Driver birth, lazy included (#1 of the lifecycle contract): an
63
+ * iconless mount keeps the element and the FIRST icon to show up (prop
64
+ * or imperative) creates the driver, already showing it — no flight.
65
+ * Stable across renders: closes over refs only. */
66
+ const ensure = useCallback((birth) => {
67
+ if (morphRef.current) return morphRef.current;
68
+ if (dead.current || !pathRef.current) return null;
69
+ morphRef.current = createMorph(el.current, birth);
70
+ return morphRef.current;
71
+ }, []);
72
+ useIsoLayoutEffect(() => {
73
+ dead.current = false;
74
+ initReducedMotion();
75
+ if (pathRef.current && initialIcon !== void 0) {
76
+ const m = createMorph(el.current, controlled ? from : initialIcon);
77
+ morphRef.current = m;
78
+ if (controlled) {
79
+ pair.current = [from, to];
80
+ const t = progress ?? 0;
81
+ if (t <= 0) m.set(from);
82
+ else if (t >= 1) m.set(to);
83
+ else {
84
+ m.seek(to, t);
85
+ based.current = true;
86
+ }
87
+ }
88
+ }
89
+ return () => {
90
+ dead.current = true;
91
+ morphRef.current?.destroy();
92
+ morphRef.current = null;
93
+ based.current = false;
94
+ pair.current = null;
95
+ };
96
+ }, []);
97
+ useEffect(() => {
98
+ const left = prevControlled.current && !controlled;
99
+ prevControlled.current = controlled;
100
+ const changed = icon !== prevIcon.current;
101
+ prevIcon.current = icon;
102
+ if (controlled) return;
103
+ if (icon === void 0 || !changed && !left) return;
104
+ pair.current = null;
105
+ based.current = false;
106
+ const m = morphRef.current;
107
+ if (m) flyRef.current(m, icon, springRef.current);
108
+ else ensure(icon);
109
+ }, [
110
+ icon,
111
+ controlled,
112
+ ensure
113
+ ]);
114
+ useEffect(() => {
115
+ if (!controlled) return;
116
+ const m = morphRef.current ?? ensure(from);
117
+ if (!m) return;
118
+ const t = progress ?? 0;
119
+ if (!pair.current || pair.current[0] !== from || pair.current[1] !== to) {
120
+ pair.current = [from, to];
121
+ based.current = false;
122
+ }
123
+ if (t <= 0) {
124
+ m.set(from);
125
+ based.current = false;
126
+ } else if (t >= 1) {
127
+ m.set(to);
128
+ based.current = false;
129
+ } else {
130
+ if (!based.current) {
131
+ m.set(from);
132
+ based.current = true;
133
+ }
134
+ m.seek(to, t);
135
+ }
136
+ }, [
137
+ controlled,
138
+ from,
139
+ to,
140
+ progress,
141
+ ensure
142
+ ]);
143
+ useImperativeHandle(ref, () => ({
144
+ morphTo: (i, s) => {
145
+ pair.current = null;
146
+ based.current = false;
147
+ const m = morphRef.current;
148
+ if (m) flyRef.current(m, i, s ?? springRef.current);
149
+ else ensure(i);
150
+ },
151
+ set: (i) => {
152
+ pair.current = null;
153
+ based.current = false;
154
+ const m = morphRef.current;
155
+ if (m) m.set(i);
156
+ else ensure(i);
157
+ }
158
+ }), [ensure]);
159
+ const sw = absoluteStrokeWidth ? Number(strokeWidth) * 24 / Number(size) : strokeWidth;
160
+ return /* @__PURE__ */ jsx(Svg, {
161
+ width: size,
162
+ height: size,
163
+ viewBox: "0 0 24 24",
164
+ fill: "none",
165
+ stroke: color,
166
+ strokeWidth: sw,
167
+ strokeLinecap: "round",
168
+ strokeLinejoin: "round",
169
+ role: label ? "img" : void 0,
170
+ "aria-label": label,
171
+ "aria-hidden": label ? void 0 : true,
172
+ ...rest,
173
+ children: /* @__PURE__ */ jsx(Path, {
174
+ ref: pathRef,
175
+ d: initialD
176
+ })
177
+ });
178
+ });
179
+ //#endregion
180
+ export { MorphIcon };
package/dist/react.js CHANGED
@@ -1,6 +1,6 @@
1
- import { a as buildPlan, f as interpPolar, l as serialize, r as resampleIcon, u as allocOutputs } from "./spring-BTcHiHR5.js";
1
+ import { a as buildPlan, f as interpPolar, l as serialize, r as resampleIcon, u as allocOutputs } from "./spring-CBynweFS.js";
2
2
  import { canonicalD, createMorph } from "./dom.js";
3
- import { forwardRef, useEffect, useImperativeHandle, useLayoutEffect, useRef, useState } from "react";
3
+ import { forwardRef, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useRef, useState } from "react";
4
4
  import { jsx, jsxs } from "react/jsx-runtime";
5
5
  //#region src/react/index.tsx
6
6
  const useIsoLayoutEffect = typeof document === "undefined" ? useEffect : useLayoutEffect;
@@ -26,42 +26,68 @@ const MorphIcon = forwardRef(function MorphIcon(props, ref) {
26
26
  const morphRef = useRef(null);
27
27
  const springRef = useRef(spring);
28
28
  springRef.current = spring;
29
- const firstIcon = useRef(true);
29
+ const prevIcon = useRef(icon);
30
+ const prevControlled = useRef(controlled);
31
+ const dead = useRef(false);
30
32
  const based = useRef(false);
31
33
  const pair = useRef(null);
34
+ /** Driver birth, lazy included (#1 of the lifecycle contract): an
35
+ * iconless mount keeps the element and the FIRST icon to show up (prop
36
+ * or imperative) creates the driver, already showing it — no flight.
37
+ * Stable across renders: closes over refs only. */
38
+ const ensure = useCallback((birth) => {
39
+ if (morphRef.current) return morphRef.current;
40
+ const el = pathRef.current;
41
+ if (dead.current || !el) return null;
42
+ morphRef.current = createMorph(el, birth);
43
+ return morphRef.current;
44
+ }, []);
32
45
  useIsoLayoutEffect(() => {
46
+ dead.current = false;
33
47
  const el = pathRef.current;
34
- if (!el || initialIcon === void 0) return;
35
- const m = createMorph(el, controlled ? from : initialIcon);
36
- morphRef.current = m;
37
- if (controlled) {
38
- pair.current = [from, to];
39
- const t = progress ?? 0;
40
- if (t <= 0) m.set(from);
41
- else if (t >= 1) m.set(to);
42
- else {
43
- m.seek(to, t);
44
- based.current = true;
48
+ if (el && initialIcon !== void 0) {
49
+ const m = createMorph(el, controlled ? from : initialIcon);
50
+ morphRef.current = m;
51
+ if (controlled) {
52
+ pair.current = [from, to];
53
+ const t = progress ?? 0;
54
+ if (t <= 0) m.set(from);
55
+ else if (t >= 1) m.set(to);
56
+ else {
57
+ m.seek(to, t);
58
+ based.current = true;
59
+ }
45
60
  }
46
61
  }
47
62
  return () => {
48
- m.destroy();
63
+ dead.current = true;
64
+ morphRef.current?.destroy();
49
65
  morphRef.current = null;
50
66
  based.current = false;
51
67
  pair.current = null;
52
68
  };
53
69
  }, []);
54
70
  useEffect(() => {
55
- if (icon === void 0) return;
56
- if (firstIcon.current) {
57
- firstIcon.current = false;
58
- return;
59
- }
60
- morphRef.current?.morphTo(icon, springRef.current);
61
- }, [icon]);
62
- useEffect(() => {
71
+ const left = prevControlled.current && !controlled;
72
+ prevControlled.current = controlled;
73
+ const changed = icon !== prevIcon.current;
74
+ prevIcon.current = icon;
75
+ if (controlled) return;
76
+ if (icon === void 0 || !changed && !left) return;
77
+ pair.current = null;
78
+ based.current = false;
63
79
  const m = morphRef.current;
64
- if (!m || !controlled) return;
80
+ if (m) m.morphTo(icon, springRef.current);
81
+ else ensure(icon);
82
+ }, [
83
+ icon,
84
+ controlled,
85
+ ensure
86
+ ]);
87
+ useEffect(() => {
88
+ if (!controlled) return;
89
+ const m = morphRef.current ?? ensure(from);
90
+ if (!m) return;
65
91
  const t = progress ?? 0;
66
92
  if (!pair.current || pair.current[0] !== from || pair.current[1] !== to) {
67
93
  pair.current = [from, to];
@@ -84,12 +110,25 @@ const MorphIcon = forwardRef(function MorphIcon(props, ref) {
84
110
  controlled,
85
111
  from,
86
112
  to,
87
- progress
113
+ progress,
114
+ ensure
88
115
  ]);
89
116
  useImperativeHandle(ref, () => ({
90
- morphTo: (i, s) => morphRef.current?.morphTo(i, s ?? springRef.current),
91
- set: (i) => morphRef.current?.set(i)
92
- }), []);
117
+ morphTo: (i, s) => {
118
+ pair.current = null;
119
+ based.current = false;
120
+ const m = morphRef.current;
121
+ if (m) m.morphTo(i, s ?? springRef.current);
122
+ else ensure(i);
123
+ },
124
+ set: (i) => {
125
+ pair.current = null;
126
+ based.current = false;
127
+ const m = morphRef.current;
128
+ if (m) m.set(i);
129
+ else ensure(i);
130
+ }
131
+ }), [ensure]);
93
132
  const sw = absoluteStrokeWidth ? Number(strokeWidth) * 24 / Number(size) : strokeWidth;
94
133
  return /* @__PURE__ */ jsxs("svg", {
95
134
  xmlns: "http://www.w3.org/2000/svg",
@@ -305,12 +305,16 @@ function serialize(subs, closed) {
305
305
  }
306
306
  return d;
307
307
  }
308
- /** Cubic subpaths → canonical `d` at full precision (round-trip). */
308
+ function fmtCanon(v) {
309
+ return String(Math.round(v * 1e4) / 1e4);
310
+ }
311
+ /** Cubic subpaths → canonical `d`, quantized to 4 decimals (engine-stable
312
+ * bytes; see fmtCanon). */
309
313
  function cubicsToPathD(paths) {
310
314
  let d = "";
311
315
  for (const { pts, closed } of paths) {
312
- d += `M${pts[0]} ${pts[1]}`;
313
- 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]}`;
316
+ d += `M${fmtCanon(pts[0])} ${fmtCanon(pts[1])}`;
317
+ for (let i = 2; i < pts.length; i += 6) d += `C${fmtCanon(pts[i])} ${fmtCanon(pts[i + 1])} ${fmtCanon(pts[i + 2])} ${fmtCanon(pts[i + 3])} ${fmtCanon(pts[i + 4])} ${fmtCanon(pts[i + 5])}`;
314
318
  if (closed) d += "Z";
315
319
  }
316
320
  return d;
@@ -0,0 +1,68 @@
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 { SVGAttributes } from "svelte/elements";
4
+ //#region src/svelte/shared.d.ts
5
+ /** Imperative surface exposed on the component instance (`bind:this`). */
6
+ interface MorphHandle {
7
+ morphTo(icon: IconInput, spring?: SpringPreset | MorphOptions): void;
8
+ set(icon: IconInput): void;
9
+ }
10
+ /** Rest props fall through to the `<svg>` fully typed (class, style, data-*,
11
+ * events, ARIA…) via svelte/elements, like lucide-svelte — typos in an SVG
12
+ * attr fail the build. `from`/`to` are omitted because the SMIL animation
13
+ * attributes of the same name collide with the controlled-mode pair. */
14
+ interface MorphIconProps extends Omit<SVGAttributes<SVGSVGElement>, "from" | "to"> {
15
+ /** Uncontrolled mode: the current icon; changing the prop animates. */
16
+ icon?: IconInput;
17
+ /** Controlled mode: source endpoint of the pair. */
18
+ from?: IconInput;
19
+ /** Controlled mode: target endpoint of the pair. */
20
+ to?: IconInput;
21
+ /** Controlled mode: 0..1 progress of the frozen morph (no spring). */
22
+ progress?: number;
23
+ /** Physics for uncontrolled/imperative mode: preset or custom spring. */
24
+ spring?: SpringPreset | MorphOptions;
25
+ size?: number | string;
26
+ color?: string;
27
+ strokeWidth?: number | string;
28
+ /** Like lucide-svelte: stroke width does not scale with `size`. */
29
+ absoluteStrokeWidth?: boolean;
30
+ /** Accessibility: with label → role="img" + <title>; without → aria-hidden. */
31
+ label?: string;
32
+ }
33
+ type ModeProps = Pick<MorphIconProps, "icon" | "from" | "to" | "progress">;
34
+ type WatchProps = ModeProps & {
35
+ spring?: SpringPreset | MorphOptions;
36
+ };
37
+ /** The initial d is constant for Svelte: computed once from the mount-time
38
+ * props (server and client produce the same string → hydration without
39
+ * mismatch) and from then on only the driver mutates it outside the template. */
40
+ declare function computeInitialD({ icon, from, to, progress }: ModeProps): string;
41
+ /** Per-instance driver state — the exact logic of the React/Vue bindings
42
+ * (mount, mode watch, controlled seek with re-basing, imperative). Takes the
43
+ * init-time props: they seed the watch baselines so the first `$effect` run
44
+ * (which fires on mount in Svelte, unlike Vue's `watch`) is a no-op. All
45
+ * change detection lives HERE, under tsc — the .svelte shell only wires
46
+ * runes to these methods.
47
+ *
48
+ * Lifecycle contract (shared verbatim by the three bindings):
49
+ * - Lazy driver: an iconless mount keeps the element and births the driver
50
+ * on the FIRST icon that shows up (prop or imperative). `morphTo` with no
51
+ * driver behaves as `set` — there is nothing to fly from.
52
+ * - Controlled wins: while `from` and `to` are both present the pair owns
53
+ * the path and `icon` changes are ignored; dropping the pair hands the
54
+ * path back to `icon`.
55
+ * - Every exit from controlled mode (imperative call or icon takeover)
56
+ * invalidates the frozen pair, so returning to it re-bases on `from`. */
57
+ declare function createController({ icon, from, to, progress }: ModeProps): {
58
+ mount(mountEl: PathEl, { icon, from, to, progress }: ModeProps): void;
59
+ destroy(): void;
60
+ /** Prop watcher: ONE owner decides per run — the controlled pair while it
61
+ * is fully present, `icon` otherwise (mount doesn't fire thanks to the
62
+ * init-time baselines). */
63
+ watch({ icon, from, to, progress, spring }: WatchProps): void;
64
+ morphTo(icon: IconInput, spring?: SpringPreset | MorphOptions): void;
65
+ set(icon: IconInput): void;
66
+ };
67
+ //#endregion
68
+ export { type IconInput, type IconNode, type Morph, MorphHandle, MorphIconProps, type MorphOptions, type PathEl, type Sampled, type SpringPreset, computeInitialD, createController };
@@ -0,0 +1,146 @@
1
+ import { a as buildPlan, f as interpPolar, l as serialize, r as resampleIcon, u as allocOutputs } from "./spring-CBynweFS.js";
2
+ import { canonicalD, createMorph } from "./dom.js";
3
+ //#region src/svelte/shared.ts
4
+ /** Frozen shape of the from→to pair at t, using the pure core (SSR-safe).
5
+ * At exact endpoints returns the canonical `d` (real curves, not polyline). */
6
+ function frozenD(from, to, t) {
7
+ if (t <= 0) return canonicalD(from);
8
+ if (t >= 1) return canonicalD(to);
9
+ const plan = buildPlan(resampleIcon(from), resampleIcon(to));
10
+ const out = allocOutputs(plan);
11
+ interpPolar(plan, t, out);
12
+ return serialize(out, plan.items.map((it) => it.closed));
13
+ }
14
+ /** The initial d is constant for Svelte: computed once from the mount-time
15
+ * props (server and client produce the same string → hydration without
16
+ * mismatch) and from then on only the driver mutates it outside the template. */
17
+ function computeInitialD({ icon, from, to, progress }) {
18
+ if (from !== void 0 && to !== void 0) return frozenD(from, to, progress ?? 0);
19
+ const first = icon ?? from ?? to;
20
+ return first !== void 0 ? canonicalD(first) : "";
21
+ }
22
+ /** Per-instance driver state — the exact logic of the React/Vue bindings
23
+ * (mount, mode watch, controlled seek with re-basing, imperative). Takes the
24
+ * init-time props: they seed the watch baselines so the first `$effect` run
25
+ * (which fires on mount in Svelte, unlike Vue's `watch`) is a no-op. All
26
+ * change detection lives HERE, under tsc — the .svelte shell only wires
27
+ * runes to these methods.
28
+ *
29
+ * Lifecycle contract (shared verbatim by the three bindings):
30
+ * - Lazy driver: an iconless mount keeps the element and births the driver
31
+ * on the FIRST icon that shows up (prop or imperative). `morphTo` with no
32
+ * driver behaves as `set` — there is nothing to fly from.
33
+ * - Controlled wins: while `from` and `to` are both present the pair owns
34
+ * the path and `icon` changes are ignored; dropping the pair hands the
35
+ * path back to `icon`.
36
+ * - Every exit from controlled mode (imperative call or icon takeover)
37
+ * invalidates the frozen pair, so returning to it re-bases on `from`. */
38
+ function createController({ icon, from, to, progress }) {
39
+ let el = null;
40
+ let dead = false;
41
+ let morph = null;
42
+ let based = false;
43
+ let pair = null;
44
+ let prevIcon = icon;
45
+ let prevControlled = from !== void 0 && to !== void 0;
46
+ let prevFrom = from;
47
+ let prevTo = to;
48
+ let prevProgress = progress;
49
+ /** Driver birth, lazy included: the first icon to show up creates it. */
50
+ const ensure = (birth) => {
51
+ if (morph) return morph;
52
+ if (dead || !el) return null;
53
+ morph = createMorph(el, birth);
54
+ return morph;
55
+ };
56
+ /** Controlled mode: freeze the pair at `progress` via seek (no spring). */
57
+ const applyPair = (from, to, progress) => {
58
+ const m = morph;
59
+ if (!m) return;
60
+ const t = progress ?? 0;
61
+ if (!pair || pair[0] !== from || pair[1] !== to) {
62
+ pair = [from, to];
63
+ based = false;
64
+ }
65
+ if (t <= 0) {
66
+ m.set(from);
67
+ based = false;
68
+ } else if (t >= 1) {
69
+ m.set(to);
70
+ based = false;
71
+ } else {
72
+ if (!based) {
73
+ m.set(from);
74
+ based = true;
75
+ }
76
+ m.seek(to, t);
77
+ }
78
+ };
79
+ return {
80
+ mount(mountEl, { icon, from, to, progress }) {
81
+ el = mountEl;
82
+ const controlled = from !== void 0 && to !== void 0;
83
+ const initialIcon = icon ?? from ?? to;
84
+ if (initialIcon === void 0) return;
85
+ const m = createMorph(el, controlled ? from : initialIcon);
86
+ morph = m;
87
+ if (controlled) {
88
+ pair = [from, to];
89
+ const t = progress ?? 0;
90
+ if (t <= 0) m.set(from);
91
+ else if (t >= 1) m.set(to);
92
+ else {
93
+ m.seek(to, t);
94
+ based = true;
95
+ }
96
+ }
97
+ },
98
+ destroy() {
99
+ dead = true;
100
+ el = null;
101
+ morph?.destroy();
102
+ morph = null;
103
+ based = false;
104
+ pair = null;
105
+ },
106
+ /** Prop watcher: ONE owner decides per run — the controlled pair while it
107
+ * is fully present, `icon` otherwise (mount doesn't fire thanks to the
108
+ * init-time baselines). */
109
+ watch({ icon, from, to, progress, spring }) {
110
+ const controlled = from !== void 0 && to !== void 0;
111
+ const left = prevControlled && !controlled;
112
+ const iconChanged = icon !== prevIcon;
113
+ const pairChanged = from !== prevFrom || to !== prevTo || progress !== prevProgress;
114
+ prevControlled = controlled;
115
+ prevIcon = icon;
116
+ prevFrom = from;
117
+ prevTo = to;
118
+ prevProgress = progress;
119
+ if (controlled) {
120
+ if (!pairChanged) return;
121
+ if (!(morph ?? ensure(from))) return;
122
+ applyPair(from, to, progress);
123
+ return;
124
+ }
125
+ if (icon === void 0 || !iconChanged && !left) return;
126
+ pair = null;
127
+ based = false;
128
+ if (morph) morph.morphTo(icon, spring);
129
+ else ensure(icon);
130
+ },
131
+ morphTo(icon, spring) {
132
+ pair = null;
133
+ based = false;
134
+ if (morph) morph.morphTo(icon, spring);
135
+ else ensure(icon);
136
+ },
137
+ set(icon) {
138
+ pair = null;
139
+ based = false;
140
+ if (morph) morph.set(icon);
141
+ else ensure(icon);
142
+ }
143
+ };
144
+ }
145
+ //#endregion
146
+ export { computeInitialD, createController };
@@ -0,0 +1,13 @@
1
+ import type { Component } from "svelte";
2
+ import type { MorphHandle, MorphIconProps } from "./svelte-shared.js";
3
+ export declare const MorphIcon: Component<MorphIconProps, MorphHandle>;
4
+ export type { MorphHandle, MorphIconProps };
5
+ export type {
6
+ IconInput,
7
+ IconNode,
8
+ Morph,
9
+ MorphOptions,
10
+ PathEl,
11
+ Sampled,
12
+ SpringPreset,
13
+ } from "./svelte-shared.js";
package/dist/svelte.js ADDED
@@ -0,0 +1 @@
1
+ export { default as MorphIcon } from "./MorphIcon.svelte";
package/dist/vue.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-BTcHiHR5.js";
1
+ import { a as buildPlan, f as interpPolar, l as serialize, r as resampleIcon, u as allocOutputs } from "./spring-CBynweFS.js";
2
2
  import { canonicalD, createMorph } from "./dom.js";
3
3
  import { defineComponent, h, onBeforeUnmount, onMounted, shallowRef, watch } from "vue";
4
4
  //#region src/vue/index.ts
@@ -72,8 +72,21 @@ const MorphIcon = defineComponent({
72
72
  })();
73
73
  const pathEl = shallowRef(null);
74
74
  let morph = null;
75
+ let dead = false;
75
76
  let based = false;
76
77
  let pair = null;
78
+ let prevIcon = props.icon;
79
+ let prevControlled = props.from !== void 0 && props.to !== void 0;
80
+ /** Driver birth, lazy included (#1 of the lifecycle contract): an
81
+ * iconless mount keeps the element and the FIRST icon to show up (prop
82
+ * or imperative) creates the driver, already showing it — no flight. */
83
+ const ensure = (birth) => {
84
+ if (morph) return morph;
85
+ const el = pathEl.value;
86
+ if (dead || !el) return null;
87
+ morph = createMorph(el, birth);
88
+ return morph;
89
+ };
77
90
  onMounted(() => {
78
91
  const el = pathEl.value;
79
92
  const { icon, from, to, progress } = props;
@@ -94,22 +107,39 @@ const MorphIcon = defineComponent({
94
107
  }
95
108
  });
96
109
  onBeforeUnmount(() => {
110
+ dead = true;
97
111
  morph?.destroy();
98
112
  morph = null;
99
113
  based = false;
100
114
  pair = null;
101
115
  });
102
- watch(() => props.icon, (icon) => {
103
- if (icon !== void 0) morph?.morphTo(icon, props.spring);
116
+ watch(() => [
117
+ props.icon,
118
+ props.from,
119
+ props.to
120
+ ], () => {
121
+ const { icon, from, to } = props;
122
+ const controlled = from !== void 0 && to !== void 0;
123
+ const left = prevControlled && !controlled;
124
+ prevControlled = controlled;
125
+ const changed = icon !== prevIcon;
126
+ prevIcon = icon;
127
+ if (controlled) return;
128
+ if (icon === void 0 || !changed && !left) return;
129
+ pair = null;
130
+ based = false;
131
+ if (morph) morph.morphTo(icon, props.spring);
132
+ else ensure(icon);
104
133
  });
105
134
  watch(() => [
106
135
  props.from,
107
136
  props.to,
108
137
  props.progress
109
138
  ], () => {
110
- const m = morph;
111
139
  const { from, to, progress } = props;
112
- if (!m || from === void 0 || to === void 0) return;
140
+ if (from === void 0 || to === void 0) return;
141
+ const m = morph ?? ensure(from);
142
+ if (!m) return;
113
143
  const t = progress ?? 0;
114
144
  if (!pair || pair[0] !== from || pair[1] !== to) {
115
145
  pair = [from, to];
@@ -130,8 +160,18 @@ const MorphIcon = defineComponent({
130
160
  }
131
161
  });
132
162
  expose({
133
- morphTo: (i, s) => morph?.morphTo(i, s ?? props.spring),
134
- set: (i) => morph?.set(i)
163
+ morphTo: (i, s) => {
164
+ pair = null;
165
+ based = false;
166
+ if (morph) morph.morphTo(i, s ?? props.spring);
167
+ else ensure(i);
168
+ },
169
+ set: (i) => {
170
+ pair = null;
171
+ based = false;
172
+ if (morph) morph.set(i);
173
+ else ensure(i);
174
+ }
135
175
  });
136
176
  return () => {
137
177
  const sw = props.absoluteStrokeWidth ? Number(props.strokeWidth) * 24 / Number(props.size) : props.strokeWidth;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "morphicons",
3
- "version": "1.2.0",
3
+ "version": "1.4.1",
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,8 +29,12 @@
29
29
  "shadcn",
30
30
  "feather",
31
31
  "react",
32
+ "react-native",
33
+ "lucide-react-native",
32
34
  "vue",
33
35
  "lucide-vue-next",
36
+ "svelte",
37
+ "lucide-svelte",
34
38
  "icon-animation"
35
39
  ],
36
40
  "type": "module",
@@ -51,19 +55,40 @@
51
55
  "types": "./dist/react.d.ts",
52
56
  "default": "./dist/react.js"
53
57
  },
58
+ "./react-native": {
59
+ "types": "./dist/react-native.d.ts",
60
+ "default": "./dist/react-native.js"
61
+ },
54
62
  "./vue": {
55
63
  "types": "./dist/vue.d.ts",
56
64
  "default": "./dist/vue.js"
65
+ },
66
+ "./svelte": {
67
+ "types": "./dist/svelte.d.ts",
68
+ "svelte": "./dist/svelte.js",
69
+ "default": "./dist/svelte.js"
57
70
  }
58
71
  },
59
72
  "peerDependencies": {
60
73
  "react": ">=18",
74
+ "react-native": ">=0.71",
75
+ "react-native-svg": ">=14",
76
+ "svelte": ">=5",
61
77
  "vue": ">=3.3"
62
78
  },
63
79
  "peerDependenciesMeta": {
64
80
  "react": {
65
81
  "optional": true
66
82
  },
83
+ "react-native": {
84
+ "optional": true
85
+ },
86
+ "react-native-svg": {
87
+ "optional": true
88
+ },
89
+ "svelte": {
90
+ "optional": true
91
+ },
67
92
  "vue": {
68
93
  "optional": true
69
94
  }
@@ -73,11 +98,11 @@
73
98
  ],
74
99
  "scripts": {
75
100
  "test": "bun test",
76
- "typecheck": "tsc --noEmit && tsc -p playground --noEmit && tsc -p tsconfig.react.json --noEmit && tsc -p tsconfig.vue.json --noEmit",
101
+ "typecheck": "tsc --noEmit && tsc -p playground --noEmit && tsc -p tsconfig.react.json --noEmit && tsc -p tsconfig.react-native.json --noEmit && tsc -p tsconfig.vue.json --noEmit && tsc -p tsconfig.svelte.json --noEmit",
77
102
  "play": "bun ./playground/index.html",
78
103
  "lint": "biome check .",
79
104
  "format": "biome check --write .",
80
- "build": "tsdown",
105
+ "build": "tsdown && bun scripts/build-svelte.ts",
81
106
  "size": "size-limit",
82
107
  "prepublishOnly": "bun run build"
83
108
  },
@@ -106,6 +131,18 @@
106
131
  "limit": "8.5 KB",
107
132
  "gzip": true
108
133
  },
134
+ {
135
+ "name": "core + dom + react-native (MorphIcon, react/rn/rnsvg external)",
136
+ "path": "dist/react-native.js",
137
+ "import": "*",
138
+ "ignore": [
139
+ "react",
140
+ "react-native",
141
+ "react-native-svg"
142
+ ],
143
+ "limit": "9 KB",
144
+ "gzip": true
145
+ },
109
146
  {
110
147
  "name": "core + dom + vue (MorphIcon, vue external)",
111
148
  "path": "dist/vue.js",
@@ -115,20 +152,44 @@
115
152
  ],
116
153
  "limit": "8.5 KB",
117
154
  "gzip": true
155
+ },
156
+ {
157
+ "name": "core + dom + svelte TS half (svelte external)",
158
+ "path": "dist/svelte-shared.js",
159
+ "import": "*",
160
+ "ignore": [
161
+ "svelte"
162
+ ],
163
+ "limit": "8 KB",
164
+ "gzip": true
165
+ },
166
+ {
167
+ "name": "svelte component (ships as source, consumer-compiled)",
168
+ "path": "dist/MorphIcon.svelte",
169
+ "disablePlugins": [
170
+ "@size-limit/esbuild"
171
+ ],
172
+ "limit": "1.4 KB",
173
+ "gzip": true
118
174
  }
119
175
  ],
120
176
  "devDependencies": {
121
177
  "@biomejs/biome": "^2.5.6",
178
+ "@happy-dom/global-registrator": "^20.11.1",
122
179
  "@size-limit/preset-small-lib": "^13.0.3",
123
180
  "@tabler/icons": "^3.46.0",
124
181
  "@types/bun": "^1.3.14",
125
182
  "@types/react": "^19.2.18",
126
183
  "@types/react-dom": "^19.2.4",
127
184
  "feather-icons": "^4.29.2",
185
+ "happy-dom": "^20.11.1",
128
186
  "lucide": "^1.28.0",
129
187
  "react": "^19.2.8",
130
188
  "react-dom": "^19.2.8",
189
+ "react-native": "^0.86.2",
190
+ "react-native-svg": "^15.15.5",
131
191
  "size-limit": "^13.0.3",
192
+ "svelte": "^5.56.8",
132
193
  "tsdown": "^0.22.14",
133
194
  "typescript": "^7.0.2",
134
195
  "vue": "^3.5.40"