morphicons 1.1.1 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -36,7 +36,7 @@ The usual icon morphs either interpolate raw coordinates (shapes shrink and shea
36
36
  bun add morphicons # or npm install / pnpm add
37
37
  ```
38
38
 
39
- ESM only. `react` is an optional peer (>= 18) — only needed for `morphicons/react`.
39
+ ESM only. `react` (>= 18) and `vue` (>= 3.3) are optional peers — only needed for `morphicons/react` and `morphicons/vue`.
40
40
 
41
41
  ## Usage
42
42
 
@@ -60,6 +60,34 @@ ref.current?.set(X); // jumps without animating
60
60
 
61
61
  Drop-in replacement for lucide-react: `size`, `strokeWidth`, `absoluteStrokeWidth`, `color`, `className` and the rest of the `<svg>` props pass straight through. Correct accessibility by default: `aria-hidden` unless you pass `label` (→ `role="img"` + `<title>`). Clean SSR: the server emits the exact static SVG (zero flash, zero layout shift); the runtime is born on hydration. `prefers-reduced-motion` degrades to an instant swap.
62
62
 
63
+ ### Vue — same three modes
64
+
65
+ ```vue
66
+ <script setup lang="ts">
67
+ import { ref } from "vue";
68
+ import { MorphIcon, type MorphHandle } from "morphicons/vue";
69
+ import { Menu, X, Check } from "lucide"; // data, not components
70
+
71
+ const open = ref(false);
72
+ const morph = ref<MorphHandle | null>(null);
73
+ // morph.value?.morphTo(Check) → animates
74
+ // morph.value?.set(X) → jumps without animating
75
+ </script>
76
+
77
+ <template>
78
+ <!-- 1. Uncontrolled (90% of uses): change the prop and morphicons animates -->
79
+ <MorphIcon :icon="open ? X : Menu" spring="snappy" />
80
+
81
+ <!-- 2. Controlled (gestures, scroll): explicit progress, no spring -->
82
+ <MorphIcon :from="Menu" :to="X" :progress="dragProgress" />
83
+
84
+ <!-- 3. Imperative (sequences): template ref → morphTo / set -->
85
+ <MorphIcon ref="morph" :icon="Menu" />
86
+ </template>
87
+ ```
88
+
89
+ Same surface as the React binding: presentation props (`size`, `strokeWidth`, `absoluteStrokeWidth`, `color`; `class`, `style` and the rest of the `<svg>` attrs fall through), the same accessibility defaults (`aria-hidden` unless you pass `label`) and the same clean SSR — works with Nuxt out of the box: the server emits the exact static SVG and the runtime is born on hydration. The binding is a plain render function; no SFC compiler or JSX involved.
90
+
63
91
  ### Vanilla (no React)
64
92
 
65
93
  ```ts
@@ -103,7 +131,7 @@ morphicons has no per-library adapters — any icon set that meets these require
103
131
  const bell = fitIcon(teenySmallBell, 15); // also "0 0 15 15" or [0, 0, 15, 15]
104
132
  ```
105
133
 
106
- It returns a plain `d`, so it goes anywhere an icon is accepted. Call it at module scope, not per render. Skipping it doesn't throw — Procrustes is similarity-invariant, so the grid mismatch never reads as false rotation; it lands in σ as an unwanted zoom, and the target draws outside the canvas. The React binding defaults to `viewBox="0 0 24 24"`, overridable via props.
134
+ It returns a plain `d`, so it goes anywhere an icon is accepted. Call it at module scope, not per render. Skipping it doesn't throw — Procrustes is similarity-invariant, so the grid mismatch never reads as false rotation; it lands in σ as an unwanted zoom, and the target draws outside the canvas. The React and Vue bindings default to `viewBox="0 0 24 24"`, overridable via props.
107
135
  4. **Uniform stroke.** Cosmetic rather than structural: a consistent stroke width and round caps/joins make in-flight shapes look native. These live on the `<svg>`, not in morphicons.
108
136
 
109
137
  Beyond the libraries in the playground, sets known to qualify on the 24×24 grid — no fitting needed: **Heroicons** (outline style), **Iconoir** (1.5px stroke), **Akar Icons**, **Untitled UI** and **Hugeicons** (stroke style). Off-grid packs work through `fitIcon`: **Teenyicons** (15×15), **Carbon** (32×32), **Heroicons solid** (20×20).
@@ -114,7 +142,7 @@ This also covers the [shadcn registry](https://www.shadcn.io/icons), which re-pu
114
142
 
115
143
  ```
116
144
  ┌─────────────────────────────────────────────┐
117
- │ bindings react │ thin: ref + createMorph
145
+ │ bindings react · vue │ thin: ref + createMorph
118
146
  ├─────────────────────────────────────────────┤
119
147
  │ drivers dom (setAttribute + rAF) │ singleton scheduler
120
148
  ├─────────────────────────────────────────────┤
@@ -125,7 +153,7 @@ This also covers the [shadcn registry](https://www.shadcn.io/icons), which re-pu
125
153
  └─────────────────────────────────────────────┘
126
154
  ```
127
155
 
128
- The hard rule: **the core never touches the DOM**. Pure functions consume icon data and produce `d` strings. Direct consequence: a React Native driver over `react-native-svg` + Reanimated is just another adapter, not a rewrite. One package with subpath exports (`.` core, `./dom`, `./react`), ESM only, `sideEffects: false`.
156
+ The hard rule: **the core never touches the DOM**. Pure functions consume icon data and produce `d` strings. Direct consequence: a React Native driver over `react-native-svg` + Reanimated is just another adapter, not a rewrite. One package with subpath exports (`.` core, `./dom`, `./react`, `./vue`), ESM only, `sideEffects: false`.
129
157
 
130
158
  The plan is the central artifact:
131
159
 
@@ -266,6 +294,7 @@ CI budget (size-limit, gzip) as an anti-regression tripwire — gates carry ~10%
266
294
  | `morphicons` (core) | 6.60 KB | 7 KB |
267
295
  | `morphicons/dom` (core + driver) | 7.04 KB | 7.5 KB |
268
296
  | `morphicons/react` (all, react external) | 7.77 KB | 8.5 KB |
297
+ | `morphicons/vue` (all, vue external) | 7.81 KB | 8.5 KB |
269
298
 
270
299
  ## Playground
271
300
 
@@ -278,15 +307,27 @@ bun run play # → http://localhost:3000
278
307
  ## Development
279
308
 
280
309
  ```bash
281
- bun test # 83 tests / ~13,400 asserts
282
- bun run typecheck # strict ×3: core+dom without lib DOM, playground, react
310
+ bun test # 92 tests / ~13,500 asserts
311
+ bun run typecheck # strict ×4: core+dom without lib DOM, playground, react, vue
283
312
  bun run format # biome
284
- bun run build # tsdown → dist/ (entries index, dom, react)
313
+ bun run build # tsdown → dist/ (entries index, dom, react, vue)
285
314
  bun run size # size gates
286
315
  ```
287
316
 
288
317
  Icons shown in the playground belong to their authors: [Lucide](https://lucide.dev) (ISC), [Feather](https://feathericons.com) (MIT), [Tabler](https://tabler.io/icons) (MIT).
289
318
 
319
+ ## Star history
320
+
321
+ <p align="center">
322
+ <a href="https://star-history.com/#guillermolg00/morphicons&Date">
323
+ <picture>
324
+ <source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=guillermolg00/morphicons&type=Date&theme=dark">
325
+ <source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=guillermolg00/morphicons&type=Date">
326
+ <img alt="Star history chart of morphicons" src="https://api.star-history.com/svg?repos=guillermolg00/morphicons&type=Date" width="640">
327
+ </picture>
328
+ </a>
329
+ </p>
330
+
290
331
  ---
291
332
 
292
333
  MIT © [Guillermo](https://guillermolg.com) — made by [guillermolg.com](https://guillermolg.com)
package/dist/vue.d.ts ADDED
@@ -0,0 +1,121 @@
1
+ import { a as Sampled, c as SpringPreset, n as IconInput, r as IconNode } from "./types-JAF3s76Y.js";
2
+ import { Morph, MorphOptions, PathEl } from "./dom.js";
3
+ import { PropType } from "vue";
4
+ //#region src/vue/index.d.ts
5
+ /** Imperative surface exposed on the component instance (template ref). */
6
+ interface MorphHandle {
7
+ morphTo(icon: IconInput, spring?: SpringPreset | MorphOptions): void;
8
+ set(icon: IconInput): void;
9
+ }
10
+ declare const MorphIcon: import("vue").DefineComponent<import("vue").ExtractPropTypes<{
11
+ /** Uncontrolled mode: the current icon; changing the prop animates. */
12
+ icon: {
13
+ type: PropType<IconInput>;
14
+ default: undefined;
15
+ };
16
+ /** Controlled mode: source endpoint of the pair. */
17
+ from: {
18
+ type: PropType<IconInput>;
19
+ default: undefined;
20
+ };
21
+ /** Controlled mode: target endpoint of the pair. */
22
+ to: {
23
+ type: PropType<IconInput>;
24
+ default: undefined;
25
+ };
26
+ /** Controlled mode: 0..1 progress of the frozen morph (no spring). */
27
+ progress: {
28
+ type: NumberConstructor;
29
+ default: undefined;
30
+ };
31
+ /** Physics for uncontrolled/imperative mode: preset or custom spring. */
32
+ spring: {
33
+ type: PropType<SpringPreset | MorphOptions>;
34
+ default: undefined;
35
+ };
36
+ size: {
37
+ type: (NumberConstructor | StringConstructor)[];
38
+ default: number;
39
+ };
40
+ color: {
41
+ type: StringConstructor;
42
+ default: string;
43
+ };
44
+ strokeWidth: {
45
+ type: (NumberConstructor | StringConstructor)[];
46
+ default: number;
47
+ };
48
+ /** Like lucide: stroke width does not scale with `size`. */
49
+ absoluteStrokeWidth: {
50
+ type: BooleanConstructor;
51
+ default: boolean;
52
+ };
53
+ /** Accessibility: with label → role="img" + <title>; without → aria-hidden. */
54
+ label: {
55
+ type: StringConstructor;
56
+ default: undefined;
57
+ };
58
+ }>, () => import("vue").VNode<import("vue").RendererNode, import("vue").RendererElement, {
59
+ [key: string]: any;
60
+ }>, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<import("vue").ExtractPropTypes<{
61
+ /** Uncontrolled mode: the current icon; changing the prop animates. */
62
+ icon: {
63
+ type: PropType<IconInput>;
64
+ default: undefined;
65
+ };
66
+ /** Controlled mode: source endpoint of the pair. */
67
+ from: {
68
+ type: PropType<IconInput>;
69
+ default: undefined;
70
+ };
71
+ /** Controlled mode: target endpoint of the pair. */
72
+ to: {
73
+ type: PropType<IconInput>;
74
+ default: undefined;
75
+ };
76
+ /** Controlled mode: 0..1 progress of the frozen morph (no spring). */
77
+ progress: {
78
+ type: NumberConstructor;
79
+ default: undefined;
80
+ };
81
+ /** Physics for uncontrolled/imperative mode: preset or custom spring. */
82
+ spring: {
83
+ type: PropType<SpringPreset | MorphOptions>;
84
+ default: undefined;
85
+ };
86
+ size: {
87
+ type: (NumberConstructor | StringConstructor)[];
88
+ default: number;
89
+ };
90
+ color: {
91
+ type: StringConstructor;
92
+ default: string;
93
+ };
94
+ strokeWidth: {
95
+ type: (NumberConstructor | StringConstructor)[];
96
+ default: number;
97
+ };
98
+ /** Like lucide: stroke width does not scale with `size`. */
99
+ absoluteStrokeWidth: {
100
+ type: BooleanConstructor;
101
+ default: boolean;
102
+ };
103
+ /** Accessibility: with label → role="img" + <title>; without → aria-hidden. */
104
+ label: {
105
+ type: StringConstructor;
106
+ default: undefined;
107
+ };
108
+ }>> & Readonly<{}>, {
109
+ icon: IconInput;
110
+ from: IconInput;
111
+ to: IconInput;
112
+ progress: number;
113
+ spring: "bouncy" | "smooth" | "snappy" | MorphOptions;
114
+ size: string | number;
115
+ color: string;
116
+ strokeWidth: string | number;
117
+ absoluteStrokeWidth: boolean;
118
+ label: string;
119
+ }, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
120
+ //#endregion
121
+ export { type IconInput, type IconNode, type Morph, MorphHandle, MorphIcon, type MorphOptions, type PathEl, type Sampled, type SpringPreset };
package/dist/vue.js ADDED
@@ -0,0 +1,158 @@
1
+ import { a as buildPlan, f as interpPolar, l as serialize, r as resampleIcon, u as allocOutputs } from "./spring-BTcHiHR5.js";
2
+ import { canonicalD, createMorph } from "./dom.js";
3
+ import { defineComponent, h, onBeforeUnmount, onMounted, shallowRef, watch } from "vue";
4
+ //#region src/vue/index.ts
5
+ /** Frozen shape of the from→to pair at t, using the pure core (SSR-safe).
6
+ * At exact endpoints returns the canonical `d` (real curves, not polyline). */
7
+ function frozenD(from, to, t) {
8
+ if (t <= 0) return canonicalD(from);
9
+ if (t >= 1) return canonicalD(to);
10
+ const plan = buildPlan(resampleIcon(from), resampleIcon(to));
11
+ const out = allocOutputs(plan);
12
+ interpPolar(plan, t, out);
13
+ return serialize(out, plan.items.map((it) => it.closed));
14
+ }
15
+ const MorphIcon = defineComponent({
16
+ name: "MorphIcon",
17
+ props: {
18
+ /** Uncontrolled mode: the current icon; changing the prop animates. */
19
+ icon: {
20
+ type: [String, Array],
21
+ default: void 0
22
+ },
23
+ /** Controlled mode: source endpoint of the pair. */
24
+ from: {
25
+ type: [String, Array],
26
+ default: void 0
27
+ },
28
+ /** Controlled mode: target endpoint of the pair. */
29
+ to: {
30
+ type: [String, Array],
31
+ default: void 0
32
+ },
33
+ /** Controlled mode: 0..1 progress of the frozen morph (no spring). */
34
+ progress: {
35
+ type: Number,
36
+ default: void 0
37
+ },
38
+ /** Physics for uncontrolled/imperative mode: preset or custom spring. */
39
+ spring: {
40
+ type: [String, Object],
41
+ default: void 0
42
+ },
43
+ size: {
44
+ type: [Number, String],
45
+ default: 24
46
+ },
47
+ color: {
48
+ type: String,
49
+ default: "currentColor"
50
+ },
51
+ strokeWidth: {
52
+ type: [Number, String],
53
+ default: 2
54
+ },
55
+ /** Like lucide: stroke width does not scale with `size`. */
56
+ absoluteStrokeWidth: {
57
+ type: Boolean,
58
+ default: false
59
+ },
60
+ /** Accessibility: with label → role="img" + <title>; without → aria-hidden. */
61
+ label: {
62
+ type: String,
63
+ default: void 0
64
+ }
65
+ },
66
+ setup(props, { expose }) {
67
+ const initialD = (() => {
68
+ const { icon, from, to, progress } = props;
69
+ if (from !== void 0 && to !== void 0) return frozenD(from, to, progress ?? 0);
70
+ const first = icon ?? from ?? to;
71
+ return first !== void 0 ? canonicalD(first) : "";
72
+ })();
73
+ const pathEl = shallowRef(null);
74
+ let morph = null;
75
+ let based = false;
76
+ let pair = null;
77
+ onMounted(() => {
78
+ const el = pathEl.value;
79
+ const { icon, from, to, progress } = props;
80
+ const controlled = from !== void 0 && to !== void 0;
81
+ const initialIcon = icon ?? from ?? to;
82
+ if (!el || initialIcon === void 0) return;
83
+ const m = createMorph(el, controlled ? from : initialIcon);
84
+ morph = m;
85
+ if (controlled) {
86
+ pair = [from, to];
87
+ const t = progress ?? 0;
88
+ if (t <= 0) m.set(from);
89
+ else if (t >= 1) m.set(to);
90
+ else {
91
+ m.seek(to, t);
92
+ based = true;
93
+ }
94
+ }
95
+ });
96
+ onBeforeUnmount(() => {
97
+ morph?.destroy();
98
+ morph = null;
99
+ based = false;
100
+ pair = null;
101
+ });
102
+ watch(() => props.icon, (icon) => {
103
+ if (icon !== void 0) morph?.morphTo(icon, props.spring);
104
+ });
105
+ watch(() => [
106
+ props.from,
107
+ props.to,
108
+ props.progress
109
+ ], () => {
110
+ const m = morph;
111
+ const { from, to, progress } = props;
112
+ if (!m || from === void 0 || to === void 0) return;
113
+ const t = progress ?? 0;
114
+ if (!pair || pair[0] !== from || pair[1] !== to) {
115
+ pair = [from, to];
116
+ based = false;
117
+ }
118
+ if (t <= 0) {
119
+ m.set(from);
120
+ based = false;
121
+ } else if (t >= 1) {
122
+ m.set(to);
123
+ based = false;
124
+ } else {
125
+ if (!based) {
126
+ m.set(from);
127
+ based = true;
128
+ }
129
+ m.seek(to, t);
130
+ }
131
+ });
132
+ expose({
133
+ morphTo: (i, s) => morph?.morphTo(i, s ?? props.spring),
134
+ set: (i) => morph?.set(i)
135
+ });
136
+ return () => {
137
+ const sw = props.absoluteStrokeWidth ? Number(props.strokeWidth) * 24 / Number(props.size) : props.strokeWidth;
138
+ return h("svg", {
139
+ xmlns: "http://www.w3.org/2000/svg",
140
+ width: props.size,
141
+ height: props.size,
142
+ viewBox: "0 0 24 24",
143
+ fill: "none",
144
+ stroke: props.color,
145
+ "stroke-width": sw,
146
+ "stroke-linecap": "round",
147
+ "stroke-linejoin": "round",
148
+ role: props.label ? "img" : void 0,
149
+ "aria-hidden": props.label ? void 0 : "true"
150
+ }, [props.label ? h("title", props.label) : null, h("path", {
151
+ ref: pathEl,
152
+ d: initialD
153
+ })]);
154
+ };
155
+ }
156
+ });
157
+ //#endregion
158
+ export { MorphIcon };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "morphicons",
3
- "version": "1.1.1",
3
+ "version": "1.2.0",
4
4
  "description": "Universal morphing for stroke-based icons with spring physics — zero runtime dependencies",
5
5
  "license": "MIT",
6
6
  "author": "Guillermo (https://guillermolg.com)",
@@ -29,6 +29,8 @@
29
29
  "shadcn",
30
30
  "feather",
31
31
  "react",
32
+ "vue",
33
+ "lucide-vue-next",
32
34
  "icon-animation"
33
35
  ],
34
36
  "type": "module",
@@ -48,14 +50,22 @@
48
50
  "./react": {
49
51
  "types": "./dist/react.d.ts",
50
52
  "default": "./dist/react.js"
53
+ },
54
+ "./vue": {
55
+ "types": "./dist/vue.d.ts",
56
+ "default": "./dist/vue.js"
51
57
  }
52
58
  },
53
59
  "peerDependencies": {
54
- "react": ">=18"
60
+ "react": ">=18",
61
+ "vue": ">=3.3"
55
62
  },
56
63
  "peerDependenciesMeta": {
57
64
  "react": {
58
65
  "optional": true
66
+ },
67
+ "vue": {
68
+ "optional": true
59
69
  }
60
70
  },
61
71
  "files": [
@@ -63,7 +73,7 @@
63
73
  ],
64
74
  "scripts": {
65
75
  "test": "bun test",
66
- "typecheck": "tsc --noEmit && tsc -p playground --noEmit && tsc -p tsconfig.react.json --noEmit",
76
+ "typecheck": "tsc --noEmit && tsc -p playground --noEmit && tsc -p tsconfig.react.json --noEmit && tsc -p tsconfig.vue.json --noEmit",
67
77
  "play": "bun ./playground/index.html",
68
78
  "lint": "biome check .",
69
79
  "format": "biome check --write .",
@@ -95,6 +105,16 @@
95
105
  ],
96
106
  "limit": "8.5 KB",
97
107
  "gzip": true
108
+ },
109
+ {
110
+ "name": "core + dom + vue (MorphIcon, vue external)",
111
+ "path": "dist/vue.js",
112
+ "import": "*",
113
+ "ignore": [
114
+ "vue"
115
+ ],
116
+ "limit": "8.5 KB",
117
+ "gzip": true
98
118
  }
99
119
  ],
100
120
  "devDependencies": {
@@ -110,6 +130,7 @@
110
130
  "react-dom": "^19.2.8",
111
131
  "size-limit": "^13.0.3",
112
132
  "tsdown": "^0.22.14",
113
- "typescript": "^7.0.2"
133
+ "typescript": "^7.0.2",
134
+ "vue": "^3.5.40"
114
135
  }
115
136
  }