morphicons 1.2.0 → 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` (>= 18) and `vue` (>= 3.3) are optional peers — only needed for `morphicons/react` and `morphicons/vue`.
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
 
@@ -88,6 +88,60 @@ const morph = ref<MorphHandle | null>(null);
88
88
 
89
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
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
+
91
145
  ### Vanilla (no React)
92
146
 
93
147
  ```ts
@@ -131,7 +185,7 @@ morphicons has no per-library adapters — any icon set that meets these require
131
185
  const bell = fitIcon(teenySmallBell, 15); // also "0 0 15 15" or [0, 0, 15, 15]
132
186
  ```
133
187
 
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.
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.
135
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.
136
190
 
137
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).
@@ -142,7 +196,7 @@ This also covers the [shadcn registry](https://www.shadcn.io/icons), which re-pu
142
196
 
143
197
  ```
144
198
  ┌─────────────────────────────────────────────┐
145
- │ bindings react · vue │ thin: ref + createMorph
199
+ │ bindings react · vue · svelte · rn │ thin: ref + createMorph
146
200
  ├─────────────────────────────────────────────┤
147
201
  │ drivers dom (setAttribute + rAF) │ singleton scheduler
148
202
  ├─────────────────────────────────────────────┤
@@ -153,7 +207,7 @@ This also covers the [shadcn registry](https://www.shadcn.io/icons), which re-pu
153
207
  └─────────────────────────────────────────────┘
154
208
  ```
155
209
 
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`.
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`.
157
211
 
158
212
  The plan is the central artifact:
159
213
 
@@ -293,8 +347,11 @@ CI budget (size-limit, gzip) as an anti-regression tripwire — gates carry ~10%
293
347
  |---|---|---|
294
348
  | `morphicons` (core) | 6.60 KB | 7 KB |
295
349
  | `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 |
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 |
298
355
 
299
356
  ## Playground
300
357
 
@@ -307,27 +364,15 @@ bun run play # → http://localhost:3000
307
364
  ## Development
308
365
 
309
366
  ```bash
310
- bun test # 92 tests / ~13,500 asserts
311
- bun run typecheck # strict ×4: core+dom without lib DOM, playground, react, vue
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
312
369
  bun run format # biome
313
- bun run build # tsdown → dist/ (entries index, dom, react, vue)
370
+ bun run build # tsdown → dist/ + scripts/build-svelte.ts (svelte entry)
314
371
  bun run size # size gates
315
372
  ```
316
373
 
317
374
  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
375
 
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
376
  ---
332
377
 
333
378
  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>
@@ -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.js CHANGED
@@ -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.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,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"