fetta 1.5.5 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +112 -262
- package/dist/chunk-HSIMGGOI.js +1 -0
- package/dist/{chunk-PA22PLRB.js → chunk-TEDMJHF7.js} +1 -13
- package/dist/chunk-V23SUR2S.js +13 -0
- package/dist/morph.d.ts +38 -0
- package/dist/morph.js +556 -0
- package/dist/motion.js +3 -1
- package/dist/react.js +2 -1
- package/package.json +11 -1
package/README.md
CHANGED
|
@@ -2,54 +2,29 @@
|
|
|
2
2
|
|
|
3
3
|
Text splitting that keeps kerning intact.
|
|
4
4
|
|
|
5
|
-
Split text into
|
|
5
|
+
Split text into characters, words, and lines for animation — without breaking your typography. Most splitting libraries wrap each character in a `<span>` and call it done, but that destroys the kerning between character pairs. Fetta compensates for this automatically.
|
|
6
6
|
|
|
7
7
|
Docs: https://fetta.dimi.me/
|
|
8
8
|
|
|
9
|
-
##
|
|
9
|
+
## Install
|
|
10
10
|
|
|
11
11
|
```bash
|
|
12
12
|
npm install fetta
|
|
13
13
|
```
|
|
14
14
|
|
|
15
|
-
## Choose Your Entry Point
|
|
16
|
-
|
|
17
|
-
| Import | Use when | Size (min + brotli) |
|
|
18
|
-
|------|------|------|
|
|
19
|
-
| `fetta` | You want the framework-agnostic core API (`splitText`) | ~7.04 kB |
|
|
20
|
-
| `fetta/react` | You use React and want callback/lifecycle control | ~8.18 kB |
|
|
21
|
-
| `fetta/motion` | You use Motion variants and built-in triggers | ~13.71 kB |
|
|
22
|
-
| `fetta/helpers` | You need optional utilities for layered split effects | ~742 B |
|
|
23
|
-
|
|
24
|
-
## Features
|
|
25
|
-
|
|
26
|
-
- **Kerning Compensation** — Maintains original character spacing when splitting by chars
|
|
27
|
-
- **Nested Elements** — Preserves `<a>`, `<em>`, `<strong>` and other inline elements with all attributes
|
|
28
|
-
- **Line Detection** — Groups words into rendered lines
|
|
29
|
-
- **Dash Handling** — Allows text to wrap naturally after em-dashes, en-dashes, hyphens, and slashes
|
|
30
|
-
- **Auto Re-split** — Re-splits on container resize
|
|
31
|
-
- **Auto Revert** — Restores original HTML after animations
|
|
32
|
-
- **Masking** — Wrap elements in clip containers for reveal animations
|
|
33
|
-
- **Emoji Support** — Properly handles compound emojis and complex Unicode characters
|
|
34
|
-
- **Accessible** — Automatic screen reader support, even with nested links or emphasis
|
|
35
|
-
- **TypeScript** — Full type definitions included
|
|
36
|
-
- **Library Agnostic** — Works with Motion, GSAP, CSS, WAAPI, or custom animation code
|
|
37
|
-
|
|
38
15
|
## Quick Start
|
|
39
16
|
|
|
40
|
-
### Vanilla
|
|
17
|
+
### Vanilla
|
|
41
18
|
|
|
42
19
|
```ts
|
|
43
20
|
import { splitText } from "fetta";
|
|
44
21
|
import { animate, stagger } from "motion";
|
|
45
22
|
|
|
46
|
-
const
|
|
47
|
-
const { chars } = splitText(element, { type: "chars" });
|
|
48
|
-
|
|
23
|
+
const { chars } = splitText(document.querySelector("h1"), { type: "chars" });
|
|
49
24
|
animate(chars, { opacity: [0, 1], y: [20, 0] }, { delay: stagger(0.02) });
|
|
50
25
|
```
|
|
51
26
|
|
|
52
|
-
### React
|
|
27
|
+
### React
|
|
53
28
|
|
|
54
29
|
```tsx
|
|
55
30
|
import { SplitText } from "fetta/react";
|
|
@@ -62,195 +37,149 @@ import { animate, stagger } from "motion";
|
|
|
62
37
|
}}
|
|
63
38
|
>
|
|
64
39
|
<h1>Hello World</h1>
|
|
65
|
-
</SplitText
|
|
40
|
+
</SplitText>
|
|
66
41
|
```
|
|
67
42
|
|
|
68
|
-
### Motion
|
|
43
|
+
### Motion
|
|
69
44
|
|
|
70
45
|
```tsx
|
|
71
46
|
import { SplitText } from "fetta/motion";
|
|
72
47
|
import { stagger } from "motion";
|
|
73
48
|
|
|
74
49
|
<SplitText
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
visible: { words: { opacity: 1, y: 0 } },
|
|
78
|
-
}}
|
|
79
|
-
initial="hidden"
|
|
80
|
-
animate="visible"
|
|
50
|
+
initial={{ opacity: 0, y: 20 }}
|
|
51
|
+
animate={{ opacity: 1, y: 0 }}
|
|
81
52
|
transition={{ duration: 0.65, delay: stagger(0.04) }}
|
|
82
53
|
options={{ type: "words" }}
|
|
83
54
|
>
|
|
84
55
|
<h1>Hello World</h1>
|
|
85
|
-
</SplitText
|
|
56
|
+
</SplitText>
|
|
86
57
|
```
|
|
87
58
|
|
|
88
|
-
|
|
59
|
+
### Morph
|
|
89
60
|
|
|
90
|
-
|
|
61
|
+
```tsx
|
|
62
|
+
import { MorphText } from "fetta/morph";
|
|
91
63
|
|
|
92
|
-
|
|
64
|
+
<MorphText>{text}</MorphText>
|
|
65
|
+
```
|
|
93
66
|
|
|
94
|
-
|
|
67
|
+
## Features
|
|
95
68
|
|
|
96
|
-
|
|
97
|
-
|
|
69
|
+
- **Kerning Compensation** — Maintains original character spacing when splitting by chars
|
|
70
|
+
- **Nested Elements** — Preserves `<a>`, `<em>`, `<strong>` and other inline elements with all attributes
|
|
71
|
+
- **Line Detection** — Groups words into rendered lines
|
|
72
|
+
- **Dash Handling** — Wraps naturally after em-dashes, en-dashes, hyphens, and slashes
|
|
73
|
+
- **Auto Re-split** — Re-splits on container resize
|
|
74
|
+
- **Auto Revert** — Restores original HTML after animations
|
|
75
|
+
- **Masking** — Clip containers for reveal animations
|
|
76
|
+
- **Emoji Support** — Handles compound emojis and complex Unicode characters
|
|
77
|
+
- **Accessible** — Automatic screen reader support, even with nested links or emphasis
|
|
78
|
+
- **TypeScript** — Full type definitions included
|
|
79
|
+
- **Zero Dependencies** — 7 kB core with no external packages
|
|
80
|
+
- **Library Agnostic** — Works with Motion, GSAP, CSS, WAAPI, or any animation code
|
|
98
81
|
|
|
99
|
-
|
|
100
|
-
|
|
82
|
+
## Entry Points
|
|
83
|
+
|
|
84
|
+
| Import | Use for | Size |
|
|
85
|
+
|------|------|------|
|
|
86
|
+
| `fetta` | Vanilla JS or any framework | 7.11 kB |
|
|
87
|
+
| `fetta/react` | React with callback/lifecycle control | 8.23 kB |
|
|
88
|
+
| `fetta/motion` | Declarative Motion animations | 13.78 kB |
|
|
89
|
+
| `fetta/morph` | Standalone MorphText component | 7.95 kB |
|
|
101
90
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
| Option | Type | Default | Description |
|
|
105
|
-
|------|------|------|------|
|
|
106
|
-
| `type` | `SplitType` | `"chars,words,lines"` | What to split: `"chars"`, `"words"`, `"lines"`, or combinations |
|
|
107
|
-
| `charClass` | `string` | `"split-char"` | CSS class for character elements |
|
|
108
|
-
| `wordClass` | `string` | `"split-word"` | CSS class for word elements |
|
|
109
|
-
| `lineClass` | `string` | `"split-line"` | CSS class for line elements |
|
|
110
|
-
| `mask` | `"chars" \| "words" \| "lines"` | — | Wrap elements in `overflow: clip` container |
|
|
111
|
-
| `autoSplit` | `boolean` | `false` | Re-split on container resize |
|
|
112
|
-
| `resplitDebounceMs` | `number` | `100` | Debounce delay for autoSplit/full-resplit width updates (`0` disables debounce) |
|
|
113
|
-
| `onResplit` | `(result) => void` | — | Callback after autoSplit/full-resplit replaces split output elements |
|
|
114
|
-
| `onSplit` | `(result) => CallbackReturn` | — | Callback after initial split. Return animation/promise for `revertOnComplete` |
|
|
115
|
-
| `revertOnComplete` | `boolean` | `false` | Auto-revert when returned animation completes |
|
|
116
|
-
| `propIndex` | `boolean` | `false` | Add CSS custom properties: `--char-index`, `--word-index`, `--line-index` |
|
|
117
|
-
| `disableKerning` | `boolean` | `false` | Skip kerning compensation (no margin adjustments) |
|
|
118
|
-
| `initialStyles` | `object` | — | Apply initial inline styles to chars/words/lines after split. Values can be objects or `(el, index) => object` functions |
|
|
119
|
-
| `initialClasses` | `object` | — | Apply initial CSS classes to chars/words/lines. Values are strings |
|
|
91
|
+
Sizes are minified + brotli.
|
|
120
92
|
|
|
121
|
-
|
|
122
|
-
type SplitType =
|
|
123
|
-
| "chars"
|
|
124
|
-
| "words"
|
|
125
|
-
| "lines"
|
|
126
|
-
| "chars,words"
|
|
127
|
-
| "words,lines"
|
|
128
|
-
| "chars,lines"
|
|
129
|
-
| "chars,words,lines";
|
|
130
|
-
```
|
|
93
|
+
## API Overview
|
|
131
94
|
|
|
132
|
-
|
|
95
|
+
Full API reference at [fetta.dimi.me](https://fetta.dimi.me/). Summary below.
|
|
96
|
+
|
|
97
|
+
### `splitText(element, options?)` — [Core docs](https://fetta.dimi.me/api/core)
|
|
98
|
+
|
|
99
|
+
Returns `{ chars, words, lines, revert }`. Key options: `type`, `mask`, `autoSplit`, `onSplit`, `revertOnComplete`, `initialStyles`, `propIndex`.
|
|
133
100
|
|
|
134
101
|
```ts
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
}
|
|
102
|
+
import { splitText } from "fetta";
|
|
103
|
+
import { animate, stagger } from "motion";
|
|
104
|
+
|
|
105
|
+
document.fonts.ready.then(() => {
|
|
106
|
+
const { words } = splitText(element, { type: "words", mask: "words" });
|
|
107
|
+
animate(words, { y: ["100%", "0%"] }, { delay: stagger(0.1) });
|
|
108
|
+
});
|
|
141
109
|
```
|
|
142
110
|
|
|
143
|
-
### `<SplitText>` (
|
|
111
|
+
### `<SplitText>` — [React docs](https://fetta.dimi.me/api/react)
|
|
112
|
+
|
|
113
|
+
Wraps `splitText()` with React lifecycle, viewport callbacks, and automatic cleanup. Key props: `onSplit`, `onResplit`, `options`, `autoSplit`, `waitForFonts`, `revertOnComplete`, `viewport`, `onViewportEnter`, `initialStyles`.
|
|
144
114
|
|
|
145
115
|
```tsx
|
|
146
116
|
import { SplitText } from "fetta/react";
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
`fetta/react` wraps `splitText()` for React with lifecycle hooks, viewport callbacks, and automatic cleanup.
|
|
150
|
-
|
|
151
|
-
#### React Props
|
|
152
|
-
|
|
153
|
-
| Prop | Type | Default | Description |
|
|
154
|
-
|------|------|------|------|
|
|
155
|
-
| `children` | `ReactElement` | — | Single React element to split |
|
|
156
|
-
| `as` | `keyof JSX.IntrinsicElements` | `"div"` | Wrapper element type |
|
|
157
|
-
| `className` | `string` | — | Wrapper class name |
|
|
158
|
-
| `style` | `CSSProperties` | — | Wrapper styles |
|
|
159
|
-
| `ref` | `Ref<HTMLElement>` | — | Ref to wrapper element |
|
|
160
|
-
| `onSplit` | `(result) => CallbackReturn` | — | Called after initial split |
|
|
161
|
-
| `onResplit` | `(result) => void` | — | Called when autoSplit/full-resplit replaces split output elements |
|
|
162
|
-
| `options` | `SplitTextOptions` | — | Split options (`type`, classes, mask, debounce, etc.) |
|
|
163
|
-
| `autoSplit` | `boolean` | `false` | Re-split on container resize |
|
|
164
|
-
| `waitForFonts` | `boolean` | `true` | Wait for `document.fonts.ready` before splitting |
|
|
165
|
-
| `revertOnComplete` | `boolean` | `false` | Revert after animation completes |
|
|
166
|
-
| `onRevert` | `() => void` | — | Called when split text reverts |
|
|
167
|
-
| `viewport` | `ViewportOptions` | — | Configure viewport detection |
|
|
168
|
-
| `onViewportEnter` | `(result) => CallbackReturn` | — | Called when entering viewport |
|
|
169
|
-
| `onViewportLeave` | `(result) => CallbackReturn` | — | Called when leaving viewport |
|
|
170
|
-
| `initialStyles` | `object` | — | Apply initial inline styles to chars/words/lines |
|
|
171
|
-
| `initialClasses` | `object` | — | Apply initial CSS classes to chars/words/lines |
|
|
172
|
-
| `resetOnViewportLeave` | `boolean` | `false` | Re-apply initial styles/classes when leaving viewport |
|
|
173
|
-
|
|
174
|
-
All callbacks receive:
|
|
175
|
-
|
|
176
|
-
```ts
|
|
177
|
-
{
|
|
178
|
-
chars: HTMLSpanElement[];
|
|
179
|
-
words: HTMLSpanElement[];
|
|
180
|
-
lines: HTMLSpanElement[];
|
|
181
|
-
revert: () => void;
|
|
182
|
-
}
|
|
183
|
-
```
|
|
117
|
+
import { animate, stagger } from "motion";
|
|
184
118
|
|
|
185
|
-
|
|
186
|
-
type
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
119
|
+
<SplitText
|
|
120
|
+
options={{ type: "words" }}
|
|
121
|
+
initialStyles={{ words: { opacity: 0, transform: "translateY(20px)" } }}
|
|
122
|
+
viewport={{ amount: 0.5 }}
|
|
123
|
+
onViewportEnter={({ words }) =>
|
|
124
|
+
animate(words, { opacity: 1, y: 0 }, { delay: stagger(0.05) })
|
|
125
|
+
}
|
|
126
|
+
resetOnViewportLeave
|
|
127
|
+
>
|
|
128
|
+
<p>Animates when scrolled into view</p>
|
|
129
|
+
</SplitText>
|
|
192
130
|
```
|
|
193
131
|
|
|
194
|
-
|
|
132
|
+
### `<SplitText>` — [Motion docs](https://fetta.dimi.me/api/motion)
|
|
195
133
|
|
|
196
|
-
|
|
134
|
+
Includes all React props plus Motion animation: `variants`, `initial`, `animate`, `exit`, `whileInView`, `whileScroll`, `whileHover`, `whileTap`, `whileFocus`, `transition`, `delayScope`, `custom`. Supports flat targets, per-type targets (`chars`/`words`/`lines`/`wrapper`), and function variants.
|
|
197
135
|
|
|
198
136
|
```tsx
|
|
199
137
|
import { SplitText } from "fetta/motion";
|
|
138
|
+
import { stagger } from "motion";
|
|
139
|
+
|
|
140
|
+
<SplitText
|
|
141
|
+
variants={{
|
|
142
|
+
hidden: { chars: { opacity: 0, y: 10 } },
|
|
143
|
+
visible: {
|
|
144
|
+
chars: ({ lineIndex }) => ({
|
|
145
|
+
opacity: 1,
|
|
146
|
+
y: 0,
|
|
147
|
+
transition: {
|
|
148
|
+
delay: stagger(0.02, { startDelay: lineIndex * 0.15 }),
|
|
149
|
+
},
|
|
150
|
+
}),
|
|
151
|
+
},
|
|
152
|
+
}}
|
|
153
|
+
initial="hidden"
|
|
154
|
+
animate="visible"
|
|
155
|
+
options={{ type: "chars,lines", mask: "lines" }}
|
|
156
|
+
>
|
|
157
|
+
<p>Per-line staggered reveal</p>
|
|
158
|
+
</SplitText>
|
|
200
159
|
```
|
|
201
160
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
#### Motion-only Props
|
|
205
|
-
|
|
206
|
-
| Prop | Type | Default | Description |
|
|
207
|
-
|------|------|------|------|
|
|
208
|
-
| `variants` | `Record<string, VariantDefinition<TCustom>>` | — | Named variant definitions |
|
|
209
|
-
| `initial` | `string \| VariantDefinition<TCustom> \| false` | — | Initial variant applied instantly after split |
|
|
210
|
-
| `animate` | `string \| VariantDefinition<TCustom>` | — | Base variant |
|
|
211
|
-
| `exit` | `string \| VariantDefinition<TCustom> \| false` | — | Exit variant (`AnimatePresence`) |
|
|
212
|
-
| `whileInView` | `string \| VariantDefinition<TCustom>` | — | Variant while element is in view |
|
|
213
|
-
| `whileOutOfView` | `string \| VariantDefinition<TCustom>` | — | Variant after element leaves view |
|
|
214
|
-
| `whileScroll` | `string \| VariantDefinition<TCustom>` | — | Scroll-driven variant (highest trigger priority) |
|
|
215
|
-
| `whileHover` | `string \| VariantDefinition<TCustom>` | — | Variant on hover |
|
|
216
|
-
| `whileTap` | `string \| VariantDefinition<TCustom>` | — | Variant on tap/press |
|
|
217
|
-
| `whileFocus` | `string \| VariantDefinition<TCustom>` | — | Variant on focus |
|
|
218
|
-
| `animateOnResplit` | `boolean` | `false` | Replay `initial -> animate` on autoSplit/full-resplit |
|
|
219
|
-
| `scroll` | `{ offset?, axis?, container? }` | — | Scroll tracking options for `whileScroll` |
|
|
220
|
-
| `transition` | `AnimationOptions` | — | Global/default transition for variants |
|
|
221
|
-
| `custom` | `TCustom` | — | Custom data forwarded to function variants |
|
|
222
|
-
| `delayScope` | `"global" \| "local"` | `"global"` | Delay-function index scope (`globalIndex` vs local `index`) |
|
|
223
|
-
| `reducedMotion` | `"user" \| "always" \| "never"` | `"never"` | Reduced-motion behavior for this component |
|
|
224
|
-
| `onHoverStart` | `() => void` | — | Called when hover starts |
|
|
225
|
-
| `onHoverEnd` | `() => void` | — | Called when hover ends |
|
|
226
|
-
|
|
227
|
-
Exit animations use standard Motion behavior (`SplitText` must be a direct child of `AnimatePresence`):
|
|
161
|
+
### `<MorphText>` — [Morph docs](https://fetta.dimi.me/api/morph)
|
|
228
162
|
|
|
229
|
-
|
|
230
|
-
import { AnimatePresence } from "motion/react";
|
|
231
|
-
import { SplitText } from "fetta/motion";
|
|
163
|
+
Text morphing with stable token identity. Matching tokens interpolate position, new tokens enter, removed tokens exit. Supports `splitBy="chars"` (default) and `splitBy="words"`. The `initial`, `animate`, and `exit` props accept static targets or `({ index, count }) => Target` callbacks.
|
|
232
164
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
</SplitText>
|
|
247
|
-
)}
|
|
248
|
-
</AnimatePresence>;
|
|
165
|
+
```tsx
|
|
166
|
+
import { MorphText } from "fetta/morph";
|
|
167
|
+
|
|
168
|
+
<MorphText
|
|
169
|
+
splitBy="words"
|
|
170
|
+
initial={({ index, count }) => ({
|
|
171
|
+
opacity: 0,
|
|
172
|
+
x: index <= count / 2 ? -75 : 75,
|
|
173
|
+
})}
|
|
174
|
+
animate={{ opacity: 1, x: 0 }}
|
|
175
|
+
>
|
|
176
|
+
{statusText}
|
|
177
|
+
</MorphText>
|
|
249
178
|
```
|
|
250
179
|
|
|
251
|
-
### `createSplitClones(
|
|
180
|
+
### `createSplitClones()` — [Helpers docs](https://fetta.dimi.me/api/helpers)
|
|
252
181
|
|
|
253
|
-
|
|
182
|
+
Creates clone layers from split output for reveal/swap effects. Pass an existing `splitText()` result.
|
|
254
183
|
|
|
255
184
|
```ts
|
|
256
185
|
import { splitText } from "fetta";
|
|
@@ -258,98 +187,19 @@ import { createSplitClones } from "fetta/helpers";
|
|
|
258
187
|
|
|
259
188
|
const split = splitText(element, { type: "chars", mask: "chars" });
|
|
260
189
|
const layers = createSplitClones(split, { unit: "chars", wrap: true });
|
|
190
|
+
// animate layers.originals + layers.clones...
|
|
191
|
+
layers.cleanup();
|
|
261
192
|
```
|
|
262
193
|
|
|
263
|
-
#### Helper Options
|
|
264
|
-
|
|
265
|
-
| Option | Type | Default | Description |
|
|
266
|
-
|------|------|------|------|
|
|
267
|
-
| `unit` | `"chars" \| "words" \| "lines"` | — | Which split nodes to layer |
|
|
268
|
-
| `wrap` | `boolean` | `false` | Wrap each original in a track wrapper (`position: relative`) |
|
|
269
|
-
| `display` | `"auto" \| "inline-block" \| "block"` | `"auto"` | Track display when `wrap: true` (`lines` => `block`, others => `inline-block`) |
|
|
270
|
-
| `cloneOffset.axis` | `"x" \| "y"` | `"y"` | Axis used for initial clone offset |
|
|
271
|
-
| `cloneOffset.direction` | `"start" \| "end"` | `"start"` | Offset direction (`start` => negative) |
|
|
272
|
-
| `cloneOffset.distance` | `string` | `"100%"` | Offset distance |
|
|
273
|
-
| `trackClassName` / `cloneClassName` | `string \| (ctx) => string \| undefined` | — | Class names (static or per-item) |
|
|
274
|
-
| `trackStyle` / `cloneStyle` | `object \| (ctx) => object` | — | Inline styles (static or per-item) |
|
|
275
|
-
|
|
276
|
-
#### Helper Behavior
|
|
277
|
-
|
|
278
|
-
- Helper does not call `splitText()`; pass an existing split result.
|
|
279
|
-
- Clone is appended to the current parent of each original split node.
|
|
280
|
-
- `wrap: false` appends clone to existing parent.
|
|
281
|
-
- `wrap: true` moves original into a generated track, then appends clone there.
|
|
282
|
-
- `cleanup()` removes helper-created tracks/clones and is idempotent.
|
|
283
|
-
- `cleanup({ revertSplit: true })` also calls `split.revert()`.
|
|
284
|
-
|
|
285
|
-
For reveal/swap effects, match helper `unit` with `splitText` `mask` (`"chars"`, `"words"`, `"lines"`).
|
|
286
|
-
|
|
287
|
-
## CSS Classes
|
|
288
|
-
|
|
289
|
-
Default classes applied to split output:
|
|
290
|
-
|
|
291
|
-
| Class | Element |
|
|
292
|
-
|------|------|
|
|
293
|
-
| `.split-char` | Character span |
|
|
294
|
-
| `.split-word` | Word span |
|
|
295
|
-
| `.split-line` | Line span |
|
|
296
|
-
|
|
297
|
-
Split elements receive index attributes:
|
|
298
|
-
|
|
299
|
-
- `data-char-index`
|
|
300
|
-
- `data-word-index`
|
|
301
|
-
- `data-line-index`
|
|
302
|
-
|
|
303
|
-
## Font Loading
|
|
304
|
-
|
|
305
|
-
For stable kerning measurements in vanilla usage, wait for fonts before splitting:
|
|
306
|
-
|
|
307
|
-
```ts
|
|
308
|
-
document.fonts.ready.then(() => {
|
|
309
|
-
const { words } = splitText(element);
|
|
310
|
-
animate(words, { opacity: [0, 1] });
|
|
311
|
-
});
|
|
312
|
-
```
|
|
313
|
-
|
|
314
|
-
React and Motion components wait for fonts by default (`waitForFonts={true}`).
|
|
315
|
-
|
|
316
|
-
## Accessibility
|
|
317
|
-
|
|
318
|
-
Fetta keeps split text readable by screen readers:
|
|
319
|
-
|
|
320
|
-
- Headings/landmarks: split nodes are hidden from assistive tech and parent gets `aria-label`.
|
|
321
|
-
- Generic/nested content: visual split output is hidden and a screen-reader copy preserves semantics.
|
|
322
|
-
- Existing `aria-label` values are preserved.
|
|
323
|
-
|
|
324
194
|
## Notes
|
|
325
195
|
|
|
326
|
-
- Ligatures are disabled (`font-variant-ligatures: none`) because ligatures
|
|
327
|
-
-
|
|
328
|
-
-
|
|
329
|
-
|
|
330
|
-
## Browser Support
|
|
331
|
-
|
|
332
|
-
All modern browsers: Chrome, Firefox, Safari, Edge.
|
|
333
|
-
|
|
334
|
-
Requires:
|
|
335
|
-
|
|
336
|
-
- `ResizeObserver`
|
|
337
|
-
- `IntersectionObserver`
|
|
338
|
-
- `Intl.Segmenter`
|
|
339
|
-
|
|
340
|
-
Safari kerning compensation works, but font rendering precision can vary slightly by font. If you notice subtle shifts around `revert()`, use `disableKerning: true`.
|
|
196
|
+
- Ligatures are disabled (`font-variant-ligatures: none`) because ligatures can't span multiple elements.
|
|
197
|
+
- React and Motion components wait for fonts by default (`waitForFonts`). In vanilla, wrap calls in `document.fonts.ready`.
|
|
198
|
+
- Accessibility is automatic: headings get `aria-label`, generic elements get a screen-reader-only copy.
|
|
341
199
|
|
|
342
|
-
##
|
|
200
|
+
## Sponsors
|
|
343
201
|
|
|
344
|
-
|
|
345
|
-
- https://fetta.dimi.me/installation
|
|
346
|
-
- https://fetta.dimi.me/api/core
|
|
347
|
-
- https://fetta.dimi.me/api/react
|
|
348
|
-
- https://fetta.dimi.me/api/motion
|
|
349
|
-
- https://fetta.dimi.me/api/helpers
|
|
350
|
-
- https://fetta.dimi.me/examples/vanilla
|
|
351
|
-
- https://fetta.dimi.me/examples/react
|
|
352
|
-
- https://fetta.dimi.me/examples/motion
|
|
202
|
+
If you find Fetta useful, consider [sponsoring the project](https://github.com/sponsors/dimicx) to support continued development.
|
|
353
203
|
|
|
354
204
|
## License
|
|
355
205
|
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -28,18 +28,6 @@ function reapplyInitialClasses(elements, className) {
|
|
|
28
28
|
}
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
// src/internal/waitForFontsReady.ts
|
|
32
|
-
async function waitForFontsReady(waitForFonts) {
|
|
33
|
-
if (!waitForFonts) return;
|
|
34
|
-
const fonts = document.fonts;
|
|
35
|
-
const ready = fonts?.ready;
|
|
36
|
-
if (!ready || typeof ready.then !== "function") return;
|
|
37
|
-
try {
|
|
38
|
-
await ready;
|
|
39
|
-
} catch {
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
|
|
43
31
|
// src/internal/viewportObserver.ts
|
|
44
32
|
function createViewportObserver(options, hasTriggeredOnceRef, onEnter, onLeave) {
|
|
45
33
|
const vpOptions = options ?? {};
|
|
@@ -70,4 +58,4 @@ function createViewportObserver(options, hasTriggeredOnceRef, onEnter, onLeave)
|
|
|
70
58
|
);
|
|
71
59
|
}
|
|
72
60
|
|
|
73
|
-
export { createViewportObserver, reapplyInitialClasses, reapplyInitialStyles
|
|
61
|
+
export { createViewportObserver, reapplyInitialClasses, reapplyInitialStyles };
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// src/internal/waitForFontsReady.ts
|
|
2
|
+
async function waitForFontsReady(waitForFonts) {
|
|
3
|
+
if (!waitForFonts) return;
|
|
4
|
+
const fonts = document.fonts;
|
|
5
|
+
const ready = fonts?.ready;
|
|
6
|
+
if (!ready || typeof ready.then !== "function") return;
|
|
7
|
+
try {
|
|
8
|
+
await ready;
|
|
9
|
+
} catch {
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export { waitForFontsReady };
|
package/dist/morph.d.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { AnimationOptions } from 'motion';
|
|
2
|
+
import { HTMLMotionProps } from 'motion/react';
|
|
3
|
+
import { CSSProperties, RefAttributes } from 'react';
|
|
4
|
+
|
|
5
|
+
interface MorphVariantInfo {
|
|
6
|
+
/** Index of this element among all animated elements */
|
|
7
|
+
index: number;
|
|
8
|
+
/** Total number of animated elements */
|
|
9
|
+
count: number;
|
|
10
|
+
}
|
|
11
|
+
type StaticMotionInitialProp = HTMLMotionProps<"div">["initial"];
|
|
12
|
+
type StaticMotionAnimateProp = HTMLMotionProps<"div">["animate"];
|
|
13
|
+
type StaticMotionExitProp = HTMLMotionProps<"div">["exit"];
|
|
14
|
+
type MotionInitialProp = StaticMotionInitialProp | ((info: MorphVariantInfo) => StaticMotionInitialProp);
|
|
15
|
+
type MotionAnimateProp = StaticMotionAnimateProp | ((info: MorphVariantInfo) => StaticMotionAnimateProp);
|
|
16
|
+
type MotionExitProp = StaticMotionExitProp | ((info: MorphVariantInfo) => StaticMotionExitProp);
|
|
17
|
+
type ControlledWrapperMotionKeys = "children" | "className" | "style" | "as" | "ref" | "transition" | "reducedMotion" | "initial" | "animate" | "exit";
|
|
18
|
+
type WrapperMotionProps = Omit<HTMLMotionProps<"div">, ControlledWrapperMotionKeys>;
|
|
19
|
+
interface MorphTextProps extends WrapperMotionProps {
|
|
20
|
+
children: string;
|
|
21
|
+
as?: keyof HTMLElementTagNameMap;
|
|
22
|
+
className?: string;
|
|
23
|
+
style?: CSSProperties;
|
|
24
|
+
transition?: AnimationOptions;
|
|
25
|
+
waitForFonts?: boolean;
|
|
26
|
+
reducedMotion?: "user" | "always" | "never";
|
|
27
|
+
splitBy?: "chars" | "words";
|
|
28
|
+
animateInitial?: boolean;
|
|
29
|
+
initial?: MotionInitialProp;
|
|
30
|
+
animate?: MotionAnimateProp;
|
|
31
|
+
exit?: MotionExitProp;
|
|
32
|
+
stagger?: number;
|
|
33
|
+
onMorphComplete?: () => void;
|
|
34
|
+
}
|
|
35
|
+
type MorphTextComponent = (props: MorphTextProps & RefAttributes<HTMLElement>) => React.ReactElement | null;
|
|
36
|
+
declare const MorphText: MorphTextComponent;
|
|
37
|
+
|
|
38
|
+
export { MorphText, type MorphTextProps, type MorphVariantInfo };
|
package/dist/morph.js
ADDED
|
@@ -0,0 +1,556 @@
|
|
|
1
|
+
import './chunk-HSIMGGOI.js';
|
|
2
|
+
import { waitForFontsReady } from './chunk-V23SUR2S.js';
|
|
3
|
+
import { splitTextData } from './chunk-44MU2I5B.js';
|
|
4
|
+
import { useReducedMotion, motion, AnimatePresence, MotionConfig } from 'motion/react';
|
|
5
|
+
import { forwardRef, useRef, useCallback, useState, useMemo, useEffect, createElement } from 'react';
|
|
6
|
+
|
|
7
|
+
// src/internal/splitIdentity.ts
|
|
8
|
+
var DEFAULT_ID_PREFIX = "c";
|
|
9
|
+
function nextIdentityId(prefix, counter) {
|
|
10
|
+
return `${prefix}${counter}`;
|
|
11
|
+
}
|
|
12
|
+
function findMatchesByLcs(prevValues, nextValues) {
|
|
13
|
+
const n = prevValues.length;
|
|
14
|
+
const m = nextValues.length;
|
|
15
|
+
const dp = Array.from(
|
|
16
|
+
{ length: n + 1 },
|
|
17
|
+
() => new Array(m + 1).fill(0)
|
|
18
|
+
);
|
|
19
|
+
for (let i2 = n - 1; i2 >= 0; i2--) {
|
|
20
|
+
for (let j2 = m - 1; j2 >= 0; j2--) {
|
|
21
|
+
if (prevValues[i2] === nextValues[j2]) {
|
|
22
|
+
dp[i2][j2] = dp[i2 + 1][j2 + 1] + 1;
|
|
23
|
+
} else {
|
|
24
|
+
dp[i2][j2] = Math.max(dp[i2 + 1][j2], dp[i2][j2 + 1]);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
const matches = [];
|
|
29
|
+
let i = 0;
|
|
30
|
+
let j = 0;
|
|
31
|
+
while (i < n && j < m) {
|
|
32
|
+
if (prevValues[i] === nextValues[j]) {
|
|
33
|
+
matches.push([i, j]);
|
|
34
|
+
i += 1;
|
|
35
|
+
j += 1;
|
|
36
|
+
} else if (dp[i + 1][j] >= dp[i][j + 1]) {
|
|
37
|
+
i += 1;
|
|
38
|
+
} else {
|
|
39
|
+
j += 1;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return matches;
|
|
43
|
+
}
|
|
44
|
+
function reconcileSplitIdentity(prevSnapshot, nextValuesInput, options = {}) {
|
|
45
|
+
const unit = options.unit ?? "chars";
|
|
46
|
+
const idPrefix = typeof options.idPrefix === "string" && options.idPrefix.length > 0 ? options.idPrefix : unit === "words" ? "w" : DEFAULT_ID_PREFIX;
|
|
47
|
+
const prevIds = prevSnapshot?.ids ?? [];
|
|
48
|
+
const prevValues = prevSnapshot?.values ?? [];
|
|
49
|
+
const nextValues = Array.from(nextValuesInput);
|
|
50
|
+
let nextIdCounter = prevSnapshot?.nextId ?? 0;
|
|
51
|
+
const matches = findMatchesByLcs(prevValues, nextValues);
|
|
52
|
+
const prevIndexByNextIndex = new Array(nextValues.length).fill(-1);
|
|
53
|
+
const nextIndexByPrevIndex = new Array(prevValues.length).fill(-1);
|
|
54
|
+
matches.forEach(([prevIndex, nextIndex]) => {
|
|
55
|
+
prevIndexByNextIndex[nextIndex] = prevIndex;
|
|
56
|
+
nextIndexByPrevIndex[prevIndex] = nextIndex;
|
|
57
|
+
});
|
|
58
|
+
const nextIds = new Array(nextValues.length);
|
|
59
|
+
for (let nextIndex = 0; nextIndex < nextValues.length; nextIndex++) {
|
|
60
|
+
const prevIndex = prevIndexByNextIndex[nextIndex];
|
|
61
|
+
if (prevIndex >= 0 && prevIds[prevIndex]) {
|
|
62
|
+
nextIds[nextIndex] = prevIds[prevIndex];
|
|
63
|
+
} else {
|
|
64
|
+
nextIds[nextIndex] = nextIdentityId(idPrefix, nextIdCounter);
|
|
65
|
+
nextIdCounter += 1;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const changes = [];
|
|
69
|
+
for (let nextIndex = 0; nextIndex < nextValues.length; nextIndex++) {
|
|
70
|
+
const prevIndex = prevIndexByNextIndex[nextIndex];
|
|
71
|
+
const id = nextIds[nextIndex];
|
|
72
|
+
if (prevIndex >= 0) {
|
|
73
|
+
changes.push({
|
|
74
|
+
id,
|
|
75
|
+
status: "persist",
|
|
76
|
+
value: nextValues[nextIndex],
|
|
77
|
+
prevIndex,
|
|
78
|
+
nextIndex
|
|
79
|
+
});
|
|
80
|
+
} else {
|
|
81
|
+
changes.push({
|
|
82
|
+
id,
|
|
83
|
+
status: "enter",
|
|
84
|
+
value: nextValues[nextIndex],
|
|
85
|
+
nextIndex
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
for (let prevIndex = 0; prevIndex < prevValues.length; prevIndex++) {
|
|
90
|
+
if (nextIndexByPrevIndex[prevIndex] >= 0) continue;
|
|
91
|
+
const id = prevIds[prevIndex] ?? nextIdentityId(idPrefix, nextIdCounter);
|
|
92
|
+
if (!prevIds[prevIndex]) {
|
|
93
|
+
nextIdCounter += 1;
|
|
94
|
+
}
|
|
95
|
+
changes.push({
|
|
96
|
+
id,
|
|
97
|
+
status: "exit",
|
|
98
|
+
value: prevValues[prevIndex] ?? "",
|
|
99
|
+
prevIndex
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
return {
|
|
103
|
+
snapshot: {
|
|
104
|
+
unit,
|
|
105
|
+
ids: nextIds,
|
|
106
|
+
values: nextValues,
|
|
107
|
+
nextId: nextIdCounter
|
|
108
|
+
},
|
|
109
|
+
changes
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
var SPLIT_ID_ATTR = "data-fetta-id";
|
|
113
|
+
var DEFAULT_ENTER_STATE = { opacity: 0 };
|
|
114
|
+
var DEFAULT_ANIMATE_STATE = { opacity: 1 };
|
|
115
|
+
var DEFAULT_EXIT_STATE = { opacity: 0 };
|
|
116
|
+
var DEFAULT_TRANSITION = { type: "spring", bounce: 0, duration: 0.4 };
|
|
117
|
+
function parseStyleValue(styleText) {
|
|
118
|
+
const style = {};
|
|
119
|
+
const parts = styleText.split(";").map((part) => part.trim());
|
|
120
|
+
for (const part of parts) {
|
|
121
|
+
if (!part) continue;
|
|
122
|
+
const [rawKey, ...rawValueParts] = part.split(":");
|
|
123
|
+
if (!rawKey || rawValueParts.length === 0) continue;
|
|
124
|
+
const rawValue = rawValueParts.join(":").trim();
|
|
125
|
+
const key = rawKey.trim();
|
|
126
|
+
if (key.startsWith("--")) {
|
|
127
|
+
style[key] = rawValue;
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
const camelKey = key.replace(
|
|
131
|
+
/-([a-z])/g,
|
|
132
|
+
(_, char) => char.toUpperCase()
|
|
133
|
+
);
|
|
134
|
+
style[camelKey] = rawValue;
|
|
135
|
+
}
|
|
136
|
+
return style;
|
|
137
|
+
}
|
|
138
|
+
function attrsToProps(attrs) {
|
|
139
|
+
const props = {};
|
|
140
|
+
for (const [name, value] of Object.entries(attrs)) {
|
|
141
|
+
if (name === "class") {
|
|
142
|
+
props.className = value;
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
if (name === "style") {
|
|
146
|
+
props.style = parseStyleValue(value);
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
props[name] = value;
|
|
150
|
+
}
|
|
151
|
+
return props;
|
|
152
|
+
}
|
|
153
|
+
var motionComponentCache = /* @__PURE__ */ new Map();
|
|
154
|
+
function getMotionComponent(tag) {
|
|
155
|
+
let component = motionComponentCache.get(tag);
|
|
156
|
+
if (!component) {
|
|
157
|
+
const registry = motion;
|
|
158
|
+
component = registry[tag] ?? motion.span;
|
|
159
|
+
motionComponentCache.set(tag, component);
|
|
160
|
+
}
|
|
161
|
+
return component;
|
|
162
|
+
}
|
|
163
|
+
function readSerializedNodeText(node) {
|
|
164
|
+
if (node.type === "text") {
|
|
165
|
+
return node.text;
|
|
166
|
+
}
|
|
167
|
+
return node.children.map(readSerializedNodeText).join("");
|
|
168
|
+
}
|
|
169
|
+
function collectRawTokens(nodes, splitBy = "chars") {
|
|
170
|
+
const rawTokens = [];
|
|
171
|
+
let nonCharIndex = 0;
|
|
172
|
+
const targetSplit = splitBy === "words" ? "word" : "char";
|
|
173
|
+
const walk = (list) => {
|
|
174
|
+
for (const node of list) {
|
|
175
|
+
if (node.type === "text") {
|
|
176
|
+
if (node.text.length > 0) {
|
|
177
|
+
rawTokens.push({
|
|
178
|
+
tokenType: "text",
|
|
179
|
+
key: `t-${nonCharIndex++}`,
|
|
180
|
+
value: node.text
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
if (node.attrs["data-fetta-sr-copy"] === "true") {
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
if (node.tag === "br") {
|
|
189
|
+
rawTokens.push({
|
|
190
|
+
tokenType: "br",
|
|
191
|
+
key: `br-${nonCharIndex++}`,
|
|
192
|
+
attrs: node.attrs
|
|
193
|
+
});
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
if (node.split === targetSplit) {
|
|
197
|
+
rawTokens.push({
|
|
198
|
+
tokenType: splitBy === "words" ? "word" : "char",
|
|
199
|
+
node,
|
|
200
|
+
value: readSerializedNodeText(node)
|
|
201
|
+
});
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
walk(node.children);
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
walk(nodes);
|
|
208
|
+
return rawTokens;
|
|
209
|
+
}
|
|
210
|
+
function buildRenderTokens(nodes, previousSnapshot, splitBy = "chars") {
|
|
211
|
+
const rawTokens = collectRawTokens(nodes, splitBy);
|
|
212
|
+
const unitTokenType = splitBy === "words" ? "word" : "char";
|
|
213
|
+
const rawUnits = rawTokens.filter(
|
|
214
|
+
(token) => token.tokenType === unitTokenType
|
|
215
|
+
);
|
|
216
|
+
const diff = reconcileSplitIdentity(
|
|
217
|
+
previousSnapshot,
|
|
218
|
+
rawUnits.map((token) => token.value),
|
|
219
|
+
{ unit: splitBy }
|
|
220
|
+
);
|
|
221
|
+
const statusByNextIndex = /* @__PURE__ */ new Map();
|
|
222
|
+
let hasExits = false;
|
|
223
|
+
for (const change of diff.changes) {
|
|
224
|
+
if (typeof change.nextIndex === "number") {
|
|
225
|
+
statusByNextIndex.set(change.nextIndex, change.status);
|
|
226
|
+
}
|
|
227
|
+
if (change.status === "exit") {
|
|
228
|
+
hasExits = true;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
const fallbackPrefix = splitBy === "words" ? "w" : "c";
|
|
232
|
+
let unitIndex = 0;
|
|
233
|
+
const intermediateTokens = rawTokens.map((token) => {
|
|
234
|
+
if (token.tokenType === "text") {
|
|
235
|
+
return token;
|
|
236
|
+
}
|
|
237
|
+
if (token.tokenType === "br") {
|
|
238
|
+
return {
|
|
239
|
+
tokenType: "br",
|
|
240
|
+
key: token.key,
|
|
241
|
+
props: attrsToProps(token.attrs)
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
const id = diff.snapshot.ids[unitIndex] ?? `${fallbackPrefix}-fallback-${unitIndex}`;
|
|
245
|
+
const status = statusByNextIndex.get(unitIndex) ?? "persist";
|
|
246
|
+
unitIndex += 1;
|
|
247
|
+
return {
|
|
248
|
+
tokenType: unitTokenType,
|
|
249
|
+
id,
|
|
250
|
+
status,
|
|
251
|
+
tag: token.node.tag,
|
|
252
|
+
props: attrsToProps(token.node.attrs),
|
|
253
|
+
value: token.value
|
|
254
|
+
};
|
|
255
|
+
});
|
|
256
|
+
let tokens;
|
|
257
|
+
if (splitBy === "words") {
|
|
258
|
+
tokens = [];
|
|
259
|
+
let prevWasWord = false;
|
|
260
|
+
for (const token of intermediateTokens) {
|
|
261
|
+
if (token.tokenType === "word" && prevWasWord) {
|
|
262
|
+
tokens.push({
|
|
263
|
+
tokenType: "space",
|
|
264
|
+
id: `sp-${token.id}`,
|
|
265
|
+
status: token.status
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
tokens.push(token);
|
|
269
|
+
if (token.tokenType === "word" || token.tokenType === "br") {
|
|
270
|
+
prevWasWord = token.tokenType === "word";
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
} else {
|
|
274
|
+
tokens = [];
|
|
275
|
+
for (let i = 0; i < intermediateTokens.length; i++) {
|
|
276
|
+
const token = intermediateTokens[i];
|
|
277
|
+
if (token.tokenType === "text" && /^\s+$/.test(token.value)) {
|
|
278
|
+
const next = intermediateTokens[i + 1];
|
|
279
|
+
const prev = intermediateTokens[i - 1];
|
|
280
|
+
const neighbor = next?.tokenType === "char" ? next : prev?.tokenType === "char" ? prev : null;
|
|
281
|
+
const status = neighbor ? neighbor.status : "persist";
|
|
282
|
+
const prevId = prev?.tokenType === "char" ? prev.id : "start";
|
|
283
|
+
const nextId = next?.tokenType === "char" ? next.id : "end";
|
|
284
|
+
tokens.push({
|
|
285
|
+
tokenType: "space",
|
|
286
|
+
id: `sp-${prevId}-${nextId}`,
|
|
287
|
+
status
|
|
288
|
+
});
|
|
289
|
+
} else {
|
|
290
|
+
tokens.push(token);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
let unitCount = 0;
|
|
295
|
+
let enterCount = 0;
|
|
296
|
+
for (const token of tokens) {
|
|
297
|
+
if (token.tokenType === "char" || token.tokenType === "word" || token.tokenType === "space") {
|
|
298
|
+
token.index = unitCount;
|
|
299
|
+
unitCount += 1;
|
|
300
|
+
if (token.status === "enter") {
|
|
301
|
+
token.enterIndex = enterCount;
|
|
302
|
+
enterCount += 1;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
return {
|
|
307
|
+
snapshot: diff.snapshot,
|
|
308
|
+
tokens,
|
|
309
|
+
enterCount,
|
|
310
|
+
unitCount,
|
|
311
|
+
hasExits
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
var MorphText = forwardRef(function MorphText2({
|
|
315
|
+
children,
|
|
316
|
+
as: Component = "span",
|
|
317
|
+
className,
|
|
318
|
+
style: userStyle,
|
|
319
|
+
transition,
|
|
320
|
+
waitForFonts = true,
|
|
321
|
+
reducedMotion = "user",
|
|
322
|
+
splitBy = "chars",
|
|
323
|
+
animateInitial = false,
|
|
324
|
+
initial: initialProp,
|
|
325
|
+
animate: animateProp,
|
|
326
|
+
exit: exitProp,
|
|
327
|
+
stagger: staggerProp,
|
|
328
|
+
onMorphComplete,
|
|
329
|
+
...wrapperProps
|
|
330
|
+
}, forwardedRef) {
|
|
331
|
+
const wrapperRef = useRef(null);
|
|
332
|
+
const mergedRef = useCallback(
|
|
333
|
+
(node) => {
|
|
334
|
+
wrapperRef.current = node;
|
|
335
|
+
if (typeof forwardedRef === "function") {
|
|
336
|
+
forwardedRef(node);
|
|
337
|
+
} else if (forwardedRef) {
|
|
338
|
+
forwardedRef.current = node;
|
|
339
|
+
}
|
|
340
|
+
},
|
|
341
|
+
[forwardedRef]
|
|
342
|
+
);
|
|
343
|
+
const enterState = initialProp === false ? false : initialProp ?? DEFAULT_ENTER_STATE;
|
|
344
|
+
const animateState = animateProp ?? DEFAULT_ANIMATE_STATE;
|
|
345
|
+
const exitState = exitProp ?? DEFAULT_EXIT_STATE;
|
|
346
|
+
const [tokens, setTokens] = useState(null);
|
|
347
|
+
const [unitCount, setUnitCount] = useState(0);
|
|
348
|
+
const [isReady, setIsReady] = useState(false);
|
|
349
|
+
const snapshotRef = useRef(null);
|
|
350
|
+
const prefersReducedMotion = useReducedMotion();
|
|
351
|
+
const reduceMotionActive = reducedMotion === "always" || reducedMotion === "user" && !!prefersReducedMotion;
|
|
352
|
+
const reducedTransition = useMemo(
|
|
353
|
+
() => ({ duration: 0, delay: 0, layout: { duration: 0 } }),
|
|
354
|
+
[]
|
|
355
|
+
);
|
|
356
|
+
const resolvedTransition = reduceMotionActive ? reducedTransition : transition ?? DEFAULT_TRANSITION;
|
|
357
|
+
const transitionRef = useRef(resolvedTransition);
|
|
358
|
+
transitionRef.current = resolvedTransition;
|
|
359
|
+
const onMorphCompleteRef = useRef(onMorphComplete);
|
|
360
|
+
onMorphCompleteRef.current = onMorphComplete;
|
|
361
|
+
const enterRemainingRef = useRef(0);
|
|
362
|
+
const exitsActiveRef = useRef(false);
|
|
363
|
+
const isFirstRenderRef = useRef(true);
|
|
364
|
+
const staggerRef = useRef(staggerProp);
|
|
365
|
+
staggerRef.current = staggerProp;
|
|
366
|
+
const enterStateRef = useRef(enterState);
|
|
367
|
+
enterStateRef.current = enterState;
|
|
368
|
+
const animateStateRef = useRef(animateState);
|
|
369
|
+
animateStateRef.current = animateState;
|
|
370
|
+
const exitStateRef = useRef(exitState);
|
|
371
|
+
exitStateRef.current = exitState;
|
|
372
|
+
const tryFireMorphComplete = useCallback(() => {
|
|
373
|
+
if (enterRemainingRef.current <= 0 && !exitsActiveRef.current) {
|
|
374
|
+
onMorphCompleteRef.current?.();
|
|
375
|
+
}
|
|
376
|
+
}, []);
|
|
377
|
+
const buildSplitDataFromProbe = useCallback((nextText, unit) => {
|
|
378
|
+
const wrapperElement = wrapperRef.current;
|
|
379
|
+
if (!wrapperElement) return null;
|
|
380
|
+
const parentElement = wrapperElement.parentElement;
|
|
381
|
+
if (!parentElement) return null;
|
|
382
|
+
const probeHost = wrapperElement.ownerDocument.createElement("div");
|
|
383
|
+
probeHost.style.position = "fixed";
|
|
384
|
+
probeHost.style.left = "-99999px";
|
|
385
|
+
probeHost.style.top = "0";
|
|
386
|
+
probeHost.style.visibility = "hidden";
|
|
387
|
+
probeHost.style.pointerEvents = "none";
|
|
388
|
+
probeHost.style.contain = "layout style paint";
|
|
389
|
+
probeHost.style.width = `${Math.max(1, parentElement.getBoundingClientRect().width)}px`;
|
|
390
|
+
const probeElement = wrapperElement.cloneNode(false);
|
|
391
|
+
probeElement.textContent = nextText;
|
|
392
|
+
probeHost.appendChild(probeElement);
|
|
393
|
+
parentElement.appendChild(probeHost);
|
|
394
|
+
try {
|
|
395
|
+
return splitTextData(probeElement, { type: unit === "words" ? "words" : "chars" });
|
|
396
|
+
} finally {
|
|
397
|
+
probeHost.remove();
|
|
398
|
+
}
|
|
399
|
+
}, []);
|
|
400
|
+
const measureAndSetTokens = useCallback(
|
|
401
|
+
(nextText, unit) => {
|
|
402
|
+
const nextData = buildSplitDataFromProbe(nextText, unit);
|
|
403
|
+
if (!nextData) return;
|
|
404
|
+
const { snapshot, tokens: nextTokens, enterCount, unitCount: nextUnitCount, hasExits } = buildRenderTokens(
|
|
405
|
+
nextData.nodes,
|
|
406
|
+
snapshotRef.current,
|
|
407
|
+
unit
|
|
408
|
+
);
|
|
409
|
+
snapshotRef.current = snapshot;
|
|
410
|
+
const isFirst = isFirstRenderRef.current;
|
|
411
|
+
isFirstRenderRef.current = false;
|
|
412
|
+
const skipCallback = isFirst && !animateInitial;
|
|
413
|
+
const hasAnimation = enterCount > 0 || hasExits;
|
|
414
|
+
if (!skipCallback && hasAnimation && onMorphCompleteRef.current) {
|
|
415
|
+
enterRemainingRef.current = enterCount;
|
|
416
|
+
exitsActiveRef.current = hasExits;
|
|
417
|
+
} else {
|
|
418
|
+
enterRemainingRef.current = 0;
|
|
419
|
+
exitsActiveRef.current = false;
|
|
420
|
+
}
|
|
421
|
+
setTokens(nextTokens);
|
|
422
|
+
setUnitCount(nextUnitCount);
|
|
423
|
+
setIsReady(true);
|
|
424
|
+
},
|
|
425
|
+
[buildSplitDataFromProbe, animateInitial]
|
|
426
|
+
);
|
|
427
|
+
useEffect(() => {
|
|
428
|
+
if (typeof children !== "string") return;
|
|
429
|
+
let cancelled = false;
|
|
430
|
+
waitForFontsReady(waitForFonts).then(() => {
|
|
431
|
+
if (cancelled) return;
|
|
432
|
+
measureAndSetTokens(children, splitBy);
|
|
433
|
+
});
|
|
434
|
+
return () => {
|
|
435
|
+
cancelled = true;
|
|
436
|
+
};
|
|
437
|
+
}, [children, waitForFonts, splitBy, measureAndSetTokens]);
|
|
438
|
+
const handleEnterAnimationComplete = useCallback(() => {
|
|
439
|
+
enterRemainingRef.current = Math.max(0, enterRemainingRef.current - 1);
|
|
440
|
+
tryFireMorphComplete();
|
|
441
|
+
}, [tryFireMorphComplete]);
|
|
442
|
+
const handleExitComplete = useCallback(() => {
|
|
443
|
+
exitsActiveRef.current = false;
|
|
444
|
+
tryFireMorphComplete();
|
|
445
|
+
}, [tryFireMorphComplete]);
|
|
446
|
+
const renderToken = useCallback(
|
|
447
|
+
(token) => {
|
|
448
|
+
if (token.tokenType === "text") {
|
|
449
|
+
return token.value;
|
|
450
|
+
}
|
|
451
|
+
if (token.tokenType === "br") {
|
|
452
|
+
return createElement("br", {
|
|
453
|
+
key: token.key,
|
|
454
|
+
...token.props,
|
|
455
|
+
"aria-hidden": "true"
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
const currentEnterState = enterStateRef.current;
|
|
459
|
+
const currentAnimateState = animateStateRef.current;
|
|
460
|
+
const currentExitState = exitStateRef.current;
|
|
461
|
+
const currentStagger = staggerRef.current;
|
|
462
|
+
const info = { index: token.index, count: unitCount };
|
|
463
|
+
const resolvedEnterState = typeof currentEnterState === "function" ? currentEnterState(info) : currentEnterState;
|
|
464
|
+
const resolvedAnimateState = typeof currentAnimateState === "function" ? currentAnimateState(info) : currentAnimateState;
|
|
465
|
+
const resolvedExitState = typeof currentExitState === "function" ? currentExitState(info) : currentExitState;
|
|
466
|
+
let tokenTransition = transitionRef.current;
|
|
467
|
+
if (currentStagger && typeof token.enterIndex === "number") {
|
|
468
|
+
const existingDelay = tokenTransition?.delay;
|
|
469
|
+
const baseDelay = typeof existingDelay === "number" ? existingDelay : 0;
|
|
470
|
+
tokenTransition = {
|
|
471
|
+
...tokenTransition,
|
|
472
|
+
delay: baseDelay + token.enterIndex * currentStagger
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
if (token.tokenType === "space") {
|
|
476
|
+
return createElement(
|
|
477
|
+
motion.span,
|
|
478
|
+
{
|
|
479
|
+
key: `space-${token.id}`,
|
|
480
|
+
"aria-hidden": "true",
|
|
481
|
+
layout: "position",
|
|
482
|
+
initial: token.status === "enter" ? resolvedEnterState : false,
|
|
483
|
+
animate: resolvedAnimateState,
|
|
484
|
+
exit: resolvedExitState,
|
|
485
|
+
transition: tokenTransition,
|
|
486
|
+
style: { display: "inline", whiteSpace: "pre" },
|
|
487
|
+
onAnimationComplete: token.status === "enter" ? handleEnterAnimationComplete : void 0
|
|
488
|
+
},
|
|
489
|
+
" "
|
|
490
|
+
);
|
|
491
|
+
}
|
|
492
|
+
const propsStyle = token.props.style ?? void 0;
|
|
493
|
+
const MotionTag = getMotionComponent(token.tag);
|
|
494
|
+
const keyPrefix = token.tokenType === "word" ? "word" : "char";
|
|
495
|
+
return createElement(
|
|
496
|
+
MotionTag,
|
|
497
|
+
{
|
|
498
|
+
key: `${keyPrefix}-${token.id}`,
|
|
499
|
+
...token.props,
|
|
500
|
+
"aria-hidden": "true",
|
|
501
|
+
[SPLIT_ID_ATTR]: token.id,
|
|
502
|
+
layout: "position",
|
|
503
|
+
initial: token.status === "enter" ? resolvedEnterState : false,
|
|
504
|
+
animate: resolvedAnimateState,
|
|
505
|
+
exit: resolvedExitState,
|
|
506
|
+
transition: tokenTransition,
|
|
507
|
+
style: {
|
|
508
|
+
display: "inline-block",
|
|
509
|
+
whiteSpace: "pre",
|
|
510
|
+
...propsStyle
|
|
511
|
+
},
|
|
512
|
+
onAnimationComplete: token.status === "enter" ? handleEnterAnimationComplete : void 0
|
|
513
|
+
},
|
|
514
|
+
token.value
|
|
515
|
+
);
|
|
516
|
+
},
|
|
517
|
+
[handleEnterAnimationComplete, unitCount]
|
|
518
|
+
);
|
|
519
|
+
if (typeof children !== "string") {
|
|
520
|
+
console.error("MorphText: children must be a string.");
|
|
521
|
+
return null;
|
|
522
|
+
}
|
|
523
|
+
const Wrapper = getMotionComponent(Component);
|
|
524
|
+
const wrapperStyle = {
|
|
525
|
+
...userStyle,
|
|
526
|
+
visibility: isReady || !waitForFonts ? "visible" : "hidden"
|
|
527
|
+
};
|
|
528
|
+
const wrapperAttrs = {
|
|
529
|
+
ref: mergedRef,
|
|
530
|
+
...wrapperProps,
|
|
531
|
+
className,
|
|
532
|
+
style: wrapperStyle
|
|
533
|
+
};
|
|
534
|
+
if (wrapperAttrs["aria-label"] === void 0) {
|
|
535
|
+
wrapperAttrs["aria-label"] = children;
|
|
536
|
+
}
|
|
537
|
+
const content = createElement(
|
|
538
|
+
Wrapper,
|
|
539
|
+
wrapperAttrs,
|
|
540
|
+
tokens ? createElement(
|
|
541
|
+
AnimatePresence,
|
|
542
|
+
{
|
|
543
|
+
mode: "popLayout",
|
|
544
|
+
initial: animateInitial,
|
|
545
|
+
onExitComplete: handleExitComplete
|
|
546
|
+
},
|
|
547
|
+
tokens.map(renderToken)
|
|
548
|
+
) : children
|
|
549
|
+
);
|
|
550
|
+
if (reducedMotion !== "never") {
|
|
551
|
+
return createElement(MotionConfig, { reducedMotion }, content);
|
|
552
|
+
}
|
|
553
|
+
return content;
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
export { MorphText };
|
package/dist/motion.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createViewportObserver, reapplyInitialStyles, reapplyInitialClasses } from './chunk-TEDMJHF7.js';
|
|
2
|
+
import './chunk-HSIMGGOI.js';
|
|
3
|
+
import { waitForFontsReady } from './chunk-V23SUR2S.js';
|
|
2
4
|
import { splitTextData, buildLineFingerprintFromData, normalizeToPromise, buildKerningStyleKey, resolveAutoSplitTargets, getObservedWidth, recordWidthChange, resolveAutoSplitWidth, querySplitWords, clearKerningCompensation, applyKerningCompensation } from './chunk-44MU2I5B.js';
|
|
3
5
|
import { scroll, animate } from 'motion';
|
|
4
6
|
import { usePresence, useReducedMotion, MotionConfig, motion } from 'motion/react';
|
package/dist/react.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createViewportObserver, reapplyInitialStyles, reapplyInitialClasses } from './chunk-TEDMJHF7.js';
|
|
2
|
+
import { waitForFontsReady } from './chunk-V23SUR2S.js';
|
|
2
3
|
import { splitText, normalizeToPromise } from './chunk-44MU2I5B.js';
|
|
3
4
|
import { forwardRef, useRef, useCallback, useState, useLayoutEffect, useEffect, isValidElement, createElement } from 'react';
|
|
4
5
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fetta",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.0",
|
|
4
4
|
"description": "Text splitting library with kerning compensation for animations",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -24,6 +24,10 @@
|
|
|
24
24
|
"./motion": {
|
|
25
25
|
"types": "./dist/motion.d.ts",
|
|
26
26
|
"import": "./dist/motion.js"
|
|
27
|
+
},
|
|
28
|
+
"./morph": {
|
|
29
|
+
"types": "./dist/morph.d.ts",
|
|
30
|
+
"import": "./dist/morph.js"
|
|
27
31
|
}
|
|
28
32
|
},
|
|
29
33
|
"main": "./dist/index.js",
|
|
@@ -65,6 +69,12 @@
|
|
|
65
69
|
"import": "*",
|
|
66
70
|
"ignore": ["react", "motion", "motion/react"]
|
|
67
71
|
},
|
|
72
|
+
{
|
|
73
|
+
"name": "Morph",
|
|
74
|
+
"path": "dist/morph.js",
|
|
75
|
+
"import": "*",
|
|
76
|
+
"ignore": ["react", "motion", "motion/react"]
|
|
77
|
+
},
|
|
68
78
|
{
|
|
69
79
|
"name": "Helpers",
|
|
70
80
|
"path": "dist/helpers.js",
|