morphico 0.3.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 (73) hide show
  1. package/README.md +98 -41
  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 +4033 -3380
  42. package/dist/types/{iconSets.d.ts → all.d.ts} +0 -2
  43. package/dist/types/clip.d.ts +6 -11
  44. package/dist/types/face.d.ts +27 -0
  45. package/dist/types/icon.d.ts +4 -9
  46. package/dist/types/index.d.ts +5 -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/faces.d.ts +25 -0
  56. package/dist/types/sets/folder.d.ts +1 -1
  57. package/dist/types/sets/handwritten.d.ts +1 -1
  58. package/dist/types/sets/lock.d.ts +1 -1
  59. package/dist/types/sets/media.d.ts +1 -1
  60. package/dist/types/sets/menu.d.ts +1 -1
  61. package/dist/types/sets/mic.d.ts +1 -1
  62. package/dist/types/sets/radio.d.ts +1 -1
  63. package/dist/types/sets/round.d.ts +1 -1
  64. package/dist/types/sets/slim.d.ts +1 -1
  65. package/dist/types/sets/square.d.ts +1 -1
  66. package/dist/types/sets/theme.d.ts +1 -1
  67. package/dist/types/sets/toggle.d.ts +1 -1
  68. package/dist/types/sets/volume.d.ts +1 -1
  69. package/dist/types/sets/weather.d.ts +1 -1
  70. package/dist/types/sets/wifi.d.ts +1 -1
  71. package/dist/types/text.d.ts +8 -13
  72. package/package.json +95 -3
  73. package/dist/morphico.mjs +0 -4003
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,49 +97,85 @@ 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
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
94
116
  photo.destroy(); // the element is whole again
95
117
  ```
96
118
 
97
119
  Shapes (based on [Lucide](https://lucide.dev)): `circle`, `ellipse`, `heart`, `sparkle`, `astroid`, `bookmark`,
98
120
  `shield`, `moon`, `tag`, `square`, `badge`, `triangle`, `droplet`, `pentagon`, `hexagon`, `octagon`, `squircle`,
99
- `star`, `rectangle-horizontal`, `rectangle-vertical`, `message-circle`, `message-square`, `diamond`, `cloud`, `puzzle`.
121
+ `star`, `rectangle-horizontal`, `message-circle`, `message-square`, `cloud`, `puzzle`, `club`. A diamond is the `square`
122
+ turned 45°, a portrait rectangle `rectangle-horizontal` turned 90° (`rotation`).
100
123
 
101
124
  | Option | Default | |
102
125
  | --- | --- | --- |
103
- | `state` | `'circle'` | The shape |
104
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`) |
105
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 |
106
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 |
107
129
  | `roundness` | `1` | Corners: `0` = sharp … `1` = the shape's own (the maximum) |
108
130
  | `outline` | `false` | `{ width, band }`: only a band of `width` px along the edge shows; `band`: `'inside'` (default), `'centre'` or `'outside'` the edge. Not animated |
131
+ | `inverted` | `false` | `true`: the other way round — the shape (or band) is a hole, the rest of the element shows. Not animated |
109
132
  | `duration`, `easing` | `400`, `'easeInOutCubic'` | As for icons; also per `to()` call |
110
133
 
111
- Everything except `outline` 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(#…)`,
112
135
  an outlined one `mask: url(#…)` (clip-path has no line width); the `<clipPath>` / `<mask>` sit in a small hidden
113
136
  `<svg>` that `clip()` adds to the page. It follows the element's size (`ResizeObserver`). Overflow beyond the element is
114
137
  cut off, as with any clip-path.
115
138
 
139
+ ## A face: `face(element, init, options)`
140
+
141
+ A face of 7 soft pieces — 2 brows, 2 eyes, 2 pupils and a mouth — that morphs between feelings and blinks.
142
+
143
+ ```html
144
+ <div id="robot" style="width: 160px; height: 160px; border-radius: 50%; background: #e4e2d6; color: #8b8e8e"></div>
145
+ ```
146
+
147
+ ```ts
148
+ import { face } from 'morphico';
149
+
150
+ const robot = face(document.querySelector('#robot'), 'normal');
151
+
152
+ await robot.to('smiling'); // morph
153
+ robot.to('angry', { duration: 300 });
154
+ robot.blink(); // close and open the eyes once, on top of any expression
155
+ robot.destroy(); // removes the face, stops blinking
156
+ ```
157
+
158
+ Expressions: `normal`, `smiling`, `pleading`, `wonder`, `focused`, `fright`, `angry`, `asleep`, `sad`,
159
+ `surprised`, `laughing`, `tired`, `wink-left`, `wink-right`, `suspicious`, `confused`, `smug`, `irritated`,
160
+ `bored`, `curious`, `empathetic`, `guilty`, `worried`, `disappointed`, `ashamed`, `embarrassed`, `yearning`,
161
+ `triumphant`, `proud`, `excited`, `scornful`, `disgusted` (also exported as `faceExpressionNames`). Any one
162
+ morphs into any other. `wink-left` / `wink-right` = the eye that closes, as seen.
163
+
164
+ | Option | Default | |
165
+ | --- | --- | --- |
166
+ | `pupilColor` | `'white'` | Colour of the pupils. Brows, eyes and mouth take the CSS `color` |
167
+ | `blink` | `true` | Blink by itself every few seconds |
168
+ | `size` | `'100%'` | CSS width and height of the face (by default it fills the element) |
169
+ | `duration`, `easing` | `500`, `'easeInOutCubic'` | As for icons; also per `to()` call |
170
+
171
+ The face draws no background: a disc, card or anything else behind it is the element's own CSS. The pupils only show
172
+ inside the eyes (an eyelid covers them). With reduced motion the face changes without animation and doesn't blink.
173
+
116
174
  ## Options
117
175
 
118
176
  | Option | For | Default | |
119
177
  | --- | --- | --- | --- |
120
- | `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'` |
121
- | `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 |
122
179
  | `size` | both | `'1em'` | CSS size (icon: width and height; text: height). `1em` follows the font size |
123
180
  | `duration` | both | `400` | Milliseconds |
124
181
  | `easing` | both | `'easeInOutCubic'` | `'linear'`, `'easeInOutCubic'`, `'easeOutBack'`, `'easeOutElastic'`, or a function `(progress) => easedProgress` |
@@ -130,17 +187,17 @@ cut off, as with any clip-path.
130
187
  | `cellWidth` | text | per set | Width of a digit cell in grid units (a digit is 16 high) |
131
188
  | `spacing` | text | per set | Gap between cells in grid units |
132
189
 
133
- `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.
134
191
 
135
192
  ## Behaviour
136
193
 
137
194
  - **Colour** comes from the CSS `color` (`currentColor`).
138
195
  - **Spacing** can be set in CSS too: `--morphico-spacing: 0.15em`.
139
- - **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.
140
197
  The earlier promise resolves with `{ interrupted: true }`; promises never stay pending.
141
198
  - **Reduced motion:** when the user's system asks for reduced motion, shapes change without animation.
142
199
  - **Accessibility:** the icon / line has `role="img"` and an `aria-label` with the current value.
143
- - **Server-side rendering:** importing does not touch the DOM; only `icon()` / `text()` / `clip()` do.
200
+ - **Server-side rendering:** importing does not touch the DOM; only `icon()` / `text()` / `clip()` / `face()` do.
144
201
 
145
202
  ## Development
146
203
 
@@ -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
+ };