morphicons 0.1.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/LICENSE +21 -0
- package/README.md +273 -0
- package/dist/dom.d.ts +36 -0
- package/dist/dom.js +192 -0
- package/dist/index.d.ts +60 -0
- package/dist/index.js +2 -0
- package/dist/react.d.ts +31 -0
- package/dist/react.js +114 -0
- package/dist/spring-Dbj7NCw2.js +1099 -0
- package/dist/types-JAF3s76Y.d.ts +60 -0
- package/package.json +106 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Guillermo
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<a href="https://www.morphicons.com">
|
|
3
|
+
<picture>
|
|
4
|
+
<source media="(prefers-color-scheme: dark)" srcset="assets/logo-dark.svg">
|
|
5
|
+
<img src="assets/logo-light.svg" alt="morphicons" width="96">
|
|
6
|
+
</picture>
|
|
7
|
+
</a>
|
|
8
|
+
</p>
|
|
9
|
+
|
|
10
|
+
<h1 align="center">morphicons</h1>
|
|
11
|
+
|
|
12
|
+
<p align="center">
|
|
13
|
+
<a href="https://www.morphicons.com"><strong>morphicons.com</strong></a> — live playground
|
|
14
|
+
</p>
|
|
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.
|
|
17
|
+
|
|
18
|
+
```tsx
|
|
19
|
+
import { MorphIcon } from "morphicons/react";
|
|
20
|
+
import { Menu, X } from "lucide"; // data, not components
|
|
21
|
+
|
|
22
|
+
<button onClick={() => setOpen(o => !o)} aria-expanded={open}>
|
|
23
|
+
<MorphIcon icon={open ? X : Menu} />
|
|
24
|
+
</button>
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
That's the whole thing. No wrappers, no `AnimatePresence`, no keys, no from/to pairs, no configuration. State lives outside; the animation is an implementation detail the component picks up when the prop changes.
|
|
28
|
+
|
|
29
|
+
## Why
|
|
30
|
+
|
|
31
|
+
The usual icon morphs either interpolate raw coordinates (shapes shrink and shear in transit) or require hand-declaring "rotation groups" per icon pair. morphicons solves the optimal similarity between shapes in closed form and interpolates it in its natural space: if a pair is congruent under rotation, it **rotates**; if not, it morphs in the aligned frame. arrow-right → arrow-down yields θ = 90° on its own, without anyone declaring it.
|
|
32
|
+
|
|
33
|
+
## Install
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
bun add morphicons # or npm install / pnpm add
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
ESM only. `react` is an optional peer (>= 18) — only needed for `morphicons/react`.
|
|
40
|
+
|
|
41
|
+
## Usage
|
|
42
|
+
|
|
43
|
+
### React — three modes
|
|
44
|
+
|
|
45
|
+
```tsx
|
|
46
|
+
import { MorphIcon, type MorphHandle } from "morphicons/react";
|
|
47
|
+
|
|
48
|
+
// 1. Uncontrolled (90% of uses): change the prop and morphicons animates
|
|
49
|
+
<MorphIcon icon={open ? X : Menu} spring="snappy" />
|
|
50
|
+
|
|
51
|
+
// 2. Controlled (gestures, scroll): explicit progress, no spring
|
|
52
|
+
<MorphIcon from={Menu} to={X} progress={dragProgress} />
|
|
53
|
+
|
|
54
|
+
// 3. Imperative (sequences)
|
|
55
|
+
const ref = useRef<MorphHandle>(null);
|
|
56
|
+
<MorphIcon ref={ref} icon={Menu} />
|
|
57
|
+
ref.current?.morphTo(Check); // animates
|
|
58
|
+
ref.current?.set(X); // jumps without animating
|
|
59
|
+
```
|
|
60
|
+
|
|
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
|
+
|
|
63
|
+
### Vanilla (no React)
|
|
64
|
+
|
|
65
|
+
```ts
|
|
66
|
+
import { createMorph } from "morphicons/dom";
|
|
67
|
+
|
|
68
|
+
const m = createMorph(pathEl, Menu); // pathEl: any object with setAttribute
|
|
69
|
+
m.morphTo(X, "snappy"); // preset or { stiffness, damping }
|
|
70
|
+
m.set(Check); // jump without animating (canonical d)
|
|
71
|
+
m.seek(X, 0.4); // morph frozen at t — scrubbing, gestures
|
|
72
|
+
m.progress = 0.4; // sugar: seek on the active target
|
|
73
|
+
m.destroy();
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Truly interruptible: a `morphTo` mid-flight re-plans from the current intermediate shape while preserving the spring's velocity — click spam never jumps. A single global `requestAnimationFrame` drives all instances.
|
|
77
|
+
|
|
78
|
+
### Pure core (no DOM)
|
|
79
|
+
|
|
80
|
+
```ts
|
|
81
|
+
import { resampleIcon, buildPlan, interpPolar, allocOutputs, serialize } from "morphicons";
|
|
82
|
+
|
|
83
|
+
const plan = buildPlan(resampleIcon(Menu), resampleIcon(X)); // cacheable
|
|
84
|
+
const out = allocOutputs(plan);
|
|
85
|
+
interpPolar(plan, 0.5, out); // t ∈ [0, 1]
|
|
86
|
+
const d = serialize(out, plan.items.map(it => it.closed)); // `d` attribute
|
|
87
|
+
```
|
|
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.
|
|
90
|
+
|
|
91
|
+
## Icon library compatibility
|
|
92
|
+
|
|
93
|
+
morphicons has no per-library adapters — any icon set that meets these requirements works out of the box:
|
|
94
|
+
|
|
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
|
+
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.
|
|
98
|
+
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
|
+
|
|
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`.
|
|
101
|
+
|
|
102
|
+
## Architecture
|
|
103
|
+
|
|
104
|
+
```
|
|
105
|
+
┌─────────────────────────────────────────────┐
|
|
106
|
+
│ bindings react │ thin: ref + createMorph
|
|
107
|
+
├─────────────────────────────────────────────┤
|
|
108
|
+
│ drivers dom (setAttribute + rAF) │ singleton scheduler
|
|
109
|
+
├─────────────────────────────────────────────┤
|
|
110
|
+
│ core parse → normalize → resample │ pure functions,
|
|
111
|
+
│ → match → align → plan │ NO DOM, produce
|
|
112
|
+
│ → interpolate → serialize │ `d` strings and numbers
|
|
113
|
+
│ + spring │
|
|
114
|
+
└─────────────────────────────────────────────┘
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
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`.
|
|
118
|
+
|
|
119
|
+
The plan is the central artifact:
|
|
120
|
+
|
|
121
|
+
```
|
|
122
|
+
icon A ─┐
|
|
123
|
+
├→ normalize → resample → match → align → PLAN ─→ interpolate(t) → serialize → d
|
|
124
|
+
icon B ─┘ ↑
|
|
125
|
+
cacheable, serializable
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
`plan()` accepts any list of sampled subpaths — not just canonical icons — which is what makes interruptions free: the currently rendered buffers are a valid morph source.
|
|
129
|
+
|
|
130
|
+
## How the math works
|
|
131
|
+
|
|
132
|
+
### 1. Normalization — everything to cubic Béziers
|
|
133
|
+
|
|
134
|
+
Every SVG primitive is reduced to cubic segments:
|
|
135
|
+
|
|
136
|
+
- **Line** → cubic with collinear controls: `C₁ = P₀ + ⅓(P₃−P₀)`, `C₂ = P₀ + ⅔(P₃−P₀)`.
|
|
137
|
+
- **Quadratic** → exact degree elevation: `C₁ = Q₀ + ⅔(Q₁−Q₀)`, `C₂ = Q₂ + ⅔(Q₁−Q₂)`.
|
|
138
|
+
- **Circle** → 4 cubics with offset `k = r·(4/3)·tan(π/8) ≈ 0.5523·r`.
|
|
139
|
+
- **Arc `A`** → center parametrization (SVG spec, appendix F.6), sliced into arcs ≤ 90°, each slice to a cubic with `α = (4/3)·tan(Δθ/4)`.
|
|
140
|
+
- **rect/polyline/polygon** → lines (rounded rects: lines + quarter ellipses).
|
|
141
|
+
|
|
142
|
+
The `d` parser handles absolute/relative commands, H/V/S/T shorthands, packed arc flags and scientific notation. Result: icon = list of subpaths; subpath = chain of cubics + closed/open flag.
|
|
143
|
+
|
|
144
|
+
### 2. Resampling — arc length with anchored corners
|
|
145
|
+
|
|
146
|
+
Instead of fighting Bézier structures of different cardinality, every subpath is sampled at **N points equidistant by arc length** (N = 64). Every subpath becomes a `Float64Array(2N)` and the structural problem disappears: correspondence is index to index.
|
|
147
|
+
|
|
148
|
+
The arc length of a cubic has no closed form; `|B′(t)|` is integrated with 8-point Gauss-Legendre quadrature per segment.
|
|
149
|
+
|
|
150
|
+
The refinement that drives quality: **corner detection** (tangent discontinuity above an angular threshold) with corners anchored as exact sample points. Remaining points are apportioned by arc length between corners — integer apportionment by largest remainder, minimum 1 interval per run, summing exactly to N−1. Effects:
|
|
151
|
+
|
|
152
|
+
- At rest the shape is **exact** (a check's vertex is a sample, not an approximation).
|
|
153
|
+
- In transit, source corners flatten smoothly and target corners sharpen — the desired behavior. Without anchoring, a morphing check visibly rounds its corners.
|
|
154
|
+
|
|
155
|
+
Closed paths anchor **only corners**, not the arbitrary `M` start point — sampling is intrinsic to the shape, so two congruent loops with different start points produce the same sample set (modulo index rotation, resolved by the circular correspondence below).
|
|
156
|
+
|
|
157
|
+
### 3. Correspondence
|
|
158
|
+
|
|
159
|
+
Three subproblems:
|
|
160
|
+
|
|
161
|
+
- **Orientation.** Both traversal directions of B are tried and scored (see the tie-break below). A hamburger line folding to the correct diagonal depends on this.
|
|
162
|
+
- **Start point (closed paths).** A closed contour has a circular degree of freedom: which index is "first". The N circular offsets × 2 directions are tried and the best post-alignment score wins. O(N²) brute force with N = 64 is ~4K operations per pair — negligible.
|
|
163
|
+
- **Subpath matching.** With p subpaths in A and q in B, pairing cost is `dist(centroids) + 0.35·|ΔL|` (arc lengths). Equal counts use the minimum-cost permutation (exhaustive with pruning up to 8 subpaths, greedy above); unequal counts use a **surjective** assignment from the large side to the small one, so no subpath ever appears or disappears out of nowhere. Leftovers are **not collapsed to a point**: the nearest subpath is **duplicated** in place and the copies separate in flight — it reads as cell division, far more natural than growing from nothing. At rest, overlapping duplicates with the same solid stroke are invisible, and the canonical snap on settle resets the count.
|
|
164
|
+
|
|
165
|
+
### 4. Alignment — 2D Procrustes
|
|
166
|
+
|
|
167
|
+
Before interpolating, the **optimal similarity** (rotation θ, scale σ, translation) mapping A onto B is computed by minimizing `Σ|σ·R(θ)·(aᵢ−c_A) − (bᵢ−c_B)|²`. In 2D it has a closed form, no SVD:
|
|
168
|
+
|
|
169
|
+
```
|
|
170
|
+
S_xx = Σ aₓbₓ S_xy = Σ aₓbᵧ (centered clouds)
|
|
171
|
+
S_yx = Σ aᵧbₓ S_yy = Σ aᵧbᵧ
|
|
172
|
+
|
|
173
|
+
θ* = atan2(S_xy − S_yx, S_xx + S_yy)
|
|
174
|
+
σ* = [cos θ*·(S_xx+S_yy) + sin θ*·(S_xy−S_yx)] / Σ‖aᵢ‖²
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
The normalized RMS **residual** after alignment (`res = √(Σ|σRa−b|² / Σ‖b‖²)`) is the shape metric: ≈ 0 means A and B are the same shape rotated/scaled. This turns rotation groups into an **emergent property**: arrow-right → arrow-down gives residual ~0 with θ = 90° and the system chooses pure rotation on its own.
|
|
178
|
+
|
|
179
|
+
Procrustes runs **per subpath** (each piece rotates around its own centroid, which translates independently) — this gives local, organic motion: a hamburger *folds* instead of spinning as a block. A **global hybrid** pass then runs Procrustes over the concatenated clouds: if the global residual is ≈ 0 (< 5e-3) the whole icon is congruent and every subpath shares the same (θ, σ), so e.g. both subpaths of an arrow spin the same way.
|
|
180
|
+
|
|
181
|
+
#### Minimal-rotation tie-break
|
|
182
|
+
|
|
183
|
+
For shapes symmetric under inversion (a straight line), both traversal orientations give residual 0 — indistinguishable to Procrustes — but produce different rotations. Naive tie-breaking can pick θ = 135° when the inverted orientation gives θ = −45° with the same end result. Each orientation is scored with:
|
|
184
|
+
|
|
185
|
+
```
|
|
186
|
+
score = res + λ·|θ|/π (λ = 0.05)
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
Deformation is minimized first and, at comparable residuals, the shortest rotation wins. λ is small enough that a notably worse shape never wins just by rotating less. This is the kind of detail the math doesn't see but the eye does.
|
|
190
|
+
|
|
191
|
+
### 5. Polar interpolation
|
|
192
|
+
|
|
193
|
+
Raw coordinate lerp **collapses rotations**: points take the chord, the shape shrinks and shears in transit. The fix: decompose the motion into similarity + residual and interpolate each part in its natural space.
|
|
194
|
+
|
|
195
|
+
Let `aᶜᵢ = aᵢ − c_A` and `b̃ᵢ = R(−θ*)·(bᵢ − c_B)/σ*` (B brought into A's frame). Then:
|
|
196
|
+
|
|
197
|
+
```
|
|
198
|
+
P(t) = c(t) + σ*ᵗ · R(t·θ*) · [(1−t)·aᶜᵢ + t·b̃ᵢ]
|
|
199
|
+
|
|
200
|
+
c(t) = lerp(c_A, c_B, t) linear translation
|
|
201
|
+
t·θ* = linear angle (θ* is already in (−π, π], short path)
|
|
202
|
+
σ*ᵗ = exp(t·ln σ*) log-linear scale (geodesic in ℝ⁺)
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
Exact at both endpoints (max error ~3.6e-15 in tests). Properties:
|
|
206
|
+
|
|
207
|
+
- If B is A rotated (residual 0), the bracket is constant and the motion is **pure rotation**.
|
|
208
|
+
- If there is no rotation, it's a clean coordinate morph.
|
|
209
|
+
- Mixed cases do both simultaneously and continuously.
|
|
210
|
+
- With spring overshoot (t > 1) the formula **extrapolates** naturally: rotation and scale overshoot slightly and come back — free juice.
|
|
211
|
+
|
|
212
|
+
### 6. Serialization and canonical snap
|
|
213
|
+
|
|
214
|
+
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.
|
|
215
|
+
|
|
216
|
+
### 7. The spring
|
|
217
|
+
|
|
218
|
+
Damped harmonic oscillator over progress `x: 0 → 1`:
|
|
219
|
+
|
|
220
|
+
```
|
|
221
|
+
ẍ = k·(1 − x) − c·ẋ
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
Integrated with **semi-implicit Euler** at h = 1/240 s substeps (stable up to ω·h ≈ 2; with k = 420, ω ≈ 20.5 — ample margin). ~25 lines, no animation library needed.
|
|
225
|
+
|
|
226
|
+
| preset | k | c | ζ = c/(2√k) | character |
|
|
227
|
+
|--------|-----|-----|------|-----------|
|
|
228
|
+
| smooth | 170 | 26 | 1.00 | critically damped, no overshoot |
|
|
229
|
+
| snappy | 420 | 30 | 0.73 | fast, subtle overshoot |
|
|
230
|
+
| bouncy | 300 | 14 | 0.40 | playful |
|
|
231
|
+
|
|
232
|
+
**Interruptions**: when a `morphTo` arrives mid-flight, the plan is rebuilt from the current intermediate shape (the rendered buffers are already N points per subpath — they serve directly as source), `x` resets to 0 and **velocity is preserved** (clamped to ±14). Perceived motion is continuous; tap spam feels alive, never jumps. The spring settles and the rAF stops when `|1−x| < 0.001 ∧ |v| < 0.02`.
|
|
233
|
+
|
|
234
|
+
## Performance
|
|
235
|
+
|
|
236
|
+
- `plan(A, B)` complete (resample + match + Procrustes × orientations): **sub-millisecond** with N = 64 — measured 0.01 ms (open paths) to 0.06 ms (closed, with circular search); 0.42 ms for a pathological 12-loops case.
|
|
237
|
+
- **Zero numeric allocation per frame**: interpolators write into the plan's preallocated `Float64Array`s. Only the `d` string is allocated.
|
|
238
|
+
- **Caches by reference**: `normalize(icon)` in a WeakMap keyed by the IconNode (Lucide imports are stable references → no string keys, GC-friendly). `plan(A, B)` in a nested WeakMap; plans from interruptions are never cached.
|
|
239
|
+
- **One global rAF** (singleton scheduler): active instances are iterated, settled ones unregister. A hundred icons morphing at once = one loop.
|
|
240
|
+
|
|
241
|
+
## Size
|
|
242
|
+
|
|
243
|
+
CI budget (size-limit, gzip) as an anti-regression tripwire — gates carry ~10% headroom over what's measured; if a real capability needs more, the number is renegotiated, but growing unnoticed is not accepted:
|
|
244
|
+
|
|
245
|
+
| entry | measured | gate |
|
|
246
|
+
|---|---|---|
|
|
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 |
|
|
250
|
+
|
|
251
|
+
## Playground
|
|
252
|
+
|
|
253
|
+
```bash
|
|
254
|
+
bun run play # → http://localhost:3000
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
38 real icons from Lucide (IconNode imported from the package), Feather and Tabler; per-pair readout of the detected similarity (θ, σ, residual), polar/linear modes for comparison, spring presets and a t scrubber. Feather/Tabler data is extracted from the npm packages with `bun playground/extract-vendor-icons.mjs`.
|
|
258
|
+
|
|
259
|
+
## Development
|
|
260
|
+
|
|
261
|
+
```bash
|
|
262
|
+
bun test # 74 tests / ~13,400 asserts
|
|
263
|
+
bun run typecheck # strict ×3: core+dom without lib DOM, playground, react
|
|
264
|
+
bun run format # biome
|
|
265
|
+
bun run build # tsdown → dist/ (entries index, dom, react)
|
|
266
|
+
bun run size # size gates
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
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).
|
|
270
|
+
|
|
271
|
+
---
|
|
272
|
+
|
|
273
|
+
MIT © [Guillermo](https://guillermolg.com) — made by [guillermolg.com](https://guillermolg.com)
|
package/dist/dom.d.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { c as SpringPreset, n as IconInput } from "./types-JAF3s76Y.js";
|
|
2
|
+
//#region src/dom/index.d.ts
|
|
3
|
+
/** Target element, structurally typed: any object with setAttribute works
|
|
4
|
+
* (a real SVGPathElement, or a fake in tests). */
|
|
5
|
+
interface PathEl {
|
|
6
|
+
setAttribute(name: string, value: string): void;
|
|
7
|
+
}
|
|
8
|
+
/** Morph physics: named preset or custom spring. */
|
|
9
|
+
interface MorphOptions {
|
|
10
|
+
stiffness?: number;
|
|
11
|
+
damping?: number;
|
|
12
|
+
}
|
|
13
|
+
interface Morph {
|
|
14
|
+
/** Animates toward the icon with spring physics. Interruptible: mid-flight
|
|
15
|
+
* it re-plans from the intermediate shape while preserving velocity. */
|
|
16
|
+
morphTo(icon: IconInput, spring?: SpringPreset | MorphOptions): void;
|
|
17
|
+
/** Jumps to the icon without animating (canonical d, no frames). */
|
|
18
|
+
set(icon: IconInput): void;
|
|
19
|
+
/** Renders the morph toward `icon` frozen at t (no spring) — the
|
|
20
|
+
* controlled-mode primitive: scrubbing, gestures, scroll. A later
|
|
21
|
+
* morphTo takes off from that intermediate shape. */
|
|
22
|
+
seek(icon: IconInput, t: number): void;
|
|
23
|
+
/** Current progress (t of the last frame; 1 at rest). Assigning it is
|
|
24
|
+
* equivalent to seek(target, t) on the active target. */
|
|
25
|
+
progress: number;
|
|
26
|
+
/** Unregisters the instance; later calls are no-ops. */
|
|
27
|
+
destroy(): void;
|
|
28
|
+
}
|
|
29
|
+
/** Canonical `d` of an icon: the input string verbatim, or the real cubics
|
|
30
|
+
* at full precision (the at-rest snap). Exported because it is what a
|
|
31
|
+
* binding renders at SSR/rest before any runtime exists. */
|
|
32
|
+
declare function canonicalD(icon: IconInput): string;
|
|
33
|
+
/** Creates the morph instance over a `<path>` and paints the initial icon. */
|
|
34
|
+
declare function createMorph(el: PathEl, icon: IconInput): Morph;
|
|
35
|
+
//#endregion
|
|
36
|
+
export { Morph, MorphOptions, PathEl, canonicalD, createMorph };
|
package/dist/dom.js
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
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";
|
|
2
|
+
//#region src/dom/index.ts
|
|
3
|
+
const tickers = /* @__PURE__ */ new Set();
|
|
4
|
+
let rafId = 0;
|
|
5
|
+
let last = -1;
|
|
6
|
+
function loop(ts) {
|
|
7
|
+
const dt = last < 0 ? 0 : Math.min(Math.max((ts - last) / 1e3, 0), .1);
|
|
8
|
+
last = ts;
|
|
9
|
+
for (const tick of [...tickers]) tick(dt);
|
|
10
|
+
if (tickers.size > 0) rafId = requestAnimationFrame(loop);
|
|
11
|
+
else {
|
|
12
|
+
rafId = 0;
|
|
13
|
+
last = -1;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
function addTicker(tick) {
|
|
17
|
+
tickers.add(tick);
|
|
18
|
+
if (rafId === 0) {
|
|
19
|
+
last = -1;
|
|
20
|
+
rafId = requestAnimationFrame(loop);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
function removeTicker(tick) {
|
|
24
|
+
tickers.delete(tick);
|
|
25
|
+
if (tickers.size === 0 && rafId !== 0) {
|
|
26
|
+
cancelAnimationFrame(rafId);
|
|
27
|
+
rafId = 0;
|
|
28
|
+
last = -1;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
const samples = /* @__PURE__ */ new WeakMap();
|
|
32
|
+
const canon = /* @__PURE__ */ new WeakMap();
|
|
33
|
+
const plans = /* @__PURE__ */ new WeakMap();
|
|
34
|
+
function sampledOf(icon) {
|
|
35
|
+
if (typeof icon === "string") return resampleIcon(icon);
|
|
36
|
+
let s = samples.get(icon);
|
|
37
|
+
if (!s) {
|
|
38
|
+
s = resampleIcon(icon);
|
|
39
|
+
samples.set(icon, s);
|
|
40
|
+
}
|
|
41
|
+
return s;
|
|
42
|
+
}
|
|
43
|
+
/** Canonical `d` of an icon: the input string verbatim, or the real cubics
|
|
44
|
+
* at full precision (the at-rest snap). Exported because it is what a
|
|
45
|
+
* binding renders at SSR/rest before any runtime exists. */
|
|
46
|
+
function canonicalD(icon) {
|
|
47
|
+
if (typeof icon === "string") return icon;
|
|
48
|
+
let d = canon.get(icon);
|
|
49
|
+
if (!d) {
|
|
50
|
+
d = cubicsToPathD(iconToCubics(icon));
|
|
51
|
+
canon.set(icon, d);
|
|
52
|
+
}
|
|
53
|
+
return d;
|
|
54
|
+
}
|
|
55
|
+
function planBetween(src, dst) {
|
|
56
|
+
if (typeof src === "string" || typeof dst === "string") return buildPlan(sampledOf(src), sampledOf(dst));
|
|
57
|
+
let inner = plans.get(src);
|
|
58
|
+
if (!inner) {
|
|
59
|
+
inner = /* @__PURE__ */ new WeakMap();
|
|
60
|
+
plans.set(src, inner);
|
|
61
|
+
}
|
|
62
|
+
let p = inner.get(dst);
|
|
63
|
+
if (!p) {
|
|
64
|
+
p = buildPlan(sampledOf(src), sampledOf(dst));
|
|
65
|
+
inner.set(dst, p);
|
|
66
|
+
}
|
|
67
|
+
return p;
|
|
68
|
+
}
|
|
69
|
+
function resolveSpring(s) {
|
|
70
|
+
if (typeof s === "string") return SPRING_PRESETS[s];
|
|
71
|
+
const d = SPRING_PRESETS.snappy;
|
|
72
|
+
return {
|
|
73
|
+
k: s?.stiffness ?? d.k,
|
|
74
|
+
c: s?.damping ?? d.c
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
/** Creates the morph instance over a `<path>` and paints the initial icon. */
|
|
78
|
+
function createMorph(el, icon) {
|
|
79
|
+
const spring = new Spring();
|
|
80
|
+
let target = icon;
|
|
81
|
+
let rest = true;
|
|
82
|
+
let plan = null;
|
|
83
|
+
let out = null;
|
|
84
|
+
let closed = null;
|
|
85
|
+
let t = 1;
|
|
86
|
+
let flying = false;
|
|
87
|
+
let dead = false;
|
|
88
|
+
el.setAttribute("d", canonicalD(icon));
|
|
89
|
+
const render = (tt) => {
|
|
90
|
+
const p = plan;
|
|
91
|
+
const o = out;
|
|
92
|
+
const cl = closed;
|
|
93
|
+
if (!p || !o || !cl) return;
|
|
94
|
+
t = tt;
|
|
95
|
+
interpPolar(p, tt, o);
|
|
96
|
+
el.setAttribute("d", serialize(o, cl));
|
|
97
|
+
};
|
|
98
|
+
const stop = () => {
|
|
99
|
+
if (!flying) return;
|
|
100
|
+
flying = false;
|
|
101
|
+
removeTicker(tick);
|
|
102
|
+
};
|
|
103
|
+
const tick = (dt) => {
|
|
104
|
+
const settled = spring.step(dt);
|
|
105
|
+
render(spring.x);
|
|
106
|
+
if (settled) {
|
|
107
|
+
stop();
|
|
108
|
+
settle();
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
const settle = () => {
|
|
112
|
+
rest = true;
|
|
113
|
+
plan = null;
|
|
114
|
+
out = null;
|
|
115
|
+
closed = null;
|
|
116
|
+
t = 1;
|
|
117
|
+
spring.x = 1;
|
|
118
|
+
spring.v = 0;
|
|
119
|
+
el.setAttribute("d", canonicalD(target));
|
|
120
|
+
};
|
|
121
|
+
/** The current shape as plan source: the at-rest icon, or the rendered
|
|
122
|
+
* buffers (already N points per subpath). */
|
|
123
|
+
const snapshot = () => {
|
|
124
|
+
const p = plan;
|
|
125
|
+
const o = out;
|
|
126
|
+
if (rest || !p || !o) return sampledOf(target);
|
|
127
|
+
return o.map((buf, k) => ({
|
|
128
|
+
pts: Float64Array.from(buf),
|
|
129
|
+
closed: p.items[k].closed
|
|
130
|
+
}));
|
|
131
|
+
};
|
|
132
|
+
const retarget = (icon) => {
|
|
133
|
+
plan = rest ? planBetween(target, icon) : buildPlan(snapshot(), sampledOf(icon));
|
|
134
|
+
out = allocOutputs(plan);
|
|
135
|
+
closed = plan.items.map((it) => it.closed);
|
|
136
|
+
target = icon;
|
|
137
|
+
rest = false;
|
|
138
|
+
};
|
|
139
|
+
const setNow = (icon) => {
|
|
140
|
+
stop();
|
|
141
|
+
target = icon;
|
|
142
|
+
settle();
|
|
143
|
+
};
|
|
144
|
+
const seek = (icon, tt) => {
|
|
145
|
+
if (dead) return;
|
|
146
|
+
const reuse = !rest && plan !== null && icon === target;
|
|
147
|
+
stop();
|
|
148
|
+
spring.v = 0;
|
|
149
|
+
if (!reuse) retarget(icon);
|
|
150
|
+
render(tt);
|
|
151
|
+
};
|
|
152
|
+
return {
|
|
153
|
+
morphTo(icon, sp) {
|
|
154
|
+
if (dead) return;
|
|
155
|
+
if (icon === target && (rest || flying)) return;
|
|
156
|
+
if (typeof matchMedia !== "undefined") {
|
|
157
|
+
if (matchMedia?.("(prefers-reduced-motion: reduce)").matches) {
|
|
158
|
+
setNow(icon);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
const { k, c } = resolveSpring(sp);
|
|
163
|
+
spring.config(k, c);
|
|
164
|
+
retarget(icon);
|
|
165
|
+
spring.start();
|
|
166
|
+
if (!flying) {
|
|
167
|
+
flying = true;
|
|
168
|
+
addTicker(tick);
|
|
169
|
+
}
|
|
170
|
+
},
|
|
171
|
+
set(icon) {
|
|
172
|
+
if (dead) return;
|
|
173
|
+
setNow(icon);
|
|
174
|
+
},
|
|
175
|
+
seek,
|
|
176
|
+
get progress() {
|
|
177
|
+
return rest ? 1 : t;
|
|
178
|
+
},
|
|
179
|
+
set progress(v) {
|
|
180
|
+
if (!dead) seek(target, v);
|
|
181
|
+
},
|
|
182
|
+
destroy() {
|
|
183
|
+
stop();
|
|
184
|
+
dead = true;
|
|
185
|
+
plan = null;
|
|
186
|
+
out = null;
|
|
187
|
+
closed = null;
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
//#endregion
|
|
192
|
+
export { canonicalD, createMorph };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { a as Sampled, c as SpringPreset, i as IconNodeAttrs, n as IconInput, o as SPRING_PRESETS, r as IconNode, s as Spring, t as CubicPath } from "./types-JAF3s76Y.js";
|
|
2
|
+
//#region src/core/plan.d.ts
|
|
3
|
+
interface PlanItem {
|
|
4
|
+
/** Points of A with the chosen correspondence (if A is a closed loop it
|
|
5
|
+
* may come circularly re-indexed: same points, different cut). */
|
|
6
|
+
a: Float64Array;
|
|
7
|
+
/** A centered on its centroid. */
|
|
8
|
+
aC: Float64Array;
|
|
9
|
+
/** B brought into A's frame: R(−θ)·(b − c_B)/σ. */
|
|
10
|
+
bT: Float64Array;
|
|
11
|
+
/** B oriented, raw (for linear mode and for exact t=1). */
|
|
12
|
+
bO: Float64Array;
|
|
13
|
+
ca: readonly [number, number];
|
|
14
|
+
cb: readonly [number, number];
|
|
15
|
+
theta: number;
|
|
16
|
+
lnSigma: number;
|
|
17
|
+
res: number;
|
|
18
|
+
/** true if both endpoints are closed loops: the subpath flies with Z.
|
|
19
|
+
* Closed → open flies open: the loop opens at the chosen cut. */
|
|
20
|
+
closed: boolean;
|
|
21
|
+
}
|
|
22
|
+
interface MorphPlan {
|
|
23
|
+
items: PlanItem[];
|
|
24
|
+
n: number;
|
|
25
|
+
}
|
|
26
|
+
/** Builds the morph plan between two lists of sampled subpaths. The plan is
|
|
27
|
+
* cacheable and serializable; it accepts any list — including intermediate
|
|
28
|
+
* shapes (interruptions). */
|
|
29
|
+
declare function buildPlan(srcSubs: readonly Sampled[], dstSubs: readonly Sampled[]): MorphPlan;
|
|
30
|
+
//#endregion
|
|
31
|
+
//#region src/core/interpolate.d.ts
|
|
32
|
+
/** Preallocated output buffers for a plan (zero allocation per frame). */
|
|
33
|
+
declare function allocOutputs(plan: MorphPlan): Float64Array[];
|
|
34
|
+
declare function interpPolar(plan: MorphPlan, t: number, out: Float64Array[]): void;
|
|
35
|
+
/** Raw coordinate lerp (same correspondence, no decomposition). */
|
|
36
|
+
declare function interpLinear(plan: MorphPlan, t: number, out: Float64Array[]): void;
|
|
37
|
+
//#endregion
|
|
38
|
+
//#region src/core/normalize.d.ts
|
|
39
|
+
/** Icon (IconNode or `d` string) → list of cubic subpaths. */
|
|
40
|
+
declare function iconToCubics(input: IconInput): CubicPath[];
|
|
41
|
+
//#endregion
|
|
42
|
+
//#region src/core/resample.d.ts
|
|
43
|
+
/** Samples a cubic subpath at N points equidistant by arc length, anchoring
|
|
44
|
+
* corners and endpoints as exact samples. Returns Float64Array(2N). Closed
|
|
45
|
+
* paths distribute N intervals around the loop (without duplicating the
|
|
46
|
+
* first point); the circular start-point freedom is resolved by the plan's
|
|
47
|
+
* circular correspondence. */
|
|
48
|
+
declare function resamplePath(path: CubicPath, N?: number, cornerThreshold?: number): Float64Array;
|
|
49
|
+
/** Full input pipeline: icon → cubics → sampled subpaths with their
|
|
50
|
+
* topology (the plan needs to know which subpaths are closed loops). */
|
|
51
|
+
declare function resampleIcon(input: IconInput, N?: number): Sampled[];
|
|
52
|
+
//#endregion
|
|
53
|
+
//#region src/core/serialize.d.ts
|
|
54
|
+
/** Sampled subpaths → polyline `d` attribute. `closed[k]` appends Z to
|
|
55
|
+
* subpath k (closed loops in flight); without flags everything is open. */
|
|
56
|
+
declare function serialize(subs: readonly Float64Array[], closed?: readonly boolean[]): string;
|
|
57
|
+
/** Cubic subpaths → canonical `d` at full precision (round-trip). */
|
|
58
|
+
declare function cubicsToPathD(paths: readonly CubicPath[]): string;
|
|
59
|
+
//#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 };
|
package/dist/index.js
ADDED
|
@@ -0,0 +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 };
|
package/dist/react.d.ts
ADDED
|
@@ -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";
|
|
4
|
+
//#region src/react/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<SVGSVGElement>, "from" | "to" | "ref"> {
|
|
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-react: stroke width does not scale with `size`. */
|
|
25
|
+
absoluteStrokeWidth?: boolean;
|
|
26
|
+
/** Accessibility: with label → role="img" + <title>; 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 };
|