morphicons 1.1.1 → 1.4.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), `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
40
 
41
41
  ## Usage
42
42
 
@@ -60,6 +60,88 @@ 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
+
91
+ ### Svelte — same three modes
92
+
93
+ ```svelte
94
+ <script lang="ts">
95
+ import { MorphIcon, type MorphHandle } from "morphicons/svelte";
96
+ import { Menu, X, Check } from "lucide"; // data, not components
97
+
98
+ let open = $state(false);
99
+ let morph = $state<MorphHandle>();
100
+ // morph?.morphTo(Check) → animates
101
+ // morph?.set(X) → jumps without animating
102
+ </script>
103
+
104
+ <!-- 1. Uncontrolled (90% of uses): change the prop and morphicons animates -->
105
+ <MorphIcon icon={open ? X : Menu} spring="snappy" />
106
+
107
+ <!-- 2. Controlled (gestures, scroll): explicit progress, no spring -->
108
+ <MorphIcon from={Menu} to={X} progress={dragProgress} />
109
+
110
+ <!-- 3. Imperative (sequences): bind:this → morphTo / set -->
111
+ <MorphIcon bind:this={morph} icon={Menu} />
112
+ ```
113
+
114
+ 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.
115
+
116
+ ### React Native — same three modes
117
+
118
+ ```tsx
119
+ import { MorphIcon, type MorphHandle } from "morphicons/react-native";
120
+ import { Menu, X, Check } from "lucide"; // data, not components
121
+
122
+ // 1. Uncontrolled (90% of uses): change the prop and morphicons animates
123
+ <MorphIcon icon={open ? X : Menu} spring="snappy" />
124
+
125
+ // 2. Controlled (gestures, scroll): explicit progress, no spring
126
+ <MorphIcon from={Menu} to={X} progress={dragProgress} />
127
+
128
+ // 3. Imperative (sequences)
129
+ const ref = useRef<MorphHandle>(null);
130
+ <MorphIcon ref={ref} icon={Menu} />
131
+ ref.current?.morphTo(Check); // animates
132
+ ref.current?.set(X); // jumps without animating
133
+ ```
134
+
135
+ 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`.
136
+
137
+ ### Lifecycle contract (all four bindings)
138
+
139
+ The four components share one contract, pinned by mirrored client-mount tests:
140
+
141
+ - **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).
142
+ - **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.
143
+ - **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`.
144
+
63
145
  ### Vanilla (no React)
64
146
 
65
147
  ```ts
@@ -103,7 +185,7 @@ morphicons has no per-library adapters — any icon set that meets these require
103
185
  const bell = fitIcon(teenySmallBell, 15); // also "0 0 15 15" or [0, 0, 15, 15]
104
186
  ```
105
187
 
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.
188
+ 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.
107
189
  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
190
 
109
191
  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 +196,7 @@ This also covers the [shadcn registry](https://www.shadcn.io/icons), which re-pu
114
196
 
115
197
  ```
116
198
  ┌─────────────────────────────────────────────┐
117
- │ bindings react │ thin: ref + createMorph
199
+ │ bindings react · vue · svelte · rn │ thin: ref + createMorph
118
200
  ├─────────────────────────────────────────────┤
119
201
  │ drivers dom (setAttribute + rAF) │ singleton scheduler
120
202
  ├─────────────────────────────────────────────┤
@@ -125,7 +207,7 @@ This also covers the [shadcn registry](https://www.shadcn.io/icons), which re-pu
125
207
  └─────────────────────────────────────────────┘
126
208
  ```
127
209
 
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`.
210
+ 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`.
129
211
 
130
212
  The plan is the central artifact:
131
213
 
@@ -265,7 +347,11 @@ CI budget (size-limit, gzip) as an anti-regression tripwire — gates carry ~10%
265
347
  |---|---|---|
266
348
  | `morphicons` (core) | 6.60 KB | 7 KB |
267
349
  | `morphicons/dom` (core + driver) | 7.04 KB | 7.5 KB |
268
- | `morphicons/react` (all, react external) | 7.77 KB | 8.5 KB |
350
+ | `morphicons/react` (all, react external) | 7.89 KB | 8.5 KB |
351
+ | `morphicons/react-native` (all, react/rn/rnsvg external) | 8.25 KB | 9 KB |
352
+ | `morphicons/vue` (all, vue external) | 7.93 KB | 8.5 KB |
353
+ | `morphicons/svelte` TS half (all, svelte external) | 7.54 KB | 8 KB |
354
+ | `MorphIcon.svelte` (ships as source, consumer-compiled) | 1.25 KB | 1.4 KB |
269
355
 
270
356
  ## Playground
271
357
 
@@ -278,10 +364,10 @@ bun run play # → http://localhost:3000
278
364
  ## Development
279
365
 
280
366
  ```bash
281
- bun test # 83 tests / ~13,400 asserts
282
- bun run typecheck # strict ×3: core+dom without lib DOM, playground, react
367
+ bun test # 156 tests / ~13,700 asserts
368
+ bun run typecheck # strict ×6: core+dom without lib DOM, playground, react, react-native, vue, svelte
283
369
  bun run format # biome
284
- bun run build # tsdown → dist/ (entries index, dom, react)
370
+ bun run build # tsdown → dist/ + scripts/build-svelte.ts (svelte entry)
285
371
  bun run size # size gates
286
372
  ```
287
373
 
@@ -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>
@@ -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-BTcHiHR5.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
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
- 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",
@@ -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-BTcHiHR5.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.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,198 @@
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 dead = false;
76
+ let based = false;
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
+ };
90
+ onMounted(() => {
91
+ const el = pathEl.value;
92
+ const { icon, from, to, progress } = props;
93
+ const controlled = from !== void 0 && to !== void 0;
94
+ const initialIcon = icon ?? from ?? to;
95
+ if (!el || initialIcon === void 0) return;
96
+ const m = createMorph(el, controlled ? from : initialIcon);
97
+ morph = m;
98
+ if (controlled) {
99
+ pair = [from, to];
100
+ const t = progress ?? 0;
101
+ if (t <= 0) m.set(from);
102
+ else if (t >= 1) m.set(to);
103
+ else {
104
+ m.seek(to, t);
105
+ based = true;
106
+ }
107
+ }
108
+ });
109
+ onBeforeUnmount(() => {
110
+ dead = true;
111
+ morph?.destroy();
112
+ morph = null;
113
+ based = false;
114
+ pair = null;
115
+ });
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);
133
+ });
134
+ watch(() => [
135
+ props.from,
136
+ props.to,
137
+ props.progress
138
+ ], () => {
139
+ const { from, to, progress } = props;
140
+ if (from === void 0 || to === void 0) return;
141
+ const m = morph ?? ensure(from);
142
+ if (!m) return;
143
+ const t = progress ?? 0;
144
+ if (!pair || pair[0] !== from || pair[1] !== to) {
145
+ pair = [from, to];
146
+ based = false;
147
+ }
148
+ if (t <= 0) {
149
+ m.set(from);
150
+ based = false;
151
+ } else if (t >= 1) {
152
+ m.set(to);
153
+ based = false;
154
+ } else {
155
+ if (!based) {
156
+ m.set(from);
157
+ based = true;
158
+ }
159
+ m.seek(to, t);
160
+ }
161
+ });
162
+ expose({
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
+ }
175
+ });
176
+ return () => {
177
+ const sw = props.absoluteStrokeWidth ? Number(props.strokeWidth) * 24 / Number(props.size) : props.strokeWidth;
178
+ return h("svg", {
179
+ xmlns: "http://www.w3.org/2000/svg",
180
+ width: props.size,
181
+ height: props.size,
182
+ viewBox: "0 0 24 24",
183
+ fill: "none",
184
+ stroke: props.color,
185
+ "stroke-width": sw,
186
+ "stroke-linecap": "round",
187
+ "stroke-linejoin": "round",
188
+ role: props.label ? "img" : void 0,
189
+ "aria-hidden": props.label ? void 0 : "true"
190
+ }, [props.label ? h("title", props.label) : null, h("path", {
191
+ ref: pathEl,
192
+ d: initialD
193
+ })]);
194
+ };
195
+ }
196
+ });
197
+ //#endregion
198
+ export { MorphIcon };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "morphicons",
3
- "version": "1.1.1",
3
+ "version": "1.4.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,12 @@
29
29
  "shadcn",
30
30
  "feather",
31
31
  "react",
32
+ "react-native",
33
+ "lucide-react-native",
34
+ "vue",
35
+ "lucide-vue-next",
36
+ "svelte",
37
+ "lucide-svelte",
32
38
  "icon-animation"
33
39
  ],
34
40
  "type": "module",
@@ -48,14 +54,43 @@
48
54
  "./react": {
49
55
  "types": "./dist/react.d.ts",
50
56
  "default": "./dist/react.js"
57
+ },
58
+ "./react-native": {
59
+ "types": "./dist/react-native.d.ts",
60
+ "default": "./dist/react-native.js"
61
+ },
62
+ "./vue": {
63
+ "types": "./dist/vue.d.ts",
64
+ "default": "./dist/vue.js"
65
+ },
66
+ "./svelte": {
67
+ "types": "./dist/svelte.d.ts",
68
+ "svelte": "./dist/svelte.js",
69
+ "default": "./dist/svelte.js"
51
70
  }
52
71
  },
53
72
  "peerDependencies": {
54
- "react": ">=18"
73
+ "react": ">=18",
74
+ "react-native": ">=0.71",
75
+ "react-native-svg": ">=14",
76
+ "svelte": ">=5",
77
+ "vue": ">=3.3"
55
78
  },
56
79
  "peerDependenciesMeta": {
57
80
  "react": {
58
81
  "optional": true
82
+ },
83
+ "react-native": {
84
+ "optional": true
85
+ },
86
+ "react-native-svg": {
87
+ "optional": true
88
+ },
89
+ "svelte": {
90
+ "optional": true
91
+ },
92
+ "vue": {
93
+ "optional": true
59
94
  }
60
95
  },
61
96
  "files": [
@@ -63,11 +98,11 @@
63
98
  ],
64
99
  "scripts": {
65
100
  "test": "bun test",
66
- "typecheck": "tsc --noEmit && tsc -p playground --noEmit && tsc -p tsconfig.react.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",
67
102
  "play": "bun ./playground/index.html",
68
103
  "lint": "biome check .",
69
104
  "format": "biome check --write .",
70
- "build": "tsdown",
105
+ "build": "tsdown && bun scripts/build-svelte.ts",
71
106
  "size": "size-limit",
72
107
  "prepublishOnly": "bun run build"
73
108
  },
@@ -95,21 +130,68 @@
95
130
  ],
96
131
  "limit": "8.5 KB",
97
132
  "gzip": true
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
+ },
146
+ {
147
+ "name": "core + dom + vue (MorphIcon, vue external)",
148
+ "path": "dist/vue.js",
149
+ "import": "*",
150
+ "ignore": [
151
+ "vue"
152
+ ],
153
+ "limit": "8.5 KB",
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
98
174
  }
99
175
  ],
100
176
  "devDependencies": {
101
177
  "@biomejs/biome": "^2.5.6",
178
+ "@happy-dom/global-registrator": "^20.11.1",
102
179
  "@size-limit/preset-small-lib": "^13.0.3",
103
180
  "@tabler/icons": "^3.46.0",
104
181
  "@types/bun": "^1.3.14",
105
182
  "@types/react": "^19.2.18",
106
183
  "@types/react-dom": "^19.2.4",
107
184
  "feather-icons": "^4.29.2",
185
+ "happy-dom": "^20.11.1",
108
186
  "lucide": "^1.28.0",
109
187
  "react": "^19.2.8",
110
188
  "react-dom": "^19.2.8",
189
+ "react-native": "^0.86.2",
190
+ "react-native-svg": "^15.15.5",
111
191
  "size-limit": "^13.0.3",
192
+ "svelte": "^5.56.8",
112
193
  "tsdown": "^0.22.14",
113
- "typescript": "^7.0.2"
194
+ "typescript": "^7.0.2",
195
+ "vue": "^3.5.40"
114
196
  }
115
197
  }