morphicons 1.0.0 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -13,7 +13,7 @@
13
13
  <a href="https://www.morphicons.com"><strong>morphicons.com</strong></a> — live playground
14
14
  </p>
15
15
 
16
- Universal morphing for stroke-based icons (Lucide, Feather, Tabler, or your own paths): **any icon morphs into any other** with spring physics. Rotations are never declared by hand — they emerge from the math (2D Procrustes + polar interpolation). Zero runtime dependencies.
16
+ Universal morphing for stroke-based icons (Lucide, Tabler, Heroicons, Iconoir, or your own paths): **any icon morphs into any other** with spring physics. Rotations are never declared by hand — they emerge from the math (2D Procrustes + polar interpolation). Zero runtime dependencies.
17
17
 
18
18
  ```tsx
19
19
  import { MorphIcon } from "morphicons/react";
@@ -86,7 +86,7 @@ interpPolar(plan, 0.5, out); // t ∈ [0, 1]
86
86
  const d = serialize(out, plan.items.map(it => it.closed)); // `d` attribute
87
87
  ```
88
88
 
89
- Accepts `IconNode` (Lucide's data format, structurally typed — Lucide is neither a dependency nor a peer) or a raw `d` attribute. Feather, Tabler and custom paths work the same.
89
+ Accepts `IconNode` (Lucide's data format, structurally typed — Lucide is neither a dependency nor a peer) or a raw `d` attribute. Tabler, Heroicons, Iconoir and custom paths work the same.
90
90
 
91
91
  ## Icon library compatibility
92
92
 
@@ -94,10 +94,21 @@ morphicons has no per-library adapters — any icon set that meets these require
94
94
 
95
95
  1. **Stroke-drawn icons.** The geometry must be the stroked centerline (`fill="none"`, color via `stroke`). The whole pipeline — resampling, correspondence, in-flight polylines — assumes strokes; filled or outlined-fill glyphs (Material Symbols, Bootstrap Icons, Remix Icon, Phosphor, Heroicons *solid*) parse fine but won't read correctly in transit.
96
96
  2. **Geometry available as data.** Either a raw `d` attribute per icon, or a `[tag, attrs][]` node list (Lucide's IconNode shape) using only `path`, `line`, `circle`, `ellipse`, `rect`, `polyline`, `polygon`. No `<g>` wrappers and no `transform` attributes — coordinates must be literal. Any other tag throws a clear error.
97
- 3. **A shared coordinate space per pair.** Both endpoints of a morph must live on the same grid. Lucide, Feather and Tabler all draw on 24×24 — that's why cross-library morphs just work. The React binding defaults to `viewBox="0 0 24 24"`, overridable via props.
97
+ 3. **A shared coordinate space per pair.** Both endpoints of a morph must live on the same grid. Lucide, Tabler, Heroicons and Iconoir all draw on 24×24 — that's why cross-library morphs just work. For a pack on another canvas (Heroicons *solid* on 20, Carbon on 32, Teenyicons on 15), re-grid it once with `fitIcon`:
98
+
99
+ ```ts
100
+ import { fitIcon } from "morphicons";
101
+
102
+ const search = fitIcon(carbonSearch, 32); // 32×32 → the 24 grid
103
+ const bell = fitIcon(teenySmallBell, 15); // also "0 0 15 15" or [0, 0, 15, 15]
104
+ ```
105
+
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.
98
107
  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.
99
108
 
100
- Beyond the three libraries in the playground, sets known to qualify: **Heroicons** (outline style, 24×24), **Iconoir** (24×24, 1.5px stroke), **Akar Icons** (24×24), **Untitled UI** (24×24) and **Hugeicons** (stroke style, 24×24). **Teenyicons** qualifies too but draws on a 15×15 grid — keep both endpoints on that grid and pass the matching `viewBox`.
109
+ 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).
110
+
111
+ This also covers the [shadcn registry](https://www.shadcn.io/icons), which re-publishes 200+ icon libraries as React components with an inline `<path d>` — pass that `d` straight in, adding `fitIcon` when the pack's `viewBox` isn't 24.
101
112
 
102
113
  ## Architecture
103
114
 
@@ -197,7 +208,7 @@ Let `aᶜᵢ = aᵢ − c_A` and `b̃ᵢ = R(−θ*)·(bᵢ − c_B)/σ*` (B bro
197
208
  ```
198
209
  P(t) = c(t) + σ*ᵗ · R(t·θ*) · [(1−t)·aᶜᵢ + t·b̃ᵢ]
199
210
 
200
- c(t) = lerp(c_A, c_B, t) linear translation
211
+ c(t) = lerp(c_A, c_B, t) linear translation (block transport under the global hybrid — below)
201
212
  t·θ* = linear angle (θ* is already in (−π, π], short path)
202
213
  σ*ᵗ = exp(t·ln σ*) log-linear scale (geodesic in ℝ⁺)
203
214
  ```
@@ -209,6 +220,14 @@ Exact at both endpoints (max error ~3.6e-15 in tests). Properties:
209
220
  - Mixed cases do both simultaneously and continuously.
210
221
  - With spring overshoot (t > 1) the formula **extrapolates** naturally: rotation and scale overshoot slightly and come back — free juice.
211
222
 
223
+ **Block transport (global hybrid).** When the whole icon is congruent (§4), lerping each subpath centroid reintroduces the chord disease one level up: every part spins with the shared θ*, but an off-center part travels the chord — inside the arc — and drifts toward the center mid-flight. An arrow's head sagged ~1 px toward its shaft at t = 0.5 (`r·(1 − cos(θ/2))` with r = 3.5, θ = 90°) while both parts rotated perfectly. Under the hybrid, each centroid rides the shared similarity around the global centroid `g_A` instead:
224
+
225
+ ```
226
+ c_k(t) = c_{A,k} + t·d_k + (σ*ᵗ·R(t·θ*) − I)·(c_{A,k} − g_A)
227
+ ```
228
+
229
+ `d_k` is fixed once so that `c_k(1) = c_{B,k}` exactly (it absorbs the tiny global residual). Every point of every subpath then moves under ONE similarity per frame: congruent icons are rigid through the whole flight, not only at the endpoints. With θ* = 0, or centroids sitting on `g_A`, or no hybrid at all, the formula degrades continuously to the plain lerp — no thresholds, no special cases.
230
+
212
231
  ### 6. Serialization and canonical snap
213
232
 
214
233
  In flight, each subpath is emitted as `M x y L x y …` (+ `Z` if the pair is closed↔closed; closed→open flies open, the loop opening at the cut chosen by the circular correspondence) with 2 decimals — invisible at 24px, and the frame's only allocation. On settle, the driver **snaps to the canonical `d`** of the target icon: exact fidelity at rest (real curves, not polylines), subpath count reset after duplications, and the DOM ends up identical to a static icon's. The jump is < 0.02px — imperceptible.
@@ -244,9 +263,9 @@ CI budget (size-limit, gzip) as an anti-regression tripwire — gates carry ~10%
244
263
 
245
264
  | entry | measured | gate |
246
265
  |---|---|---|
247
- | `morphicons` (core) | 6.32 KB | 7 KB |
248
- | `morphicons/dom` (core + driver) | 6.92 KB | 7.5 KB |
249
- | `morphicons/react` (all, react external) | 7.65 KB | 8.5 KB |
266
+ | `morphicons` (core) | 6.60 KB | 7 KB |
267
+ | `morphicons/dom` (core + driver) | 7.04 KB | 7.5 KB |
268
+ | `morphicons/react` (all, react external) | 7.77 KB | 8.5 KB |
250
269
 
251
270
  ## Playground
252
271
 
@@ -259,7 +278,7 @@ bun run play # → http://localhost:3000
259
278
  ## Development
260
279
 
261
280
  ```bash
262
- bun test # 74 tests / ~13,400 asserts
281
+ bun test # 83 tests / ~13,400 asserts
263
282
  bun run typecheck # strict ×3: core+dom without lib DOM, playground, react
264
283
  bun run format # biome
265
284
  bun run build # tsdown → dist/ (entries index, dom, react)
package/dist/dom.js CHANGED
@@ -1,4 +1,4 @@
1
- import { a as resampleIcon, c as iconToCubics, d as interpPolar, i as serialize, l as allocOutputs, n as Spring, r as cubicsToPathD, s as buildPlan, t as SPRING_PRESETS } from "./spring-Dbj7NCw2.js";
1
+ import { a as buildPlan, c as cubicsToPathD, f as interpPolar, l as serialize, n as Spring, r as resampleIcon, s as iconToCubics, t as SPRING_PRESETS, u as allocOutputs } from "./spring-BTcHiHR5.js";
2
2
  //#region src/dom/index.ts
3
3
  const tickers = /* @__PURE__ */ new Set();
4
4
  let rafId = 0;
package/dist/index.d.ts CHANGED
@@ -18,6 +18,13 @@ interface PlanItem {
18
18
  /** true if both endpoints are closed loops: the subpath flies with Z.
19
19
  * Closed → open flies open: the loop opens at the chosen cut. */
20
20
  closed: boolean;
21
+ /** Block transport (set by the global hybrid, else null): mid-flight the
22
+ * centroid rides the shared similarity around the global centroid instead
23
+ * of lerping — off = c_A − g_A; drift closes c(1) = c_B exactly. */
24
+ block: {
25
+ off: readonly [number, number];
26
+ drift: readonly [number, number];
27
+ } | null;
21
28
  }
22
29
  interface MorphPlan {
23
30
  items: PlanItem[];
@@ -38,6 +45,17 @@ declare function interpLinear(plan: MorphPlan, t: number, out: Float64Array[]):
38
45
  //#region src/core/normalize.d.ts
39
46
  /** Icon (IconNode or `d` string) → list of cubic subpaths. */
40
47
  declare function iconToCubics(input: IconInput): CubicPath[];
48
+ /** A source viewBox: `24`, `"0 0 20 20"` or `[minX, minY, w, h]`. */
49
+ type ViewBox = number | string | readonly [number, number, number, number];
50
+ /** Re-grids an icon drawn on `viewBox` onto the shared `grid` (24 by default),
51
+ * centred and preserving aspect ratio — the SVG `xMidYMid meet` rule.
52
+ *
53
+ * Both endpoints of a morph must live on the same coordinate space. Lucide and
54
+ * Tabler already draw on 24×24; packs on 20 (Heroicons solid) or 32 (Carbon)
55
+ * do not, and mixing them unfitted makes Procrustes read the scale/offset gap
56
+ * as rotation. Apply once at module scope (not per render) and pass the
57
+ * resulting `d` anywhere an icon is accepted. */
58
+ declare function fitIcon(input: IconInput, viewBox: ViewBox, grid?: number): string;
41
59
  //#endregion
42
60
  //#region src/core/resample.d.ts
43
61
  /** Samples a cubic subpath at N points equidistant by arc length, anchoring
@@ -57,4 +75,4 @@ declare function serialize(subs: readonly Float64Array[], closed?: readonly bool
57
75
  /** Cubic subpaths → canonical `d` at full precision (round-trip). */
58
76
  declare function cubicsToPathD(paths: readonly CubicPath[]): string;
59
77
  //#endregion
60
- export { type CubicPath, type IconInput, type IconNode, type IconNodeAttrs, type MorphPlan, type PlanItem, SPRING_PRESETS, type Sampled, Spring, type SpringPreset, allocOutputs, buildPlan, cubicsToPathD, iconToCubics, interpLinear, interpPolar, resampleIcon, resamplePath, serialize };
78
+ export { type CubicPath, type IconInput, type IconNode, type IconNodeAttrs, type MorphPlan, type PlanItem, SPRING_PRESETS, type Sampled, Spring, type SpringPreset, type ViewBox, allocOutputs, buildPlan, cubicsToPathD, fitIcon, iconToCubics, interpLinear, interpPolar, resampleIcon, resamplePath, serialize };
package/dist/index.js CHANGED
@@ -1,2 +1,2 @@
1
- import { a as resampleIcon, c as iconToCubics, d as interpPolar, i as serialize, l as allocOutputs, n as Spring, o as resamplePath, r as cubicsToPathD, s as buildPlan, t as SPRING_PRESETS, u as interpLinear } from "./spring-Dbj7NCw2.js";
2
- export { SPRING_PRESETS, Spring, allocOutputs, buildPlan, cubicsToPathD, iconToCubics, interpLinear, interpPolar, resampleIcon, resamplePath, serialize };
1
+ import { a as buildPlan, c as cubicsToPathD, d as interpLinear, f as interpPolar, i as resamplePath, l as serialize, n as Spring, o as fitIcon, r as resampleIcon, s as iconToCubics, t as SPRING_PRESETS, u as allocOutputs } from "./spring-BTcHiHR5.js";
2
+ export { SPRING_PRESETS, Spring, allocOutputs, buildPlan, cubicsToPathD, fitIcon, iconToCubics, interpLinear, interpPolar, resampleIcon, resamplePath, serialize };
package/dist/react.js CHANGED
@@ -1,4 +1,4 @@
1
- import { a as resampleIcon, d as interpPolar, i as serialize, l as allocOutputs, s as buildPlan } from "./spring-Dbj7NCw2.js";
1
+ import { a as buildPlan, f as interpPolar, l as serialize, r as resampleIcon, u as allocOutputs } from "./spring-BTcHiHR5.js";
2
2
  import { canonicalD, createMorph } from "./dom.js";
3
3
  import { forwardRef, useEffect, useImperativeHandle, useLayoutEffect, useRef, useState } from "react";
4
4
  import { jsx, jsxs } from "react/jsx-runtime";
@@ -8,12 +8,21 @@ function interpPolar(plan, t, out) {
8
8
  const it = plan.items[k];
9
9
  const o = out[k];
10
10
  const n = plan.n;
11
- const cx = it.ca[0] + (it.cb[0] - it.ca[0]) * t;
12
- const cy = it.ca[1] + (it.cb[1] - it.ca[1]) * t;
13
11
  const s = Math.exp(it.lnSigma * t);
14
12
  const ang = it.theta * t;
15
13
  const cos = Math.cos(ang) * s;
16
14
  const sin = Math.sin(ang) * s;
15
+ let cx;
16
+ let cy;
17
+ if (it.block) {
18
+ const [ox, oy] = it.block.off;
19
+ const [dx, dy] = it.block.drift;
20
+ cx = it.ca[0] + dx * t + (ox * cos - oy * sin - ox);
21
+ cy = it.ca[1] + dy * t + (ox * sin + oy * cos - oy);
22
+ } else {
23
+ cx = it.ca[0] + (it.cb[0] - it.ca[0]) * t;
24
+ cy = it.ca[1] + (it.cb[1] - it.ca[1]) * t;
25
+ }
17
26
  for (let i = 0; i < n; i++) {
18
27
  const px = it.aC[2 * i] + (it.bT[2 * i] - it.aC[2 * i]) * t;
19
28
  const py = it.aC[2 * i + 1] + (it.bT[2 * i + 1] - it.aC[2 * i + 1]) * t;
@@ -279,6 +288,34 @@ function parsePath(d) {
279
288
  return subs.filter((s) => s.segs.length > 0);
280
289
  }
281
290
  //#endregion
291
+ //#region src/core/serialize.ts
292
+ function fmt(v) {
293
+ return String(Math.round(v * 100) / 100);
294
+ }
295
+ /** Sampled subpaths → polyline `d` attribute. `closed[k]` appends Z to
296
+ * subpath k (closed loops in flight); without flags everything is open. */
297
+ function serialize(subs, closed) {
298
+ let d = "";
299
+ for (let k = 0; k < subs.length; k++) {
300
+ const o = subs[k];
301
+ const n = o.length / 2;
302
+ d += `M${fmt(o[0])} ${fmt(o[1])}`;
303
+ for (let i = 1; i < n; i++) d += `L${fmt(o[2 * i])} ${fmt(o[2 * i + 1])}`;
304
+ if (closed?.[k]) d += "Z";
305
+ }
306
+ return d;
307
+ }
308
+ /** Cubic subpaths → canonical `d` at full precision (round-trip). */
309
+ function cubicsToPathD(paths) {
310
+ let d = "";
311
+ for (const { pts, closed } of paths) {
312
+ d += `M${pts[0]} ${pts[1]}`;
313
+ for (let i = 2; i < pts.length; i += 6) d += `C${pts[i]} ${pts[i + 1]} ${pts[i + 2]} ${pts[i + 3]} ${pts[i + 4]} ${pts[i + 5]}`;
314
+ if (closed) d += "Z";
315
+ }
316
+ return d;
317
+ }
318
+ //#endregion
282
319
  //#region src/core/normalize.ts
283
320
  /** Control-point offset for a quarter circle: (4/3)·tan(π/8) ≈ 0.5523. */
284
321
  const KAPPA = 4 / 3 * Math.tan(Math.PI / 8);
@@ -505,6 +542,42 @@ function iconToCubics(input) {
505
542
  }
506
543
  return out;
507
544
  }
545
+ function parseViewBox(vb) {
546
+ const v = typeof vb === "number" ? [
547
+ 0,
548
+ 0,
549
+ vb,
550
+ vb
551
+ ] : typeof vb === "string" ? vb.trim().split(/[\s,]+/).map(Number) : vb;
552
+ const [minX, minY, w, h] = v;
553
+ if (v.length !== 4 || !(w > 0) || !(h > 0) || !Number.isFinite(minX) || !Number.isFinite(minY)) throw new Error(`morphicons: invalid viewBox "${String(vb)}"`);
554
+ return [
555
+ minX,
556
+ minY,
557
+ w,
558
+ h
559
+ ];
560
+ }
561
+ /** Re-grids an icon drawn on `viewBox` onto the shared `grid` (24 by default),
562
+ * centred and preserving aspect ratio — the SVG `xMidYMid meet` rule.
563
+ *
564
+ * Both endpoints of a morph must live on the same coordinate space. Lucide and
565
+ * Tabler already draw on 24×24; packs on 20 (Heroicons solid) or 32 (Carbon)
566
+ * do not, and mixing them unfitted makes Procrustes read the scale/offset gap
567
+ * as rotation. Apply once at module scope (not per render) and pass the
568
+ * resulting `d` anywhere an icon is accepted. */
569
+ function fitIcon(input, viewBox, grid = 24) {
570
+ const [minX, minY, w, h] = parseViewBox(viewBox);
571
+ const s = Math.min(grid / w, grid / h);
572
+ const tx = (grid - w * s) / 2 - minX * s;
573
+ const ty = (grid - h * s) / 2 - minY * s;
574
+ const paths = iconToCubics(input);
575
+ for (const { pts } of paths) for (let i = 0; i < pts.length; i += 2) {
576
+ pts[i] = pts[i] * s + tx;
577
+ pts[i + 1] = pts[i + 1] * s + ty;
578
+ }
579
+ return cubicsToPathD(paths);
580
+ }
508
581
  //#endregion
509
582
  //#region src/core/plan.ts
510
583
  /** Weight of |ΔL| in the subpath pairing cost. */
@@ -742,7 +815,8 @@ function applyGlobal(items, n) {
742
815
  ga.set(it.a, 2 * n * k);
743
816
  gb.set(it.bO, 2 * n * k);
744
817
  });
745
- const g = procrustes(ga, gb, centroid(ga), centroid(gb));
818
+ const gca = centroid(ga);
819
+ const g = procrustes(ga, gb, gca, centroid(gb));
746
820
  if (g.res >= GLOBAL_EPS) return;
747
821
  const cos = Math.cos(-g.theta);
748
822
  const sin = Math.sin(-g.theta);
@@ -764,6 +838,17 @@ function applyGlobal(items, n) {
764
838
  it.theta = g.theta;
765
839
  it.lnSigma = Math.log(g.sigma);
766
840
  it.res = nb > 1e-12 ? Math.sqrt(e2 / nb) : 0;
841
+ const s1 = Math.exp(it.lnSigma);
842
+ const c1 = Math.cos(it.theta) * s1;
843
+ const n1 = Math.sin(it.theta) * s1;
844
+ const ox = it.ca[0] - gca[0];
845
+ const oy = it.ca[1] - gca[1];
846
+ const rx = ox * c1 - oy * n1 - ox;
847
+ const ry = ox * n1 + oy * c1 - oy;
848
+ it.block = {
849
+ off: [ox, oy],
850
+ drift: [it.cb[0] - it.ca[0] - rx, it.cb[1] - it.ca[1] - ry]
851
+ };
767
852
  }
768
853
  }
769
854
  /** Builds the morph plan between two lists of sampled subpaths. The plan is
@@ -815,7 +900,8 @@ function buildPlan(srcSubs, dstSubs) {
815
900
  theta: al.theta,
816
901
  lnSigma: Math.log(al.sigma),
817
902
  res: al.res,
818
- closed: srcSubs[si].closed && dstSubs[di].closed
903
+ closed: srcSubs[si].closed && dstSubs[di].closed,
904
+ block: null
819
905
  };
820
906
  });
821
907
  if (items.length > 1) applyGlobal(items, n);
@@ -1021,34 +1107,6 @@ function resampleIcon(input, N = 64) {
1021
1107
  }));
1022
1108
  }
1023
1109
  //#endregion
1024
- //#region src/core/serialize.ts
1025
- function fmt(v) {
1026
- return String(Math.round(v * 100) / 100);
1027
- }
1028
- /** Sampled subpaths → polyline `d` attribute. `closed[k]` appends Z to
1029
- * subpath k (closed loops in flight); without flags everything is open. */
1030
- function serialize(subs, closed) {
1031
- let d = "";
1032
- for (let k = 0; k < subs.length; k++) {
1033
- const o = subs[k];
1034
- const n = o.length / 2;
1035
- d += `M${fmt(o[0])} ${fmt(o[1])}`;
1036
- for (let i = 1; i < n; i++) d += `L${fmt(o[2 * i])} ${fmt(o[2 * i + 1])}`;
1037
- if (closed?.[k]) d += "Z";
1038
- }
1039
- return d;
1040
- }
1041
- /** Cubic subpaths → canonical `d` at full precision (round-trip). */
1042
- function cubicsToPathD(paths) {
1043
- let d = "";
1044
- for (const { pts, closed } of paths) {
1045
- d += `M${pts[0]} ${pts[1]}`;
1046
- for (let i = 2; i < pts.length; i += 6) d += `C${pts[i]} ${pts[i + 1]} ${pts[i + 2]} ${pts[i + 3]} ${pts[i + 4]} ${pts[i + 5]}`;
1047
- if (closed) d += "Z";
1048
- }
1049
- return d;
1050
- }
1051
- //#endregion
1052
1110
  //#region src/core/spring.ts
1053
1111
  var Spring = class {
1054
1112
  x = 1;
@@ -1096,4 +1154,4 @@ const SPRING_PRESETS = {
1096
1154
  }
1097
1155
  };
1098
1156
  //#endregion
1099
- export { resampleIcon as a, iconToCubics as c, interpPolar as d, serialize as i, allocOutputs as l, Spring as n, resamplePath as o, cubicsToPathD as r, buildPlan as s, SPRING_PRESETS as t, interpLinear as u };
1157
+ export { buildPlan as a, cubicsToPathD as c, interpLinear as d, interpPolar as f, resamplePath as i, serialize as l, Spring as n, fitIcon as o, resampleIcon as r, iconToCubics as s, SPRING_PRESETS as t, allocOutputs as u };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "morphicons",
3
- "version": "1.0.0",
3
+ "version": "1.1.1",
4
4
  "description": "Universal morphing for stroke-based icons with spring physics — zero runtime dependencies",
5
5
  "license": "MIT",
6
6
  "author": "Guillermo (https://guillermolg.com)",
@@ -23,8 +23,11 @@
23
23
  "spring",
24
24
  "lucide",
25
25
  "lucide-react",
26
- "feather",
27
26
  "tabler",
27
+ "heroicons",
28
+ "iconoir",
29
+ "shadcn",
30
+ "feather",
28
31
  "react",
29
32
  "icon-animation"
30
33
  ],