morphico 0.4.0 → 0.5.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.
Files changed (72) hide show
  1. package/README.md +62 -45
  2. package/dist/esm/all.js +50 -0
  3. package/dist/esm/clip.js +283 -0
  4. package/dist/esm/engine.js +25 -0
  5. package/dist/esm/face.js +153 -0
  6. package/dist/esm/geometry.js +459 -0
  7. package/dist/esm/icon.js +41 -0
  8. package/dist/esm/index.js +50 -0
  9. package/dist/esm/morph.js +107 -0
  10. package/dist/esm/roundness.js +37 -0
  11. package/dist/esm/sets/battery.js +87 -0
  12. package/dist/esm/sets/bell.js +101 -0
  13. package/dist/esm/sets/characters.js +43 -0
  14. package/dist/esm/sets/check-mark.js +74 -0
  15. package/dist/esm/sets/checkbox.js +91 -0
  16. package/dist/esm/sets/chevron.js +70 -0
  17. package/dist/esm/sets/condensed.js +137 -0
  18. package/dist/esm/sets/copy.js +87 -0
  19. package/dist/esm/sets/eye.js +105 -0
  20. package/dist/esm/sets/faces.js +482 -0
  21. package/dist/esm/sets/folder.js +112 -0
  22. package/dist/esm/sets/handwritten.js +136 -0
  23. package/dist/esm/sets/lock.js +75 -0
  24. package/dist/esm/sets/lucide.js +66 -0
  25. package/dist/esm/sets/media.js +109 -0
  26. package/dist/esm/sets/menu.js +84 -0
  27. package/dist/esm/sets/mic.js +102 -0
  28. package/dist/esm/sets/radio.js +84 -0
  29. package/dist/esm/sets/round.js +244 -0
  30. package/dist/esm/sets/shapes.js +455 -0
  31. package/dist/esm/sets/slim.js +148 -0
  32. package/dist/esm/sets/square.js +149 -0
  33. package/dist/esm/sets/theme.js +107 -0
  34. package/dist/esm/sets/toggle.js +73 -0
  35. package/dist/esm/sets/volume.js +89 -0
  36. package/dist/esm/sets/weather.js +691 -0
  37. package/dist/esm/sets/wifi.js +68 -0
  38. package/dist/esm/svg.js +31 -0
  39. package/dist/esm/text.js +88 -0
  40. package/dist/esm/types.js +3 -0
  41. package/dist/morphico.js +3764 -3750
  42. package/dist/types/{iconSets.d.ts → all.d.ts} +0 -2
  43. package/dist/types/clip.d.ts +4 -11
  44. package/dist/types/face.d.ts +2 -6
  45. package/dist/types/icon.d.ts +4 -9
  46. package/dist/types/index.d.ts +1 -3
  47. package/dist/types/roundness.d.ts +8 -0
  48. package/dist/types/sets/battery.d.ts +1 -1
  49. package/dist/types/sets/bell.d.ts +1 -1
  50. package/dist/types/sets/checkbox.d.ts +1 -1
  51. package/dist/types/sets/chevron.d.ts +1 -1
  52. package/dist/types/sets/condensed.d.ts +1 -1
  53. package/dist/types/sets/copy.d.ts +1 -1
  54. package/dist/types/sets/eye.d.ts +1 -1
  55. package/dist/types/sets/folder.d.ts +1 -1
  56. package/dist/types/sets/handwritten.d.ts +1 -1
  57. package/dist/types/sets/lock.d.ts +1 -1
  58. package/dist/types/sets/media.d.ts +1 -1
  59. package/dist/types/sets/menu.d.ts +1 -1
  60. package/dist/types/sets/mic.d.ts +1 -1
  61. package/dist/types/sets/radio.d.ts +1 -1
  62. package/dist/types/sets/round.d.ts +1 -1
  63. package/dist/types/sets/slim.d.ts +1 -1
  64. package/dist/types/sets/square.d.ts +1 -1
  65. package/dist/types/sets/theme.d.ts +1 -1
  66. package/dist/types/sets/toggle.d.ts +1 -1
  67. package/dist/types/sets/volume.d.ts +1 -1
  68. package/dist/types/sets/weather.d.ts +1 -1
  69. package/dist/types/sets/wifi.d.ts +1 -1
  70. package/dist/types/text.d.ts +8 -13
  71. package/package.json +95 -3
  72. package/dist/morphico.mjs +0 -4642
package/README.md CHANGED
@@ -11,64 +11,85 @@ npm install morphico
11
11
  ```
12
12
 
13
13
  ```ts
14
- import { icon, text } from 'morphico';
14
+ import { icon, text, clip, face } from 'morphico';
15
+ import { digitsRound } from 'morphico/sets/round'; // every set is its own import
16
+ import { iconsWeather } from 'morphico/sets/weather';
15
17
  ```
16
18
 
17
- Or from a CDN, as a classic script with the global `morphico`:
19
+ Every set is a separate file, so a bundler (Vite, webpack, esbuild, Rollup …) only ships the sets and functions a
20
+ page uses — e.g. `icon()` with one icon set is about 13–21 KB gzipped, everything about 63 KB. `clip()` and `face()`
21
+ bring their own shapes / expressions. To pick sets by name at run time, `import { iconSets } from 'morphico/all'`
22
+ (`iconSets.weather` …; this imports every set).
23
+
24
+ Or from a CDN, as a classic script with the global `morphico` — one file with everything, sets by name in
25
+ `morphico.iconSets`:
18
26
 
19
27
  ```html
20
28
  <script src="https://cdn.jsdelivr.net/npm/morphico"></script>
21
29
  <script>
22
- const { icon, text, clip } = morphico;
30
+ const { icon, text, clip, face, iconSets } = morphico;
31
+ icon(document.querySelector('#weather'), 'sunny', { set: iconSets.weather });
23
32
  </script>
24
33
  ```
25
34
 
26
- ## One icon: `icon(element, options)`
35
+ Every layer is created the same way — `layer(element, init, options)`: the element to draw into, what it shows
36
+ first, and the settings — and changes with `to()`, which returns a promise. `destroy()` removes it.
37
+
38
+ ## One icon: `icon(element, init, options)`
27
39
 
28
40
  ```ts
29
- const digit = icon(document.querySelector('#digit'), { state: '4' });
41
+ import { icon } from 'morphico';
42
+ import { digitsRound } from 'morphico/sets/round';
43
+
44
+ const digit = icon(document.querySelector('#digit'), '4', { set: digitsRound });
30
45
 
31
46
  await digit.to('7'); // the promise resolves when the morph ends
32
47
  digit.to('8', { onEnd: (result) => {} }); // or pass a callback
33
48
  digit.destroy(); // stops and removes it (e.g. when a component unmounts)
34
49
  ```
35
50
 
36
- States of the digit sets: `'0'`…`'9'`, `'-'` `'/'` `'.'` `','` `':'`, and `' '` (empty: nothing drawn, shapes grow
51
+ Digit sets: `digitsRound` (`morphico/sets/round`), `digitsSquare` (`/square`), `digitsCondensed` (`/condensed`),
52
+ `digitsSlim` (`/slim`), `digitsHandwritten` (`/handwritten`). Their states: `'0'`…`'9'`, `'-'` `'/'` `'.'` `','` `':'`, and `' '` (empty: nothing drawn, shapes grow
37
53
  out of it and fade). A digit morphing into `-` `/` `.` `,` is pressed flat onto it; into `:` it splits into the two dots.
38
54
 
39
55
  Icon sets (based on [Lucide](https://lucide.dev)):
40
56
 
41
- | Set | States |
57
+ | Import from `morphico/sets/…` | States |
42
58
  | --- | --- |
43
- | `media` | `play`, `pause`, `stop` |
44
- | `menu` | `menu` (☰), `close` (✕), `back` (←) |
45
- | `checkbox` | `unchecked` (☐), `checked` (☑), `checked-big` (✓ breaking out of the box) |
46
- | `radio` | `unchecked` (○), `checked` (✓ in a circle), `checked-big` (✓ breaking out of the circle) |
47
- | `folder` | `closed`, `open` |
48
- | `theme` | `light` (sun), `dark` (moon) |
49
- | `weather` | `sunny`, `frosty`, `partly-cloudy`, `showers`, `cloudy`, `thunder`, `rain`, `snow`, `drizzle` |
50
- | `eye` | `visible`, `hidden` (slashed) |
51
- | `volume` | `muted` (✕), `low` (no waves), `medium`, `high` |
52
- | `toggle` | `plus`, `minus`, `close` (✕), `check` |
53
- | `lock` | `locked`, `unlocked` |
54
- | `bell` | `on`, `off` (slashed) |
55
- | `mic` | `on`, `off` (slashed) |
56
- | `chevron` | `up`, `right`, `down`, `left` |
57
- | `copy` | `copy`, `copied` (✓) |
58
- | `wifi` | `none`, `low`, `medium`, `high` |
59
- | `battery` | `empty`, `low`, `medium`, `full` |
59
+ | `media` → `iconsMedia` | `play`, `pause`, `stop` |
60
+ | `menu` → `iconsMenu` | `menu` (☰), `close` (✕), `back` (←) |
61
+ | `checkbox` → `iconsCheckbox` | `unchecked` (☐), `checked` (☑), `checked-big` (✓ breaking out of the box) |
62
+ | `radio` → `iconsRadio` | `unchecked` (○), `checked` (✓ in a circle), `checked-big` (✓ breaking out of the circle) |
63
+ | `folder` → `iconsFolder` | `closed`, `open` |
64
+ | `theme` → `iconsTheme` | `light` (sun), `dark` (moon) |
65
+ | `weather` → `iconsWeather` | `sunny`, `frosty`, `partly-cloudy`, `showers`, `cloudy`, `thunder`, `rain`, `snow`, `drizzle` |
66
+ | `eye` → `iconsEye` | `visible`, `hidden` (slashed) |
67
+ | `volume` → `iconsVolume` | `muted` (✕), `low` (no waves), `medium`, `high` |
68
+ | `toggle` → `iconsToggle` | `plus`, `minus`, `close` (✕), `check` |
69
+ | `lock` → `iconsLock` | `locked`, `unlocked` |
70
+ | `bell` → `iconsBell` | `on`, `off` (slashed) |
71
+ | `mic` → `iconsMic` | `on`, `off` (slashed) |
72
+ | `chevron` → `iconsChevron` | `up`, `right`, `down`, `left` |
73
+ | `copy` → `iconsCopy` | `copy`, `copied` (✓) |
74
+ | `wifi` → `iconsWifi` | `none`, `low`, `medium`, `high` |
75
+ | `battery` → `iconsBattery` | `empty`, `low`, `medium`, `full` |
60
76
 
61
77
  ```ts
62
- const player = icon(button, { set: 'media', state: 'play' });
78
+ import { iconsMedia } from 'morphico/sets/media';
79
+
80
+ const player = icon(button, 'play', { set: iconsMedia });
63
81
  player.to('pause');
64
82
  ```
65
83
 
66
- ## A line of characters: `text(element, options)`
84
+ ## A line of characters: `text(element, init, options)`
67
85
 
68
86
  ```ts
69
- const clock = text(document.querySelector('#clock'), { value: '04:59' });
87
+ import { text } from 'morphico';
88
+ import { digitsRound } from 'morphico/sets/round';
89
+
90
+ const clock = text(document.querySelector('#clock'), '04:59', { set: digitsRound });
70
91
 
71
- clock.set('05:00'); // only the digits that changed morph
92
+ await clock.to('05:00'); // only the digits that changed morph
72
93
  clock.destroy();
73
94
  ```
74
95
 
@@ -76,22 +97,22 @@ clock.destroy();
76
97
  **Any other character is removed** (with the regex `/[^0-9 :.,\/-]/g`): `'12:09 AM'` shows `12:09 `.
77
98
  - Cells: digits, space and `-` have one width, `:` `.` `,` half of it, `/` 0.7 of it. A change morphs when the length
78
99
  and every cell's width stay the same (`5` ↔ `-`, `.` ↔ `,`); otherwise the line is rebuilt without animation
79
- (`5` ↔ `:`). Pad counters with spaces to keep them morphing: `counter.set(String(count).padStart(3, ' '))`.
100
+ (`5` ↔ `:`). Pad counters with spaces to keep them morphing: `counter.to(String(count).padStart(3, ' '))`.
80
101
  - Digits sit on the text baseline, and every cell has the same width, so clocks don't jiggle.
81
102
 
82
- ## A cut-out: `clip(element, options)`
103
+ ## A cut-out: `clip(element, init, options)`
83
104
 
84
105
  Cuts any element (an `<img>`, a `<div>`, a `<main>` …) out by a shape, and morphs, moves, resizes and turns it.
85
106
 
86
107
  ```ts
87
- const photo = clip(document.querySelector('#photo'), { state: 'star', x: 50, y: 50, size: '80%' });
108
+ const photo = clip(document.querySelector('#photo'), 'star', { x: 50, y: 50, size: '80%' });
88
109
 
89
110
  await photo.to('heart'); // morph
90
111
  photo.to('shield', { rotation: 360, x: 30, size: '120px' }); // morph + turn + move together
91
112
  photo.to({ x: 70, y: '150px' }); // move only, the shape stays
92
- photo.set({ rotation: 15 }); // jump, no animation (e.g. while dragging a slider)
93
- photo.set({ outline: { width: 16 } }); // only a 16 px band along the edge shows
94
- photo.set({ inverted: true }); // the other way round: the shape is a hole
113
+ photo.to({ rotation: 15, duration: 0 }); // jump, no animation (e.g. while dragging a slider)
114
+ photo.to({ outline: { width: 16 } }); // only a 16 px band along the edge shows
115
+ photo.to({ inverted: true }); // the other way round: the shape is a hole
95
116
  photo.destroy(); // the element is whole again
96
117
  ```
97
118
 
@@ -102,7 +123,6 @@ turned 45°, a portrait rectangle `rectangle-horizontal` turned 90° (`rotation`
102
123
 
103
124
  | Option | Default | |
104
125
  | --- | --- | --- |
105
- | `state` | `'circle'` | The shape |
106
126
  | `x`, `y` | `50` | The shape's **centre**. A number is %, or a string `'40%'` / `'120px'` (% of the element's width for `x`, height for `y`) |
107
127
  | `size` | fits | The side of the shape's square (% of the element's width, or px). The shape keeps its own proportions inside that square. Default: the largest square that fits the element |
108
128
  | `rotation` | `0` | Degrees, clockwise, **absolute**: `rotation: 360` ends one full turn from 0. The shape turns rigidly, also in a wide or tall element |
@@ -111,12 +131,12 @@ turned 45°, a portrait rectangle `rectangle-horizontal` turned 90° (`rotation`
111
131
  | `inverted` | `false` | `true`: the other way round — the shape (or band) is a hole, the rest of the element shows. Not animated |
112
132
  | `duration`, `easing` | `400`, `'easeInOutCubic'` | As for icons; also per `to()` call |
113
133
 
114
- Everything except `outline` and `inverted` animates together in `to()`. How it works: a filled shape uses CSS `clip-path: url(#…)`,
134
+ Everything except `outline` and `inverted` (they switch at once) animates together in `to()`; `duration: 0` jumps. How it works: a filled shape uses CSS `clip-path: url(#…)`,
115
135
  an outlined one `mask: url(#…)` (clip-path has no line width); the `<clipPath>` / `<mask>` sit in a small hidden
116
136
  `<svg>` that `clip()` adds to the page. It follows the element's size (`ResizeObserver`). Overflow beyond the element is
117
137
  cut off, as with any clip-path.
118
138
 
119
- ## A face: `face(element, options)`
139
+ ## A face: `face(element, init, options)`
120
140
 
121
141
  A face of 7 soft pieces — 2 brows, 2 eyes, 2 pupils and a mouth — that morphs between feelings and blinks.
122
142
 
@@ -127,12 +147,11 @@ A face of 7 soft pieces — 2 brows, 2 eyes, 2 pupils and a mouth — that morph
127
147
  ```ts
128
148
  import { face } from 'morphico';
129
149
 
130
- const robot = face(document.querySelector('#robot'), { state: 'normal' });
150
+ const robot = face(document.querySelector('#robot'), 'normal');
131
151
 
132
152
  await robot.to('smiling'); // morph
133
- robot.to('angry', { duration: 300, onEnd: (result) => console.log(result) });
153
+ robot.to('angry', { duration: 300 });
134
154
  robot.blink(); // close and open the eyes once, on top of any expression
135
- robot.state; // 'angry'
136
155
  robot.destroy(); // removes the face, stops blinking
137
156
  ```
138
157
 
@@ -144,7 +163,6 @@ morphs into any other. `wink-left` / `wink-right` = the eye that closes, as seen
144
163
 
145
164
  | Option | Default | |
146
165
  | --- | --- | --- |
147
- | `state` | `'normal'` | The expression shown first |
148
166
  | `pupilColor` | `'white'` | Colour of the pupils. Brows, eyes and mouth take the CSS `color` |
149
167
  | `blink` | `true` | Blink by itself every few seconds |
150
168
  | `size` | `'100%'` | CSS width and height of the face (by default it fills the element) |
@@ -157,8 +175,7 @@ inside the eyes (an eyelid covers them). With reduced motion the face changes wi
157
175
 
158
176
  | Option | For | Default | |
159
177
  | --- | --- | --- | --- |
160
- | `set` | both | `'round'` | digits: `'round'`, `'square'`, `'condensed'`, `'slim'`, `'handwritten'`; icons (`icon()` only): `'media'`, `'menu'`, `'checkbox'`, `'radio'`, `'folder'`, `'theme'`, `'weather'`, `'eye'`, `'volume'`, `'toggle'`, `'lock'`, `'bell'`, `'mic'`, `'chevron'`, `'copy'`, `'wifi'`, `'battery'` |
161
- | `state` / `value` | icon / text | empty | What is shown first |
178
+ | `set` | both | **required** | A set object: a digit set (`digitsRound` …) for `text()`, any set for `icon()` — see above |
162
179
  | `size` | both | `'1em'` | CSS size (icon: width and height; text: height). `1em` follows the font size |
163
180
  | `duration` | both | `400` | Milliseconds |
164
181
  | `easing` | both | `'easeInOutCubic'` | `'linear'`, `'easeInOutCubic'`, `'easeOutBack'`, `'easeOutElastic'`, or a function `(progress) => easedProgress` |
@@ -170,13 +187,13 @@ inside the eyes (an eyelid covers them). With reduced motion the face changes wi
170
187
  | `cellWidth` | text | per set | Width of a digit cell in grid units (a digit is 16 high) |
171
188
  | `spacing` | text | per set | Gap between cells in grid units |
172
189
 
173
- `duration` and `easing` can also be passed to a single `to()` / `set()` call.
190
+ `duration` and `easing` can also be passed to a single `to()` call.
174
191
 
175
192
  ## Behaviour
176
193
 
177
194
  - **Colour** comes from the CSS `color` (`currentColor`).
178
195
  - **Spacing** can be set in CSS too: `--morphico-spacing: 0.15em`.
179
- - **Interrupting:** calling `to()` / `set()` during a morph starts the new morph from the shape on screen.
196
+ - **Interrupting:** calling `to()` during a morph starts the new morph from the shape on screen.
180
197
  The earlier promise resolves with `{ interrupted: true }`; promises never stay pending.
181
198
  - **Reduced motion:** when the user's system asks for reduced motion, shapes change without animation.
182
199
  - **Accessibility:** the icon / line has `role="img"` and an `aria-label` with the current value.
@@ -0,0 +1,50 @@
1
+ /*! morphico v0.5.0 | MIT License, Copyright (c) 2026 Raycho Ivanov (see LICENSE) */
2
+ // Every built-in set by name — `import { iconSets } from 'morphico/all'` (and `morphico.iconSets` in the classic script).
3
+ // Imports every set: for pages that pick sets at run time. Others import single sets (`morphico/sets/<name>`), so a
4
+ // bundler only keeps those.
5
+ import { iconsCheckbox } from './sets/checkbox.js';
6
+ import { digitsCondensed } from './sets/condensed.js';
7
+ import { iconsFolder } from './sets/folder.js';
8
+ import { digitsHandwritten } from './sets/handwritten.js';
9
+ import { iconsMedia } from './sets/media.js';
10
+ import { iconsRadio } from './sets/radio.js';
11
+ import { iconsMenu } from './sets/menu.js';
12
+ import { digitsRound } from './sets/round.js';
13
+ import { digitsSlim } from './sets/slim.js';
14
+ import { digitsSquare } from './sets/square.js';
15
+ import { iconsTheme } from './sets/theme.js';
16
+ import { iconsWeather } from './sets/weather.js';
17
+ import { iconsEye } from './sets/eye.js';
18
+ import { iconsVolume } from './sets/volume.js';
19
+ import { iconsToggle } from './sets/toggle.js';
20
+ import { iconsLock } from './sets/lock.js';
21
+ import { iconsBell } from './sets/bell.js';
22
+ import { iconsMic } from './sets/mic.js';
23
+ import { iconsChevron } from './sets/chevron.js';
24
+ import { iconsCopy } from './sets/copy.js';
25
+ import { iconsWifi } from './sets/wifi.js';
26
+ import { iconsBattery } from './sets/battery.js';
27
+ export const iconSets = {
28
+ round: digitsRound,
29
+ square: digitsSquare,
30
+ condensed: digitsCondensed,
31
+ slim: digitsSlim,
32
+ handwritten: digitsHandwritten,
33
+ media: iconsMedia,
34
+ menu: iconsMenu,
35
+ checkbox: iconsCheckbox,
36
+ radio: iconsRadio,
37
+ folder: iconsFolder,
38
+ theme: iconsTheme,
39
+ weather: iconsWeather,
40
+ eye: iconsEye,
41
+ volume: iconsVolume,
42
+ toggle: iconsToggle,
43
+ lock: iconsLock,
44
+ bell: iconsBell,
45
+ mic: iconsMic,
46
+ chevron: iconsChevron,
47
+ copy: iconsCopy,
48
+ wifi: iconsWifi,
49
+ battery: iconsBattery,
50
+ };
@@ -0,0 +1,283 @@
1
+ /*! morphico v0.5.0 | MIT License, Copyright (c) 2026 Raycho Ivanov (see LICENSE) */
2
+ // Layer 3: an element cut out by a morphing shape (a star, a heart, a speech bubble …), e.g. a photo or a card.
3
+ // The shape is square (1:1), placed by its centre (x, y) and size in % or px of the element, can be turned and
4
+ // outlined (only a band along its edge shows). Filled → CSS `clip-path: url(#id)`; outlined → `mask: url(#id)`
5
+ // (clip-path ignores line width). The <clipPath> / <mask> live in one hidden <svg> per clip.
6
+ import { animate, easings } from './engine.js';
7
+ import { interpolate, pieceLengthOf, shapeForPair, toPathData } from './geometry.js';
8
+ import { checkedInit, resolveIconSet } from './roundness.js';
9
+ import { clampToUnit, DEFAULT_DURATION, prefersReducedMotion, shapesOf } from './morph.js';
10
+ import { clipShapes } from './sets/shapes.js';
11
+ import { createSvgElement } from './svg.js';
12
+ const GRID_SIZE = 24;
13
+ const GRID_CENTER = 12;
14
+ const DEGREE = Math.PI / 180;
15
+ /** Masks and their clip regions reach far past the element, so bands and turned shapes are never cut by them. */
16
+ const FAR = 100000;
17
+ /** In objectBoundingBox units (1 = the element's width / height): far around the element. */
18
+ const INVERTED_SURROUNDING = 'M-1000 -1000H1001V1001H-1000Z';
19
+ let clipCount = 0;
20
+ const lengthToPixels = (length, reference, name) => {
21
+ if (typeof length === 'number' && Number.isFinite(length))
22
+ return (length / 100) * reference;
23
+ const match = typeof length === 'string' ? /^\s*(-?[\d.]+(?:e[-+]?\d+)?)\s*(px|%)\s*$/i.exec(length) : null;
24
+ if (!match)
25
+ throw new Error(`morphico: ${name} "${length}" is not a length — use a number (= %), '40%' or '120px'`);
26
+ const value = Number(match[1]);
27
+ return match[2] === 'px' ? value : (value / 100) * reference;
28
+ };
29
+ const checkedOutline = (outline) => {
30
+ if (!outline)
31
+ return false;
32
+ if (!(outline.width > 0))
33
+ throw new Error('morphico: outline.width must be a number of px above 0');
34
+ if (outline.band && !['inside', 'centre', 'outside'].includes(outline.band)) {
35
+ throw new Error(`morphico: outline.band "${outline.band}" — use 'inside', 'centre' or 'outside'`);
36
+ }
37
+ return outline;
38
+ };
39
+ /** Cuts `element` out by a morphing shape, `init` first. */
40
+ export const clip = (element, init, options = {}) => {
41
+ const { set = clipShapes, x = 50, y = 50, size, rotation = 0, roundness = 1, outline = false, inverted = false, ...defaultMorphOptions } = options;
42
+ const checkedState = (shapeSet, state) => {
43
+ if (!(state in shapeSet.strokes)) {
44
+ throw new Error(`morphico: unknown shape "${state}" (known: ${Object.keys(shapeSet.strokes).join(', ')})`);
45
+ }
46
+ return state;
47
+ };
48
+ for (const [name, length] of [['x', x], ['y', y], ['size', size]]) {
49
+ if (length !== undefined)
50
+ lengthToPixels(length, 1, name);
51
+ }
52
+ /** Where the change goes (or what shows when nothing moves). */
53
+ const target = {
54
+ state: checkedState(set, checkedInit(init, 'clip')), set: resolveIconSet(set, roundness), roundness,
55
+ x, y, size, rotation, outline: checkedOutline(outline), inverted,
56
+ };
57
+ const pieceLength = pieceLengthOf(set);
58
+ let frame = null; // null = resting at the target
59
+ let running = null;
60
+ let elementWidth = 0, elementHeight = 0;
61
+ // ---- the hidden <svg> with the <clipPath> (filled) and the <mask> (outlined) ----
62
+ const id = `morphico-clip-${++clipCount}`;
63
+ const svg = createSvgElement('svg', { width: 0, height: 0, 'aria-hidden': 'true', focusable: 'false' });
64
+ svg.style.position = 'absolute';
65
+ svg.style.pointerEvents = 'none';
66
+ const clipPathElement = svg.appendChild(createSvgElement('clipPath', { id: `${id}-shape`, clipPathUnits: 'objectBoundingBox' }));
67
+ const clipShape = clipPathElement.appendChild(createSvgElement('path'));
68
+ const farRegion = { maskUnits: 'userSpaceOnUse', x: -FAR, y: -FAR, width: 2 * FAR, height: 2 * FAR };
69
+ const maskElement = svg.appendChild(createSvgElement('mask', { id: `${id}-band`, ...farRegion }));
70
+ /** Every path drawn in element pixels for the outlined band (the band itself and its helpers). */
71
+ let bandPaths = [];
72
+ const buildBand = () => {
73
+ for (const helper of svg.querySelectorAll('.morphico-band-helper'))
74
+ helper.remove();
75
+ maskElement.replaceChildren();
76
+ bandPaths = [];
77
+ clipShape.setAttribute('clip-rule', target.inverted ? 'evenodd' : 'nonzero');
78
+ if (!target.outline)
79
+ return;
80
+ const { width, band = 'inside' } = target.outline;
81
+ // inverted: everything shows (white), the band is the hole (black)
82
+ const bandColor = target.inverted ? 'black' : 'white';
83
+ if (target.inverted) {
84
+ maskElement.append(createSvgElement('rect', { x: -FAR, y: -FAR, width: 2 * FAR, height: 2 * FAR, fill: 'white' }));
85
+ }
86
+ const pixelPath = (attributes) => {
87
+ const path = createSvgElement('path', attributes);
88
+ bandPaths.push(path);
89
+ return path;
90
+ };
91
+ // inside = a line twice as wide, clipped to the shape; outside = a line twice as wide, the shape cut out of it
92
+ const edgeLine = (lineWidth) => pixelPath({ fill: 'none', stroke: bandColor, 'stroke-width': lineWidth, 'stroke-linejoin': 'miter' });
93
+ if (band === 'centre') {
94
+ maskElement.append(edgeLine(width));
95
+ }
96
+ else if (band === 'inside') {
97
+ const insideClip = createSvgElement('clipPath', { id: `${id}-inside`, class: 'morphico-band-helper' });
98
+ insideClip.append(pixelPath({}));
99
+ svg.append(insideClip);
100
+ const line = edgeLine(2 * width);
101
+ line.setAttribute('clip-path', `url(#${id}-inside)`);
102
+ maskElement.append(line);
103
+ }
104
+ else {
105
+ // painting black over the inside would only work for luminance masks; a mask cut out of the line works for both
106
+ const outsideMask = createSvgElement('mask', { id: `${id}-outside`, class: 'morphico-band-helper', ...farRegion });
107
+ outsideMask.append(createSvgElement('rect', { x: -FAR, y: -FAR, width: 2 * FAR, height: 2 * FAR, fill: 'white' }), pixelPath({ fill: 'black' }));
108
+ svg.append(outsideMask);
109
+ const line = edgeLine(2 * width);
110
+ line.setAttribute('mask', `url(#${id}-outside)`);
111
+ maskElement.append(line);
112
+ }
113
+ };
114
+ const ownClipPath = element.style.clipPath;
115
+ const ownMask = element.style.mask;
116
+ const applyStyle = () => {
117
+ const mask = target.outline ? `url(#${id}-band)` : ownMask;
118
+ element.style.clipPath = target.outline ? ownClipPath : `url(#${id}-shape)`;
119
+ element.style.mask = mask;
120
+ element.style.setProperty('-webkit-mask', mask);
121
+ };
122
+ // ---- placement and drawing ----
123
+ const restingPlacement = () => ({
124
+ centerX: lengthToPixels(target.x, elementWidth, 'x'),
125
+ centerY: lengthToPixels(target.y, elementHeight, 'y'),
126
+ side: target.size === undefined ? Math.min(elementWidth, elementHeight) : lengthToPixels(target.size, elementWidth, 'size'),
127
+ });
128
+ const restingFrame = () => ({ shape: shapesOf(target.set)[target.state], rotation: target.rotation, placement: restingPlacement() });
129
+ /** The shape in element pixels: scaled to its square, turned around the square's centre, moved into place. */
130
+ const toElementPixels = ({ shape, rotation: angle, placement: { centerX, centerY, side } }) => {
131
+ const scale = side / GRID_SIZE;
132
+ const cos = Math.cos(angle * DEGREE), sin = Math.sin(angle * DEGREE);
133
+ const pixels = [];
134
+ for (let index = 0; index < shape.length; index += 2) {
135
+ const offsetX = (shape[index] - GRID_CENTER) * scale, offsetY = (shape[index + 1] - GRID_CENTER) * scale;
136
+ pixels.push(centerX + offsetX * cos - offsetY * sin, centerY + offsetX * sin + offsetY * cos);
137
+ }
138
+ return pixels;
139
+ };
140
+ const render = () => {
141
+ if (!elementWidth || !elementHeight)
142
+ return;
143
+ const pixels = toElementPixels(frame ?? restingFrame());
144
+ if (target.outline) {
145
+ const pathData = toPathData(pixels, pieceLength);
146
+ for (const path of bandPaths)
147
+ path.setAttribute('d', pathData);
148
+ }
149
+ else {
150
+ // objectBoundingBox: 0…1 of the element's width / height
151
+ const boxUnits = pixels.map((value, index) => value / (index % 2 ? elementHeight : elementWidth));
152
+ // inverted: a rectangle far around the element with the shape as a hole in it (clip-rule evenodd)
153
+ const surrounding = target.inverted ? INVERTED_SURROUNDING : '';
154
+ clipShape.setAttribute('d', surrounding + toPathData(boxUnits, pieceLength));
155
+ }
156
+ };
157
+ const measure = (width, height) => {
158
+ elementWidth = width;
159
+ elementHeight = height;
160
+ render();
161
+ };
162
+ // ---- changes ----
163
+ /** Ends the running change and resolves everyone waiting for it. */
164
+ const finish = (interrupted) => {
165
+ if (!running)
166
+ return;
167
+ const { stopAnimation, resolvers } = running;
168
+ running = null;
169
+ stopAnimation();
170
+ for (const resolve of resolvers)
171
+ resolve({ state: target.state, interrupted });
172
+ };
173
+ /** Updates the target (checked first, so a wrong value throws right away, not in an animation frame). */
174
+ const applyChanges = (changes) => {
175
+ for (const name of ['x', 'y', 'size']) {
176
+ if (changes[name] !== undefined)
177
+ lengthToPixels(changes[name], 1, name);
178
+ }
179
+ if (changes.state !== undefined)
180
+ target.state = checkedState(target.set, changes.state);
181
+ if (changes.roundness !== undefined && changes.roundness !== target.roundness) {
182
+ target.roundness = changes.roundness;
183
+ target.set = resolveIconSet(set, changes.roundness);
184
+ }
185
+ if (changes.x !== undefined)
186
+ target.x = changes.x;
187
+ if (changes.y !== undefined)
188
+ target.y = changes.y;
189
+ if (changes.size !== undefined)
190
+ target.size = changes.size;
191
+ if (changes.rotation !== undefined)
192
+ target.rotation = changes.rotation;
193
+ if (changes.outline !== undefined || changes.inverted !== undefined) {
194
+ if (changes.outline !== undefined)
195
+ target.outline = checkedOutline(changes.outline);
196
+ if (changes.inverted !== undefined)
197
+ target.inverted = changes.inverted;
198
+ buildBand();
199
+ applyStyle();
200
+ }
201
+ };
202
+ const change = (changes, morphOptions) => {
203
+ const settled = !running;
204
+ finish(true);
205
+ const previous = { state: target.state, set: target.set };
206
+ const from = frame ?? restingFrame();
207
+ applyChanges(changes);
208
+ const { duration = DEFAULT_DURATION, easing = 'easeInOutCubic' } = morphOptions;
209
+ if (duration <= 0 || prefersReducedMotion() || !elementWidth || !elementHeight) {
210
+ frame = null;
211
+ render();
212
+ return Promise.resolve({ state: target.state, interrupted: false });
213
+ }
214
+ // settled: both sides use the pair's layouts; interrupted: start from the shape on screen
215
+ const fromShape = settled
216
+ ? shapeForPair(previous.set, shapesOf(previous.set), previous.state, target.state) : from.shape;
217
+ const toShape = shapeForPair(target.set, shapesOf(target.set), target.state, previous.state);
218
+ const to = restingPlacement();
219
+ const ease = typeof easing === 'function' ? easing : easings[easing];
220
+ const startTime = performance.now();
221
+ return new Promise((resolve) => {
222
+ const stopAnimation = animate((frameTime) => {
223
+ const progress = clampToUnit((frameTime - startTime) / duration);
224
+ if (progress >= 1) {
225
+ frame = null;
226
+ render();
227
+ finish(false);
228
+ return false;
229
+ }
230
+ const easedProgress = ease(progress);
231
+ const between = (start, end) => start + (end - start) * easedProgress;
232
+ frame = {
233
+ shape: interpolate(fromShape, toShape, easedProgress),
234
+ rotation: between(from.rotation, target.rotation),
235
+ placement: {
236
+ centerX: between(from.placement.centerX, to.centerX),
237
+ centerY: between(from.placement.centerY, to.centerY),
238
+ side: between(from.placement.side, to.side),
239
+ },
240
+ };
241
+ render();
242
+ return true;
243
+ });
244
+ running = { stopAnimation, resolvers: [resolve] };
245
+ });
246
+ };
247
+ // ---- start ----
248
+ const root = element.getRootNode();
249
+ // inside a shadow root, url(#id) only finds ids of that root
250
+ (root instanceof ShadowRoot ? root : document.body).append(svg);
251
+ buildBand();
252
+ applyStyle();
253
+ const box = element.getBoundingClientRect();
254
+ measure(box.width, box.height);
255
+ const resizeObserver = typeof ResizeObserver === 'function'
256
+ ? new ResizeObserver(([entry]) => {
257
+ const borderBox = entry.borderBoxSize?.[0];
258
+ if (borderBox)
259
+ measure(borderBox.inlineSize, borderBox.blockSize);
260
+ else
261
+ measure(entry.contentRect.width, entry.contentRect.height);
262
+ })
263
+ : null;
264
+ resizeObserver?.observe(element);
265
+ return {
266
+ to(stateOrChanges, options = {}) {
267
+ const { onEnd, duration, easing, ...changes } = typeof stateOrChanges === 'string'
268
+ ? { ...options, state: stateOrChanges } : stateOrChanges;
269
+ const result = change(changes, { ...defaultMorphOptions, ...(duration !== undefined && { duration }), ...(easing && { easing }) });
270
+ if (onEnd)
271
+ result.then(onEnd);
272
+ return result;
273
+ },
274
+ destroy() {
275
+ finish(true);
276
+ resizeObserver?.disconnect();
277
+ svg.remove();
278
+ element.style.clipPath = ownClipPath;
279
+ element.style.mask = ownMask;
280
+ element.style.setProperty('-webkit-mask', ownMask);
281
+ },
282
+ };
283
+ };
@@ -0,0 +1,25 @@
1
+ /*! morphico v0.5.0 | MIT License, Copyright (c) 2026 Raycho Ivanov (see LICENSE) */
2
+ // Animation engine: easings and one shared requestAnimationFrame loop for every running morph.
3
+ // The loop only runs while something moves; nothing touches the DOM at import time (SSR-safe).
4
+ export const easings = {
5
+ linear: (progress) => progress,
6
+ easeInOutCubic: (progress) => (progress < 0.5 ? 4 * progress ** 3 : 1 - (-2 * progress + 2) ** 3 / 2),
7
+ easeOutBack: (progress) => 1 + 2.70158 * (progress - 1) ** 3 + 1.70158 * (progress - 1) ** 2,
8
+ easeOutElastic: (progress) => (progress === 0 || progress === 1
9
+ ? progress
10
+ : 2 ** (-10 * progress) * Math.sin((progress * 10 - 0.75) * ((2 * Math.PI) / 3)) + 1),
11
+ };
12
+ const frameCallbacks = new Set();
13
+ let animationFrameId = 0;
14
+ const runFrame = (frameTime) => {
15
+ for (const callback of frameCallbacks)
16
+ if (!callback(frameTime))
17
+ frameCallbacks.delete(callback);
18
+ animationFrameId = frameCallbacks.size ? requestAnimationFrame(runFrame) : 0;
19
+ };
20
+ /** Adds a callback to the shared loop. Returns a function that removes it again. */
21
+ export const animate = (onFrame) => {
22
+ frameCallbacks.add(onFrame);
23
+ animationFrameId ||= requestAnimationFrame(runFrame);
24
+ return () => { frameCallbacks.delete(onFrame); };
25
+ };