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 CHANGED
@@ -2,54 +2,29 @@
2
2
 
3
3
  Text splitting that keeps kerning intact.
4
4
 
5
- Split text into chars, words, and lines while preserving original typography. `fetta` provides the framework-agnostic `splitText()` API for vanilla and imperative workflows, `fetta/react` is for React lifecycle and callback-based control, and `fetta/motion` is for Motion-first variants and triggers. `fetta/helpers` adds optional utilities for advanced layered effects.
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
- ## Installation
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 JavaScript (`fetta`)
17
+ ### Vanilla
41
18
 
42
19
  ```ts
43
20
  import { splitText } from "fetta";
44
21
  import { animate, stagger } from "motion";
45
22
 
46
- const element = document.querySelector("h1");
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 Callbacks (`fetta/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 Variants (`fetta/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
- variants={{
76
- hidden: { words: { opacity: 0, y: 20 } },
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
- `fetta/motion` supports standard Motion targets, per-type targets (`chars` / `words` / `lines` / `wrapper`), and split-aware function variants.
59
+ ### Morph
89
60
 
90
- ## API
61
+ ```tsx
62
+ import { MorphText } from "fetta/morph";
91
63
 
92
- ### `splitText(element, options?)` (`fetta`)
64
+ <MorphText>{text}</MorphText>
65
+ ```
93
66
 
94
- Splits text content into characters, words, and/or lines.
67
+ ## Features
95
68
 
96
- ```ts
97
- import { splitText } from "fetta";
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
- const result = splitText(element, options);
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
- #### Options
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
- ```ts
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
- #### Return Value
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
- interface SplitTextResult {
136
- chars: HTMLSpanElement[];
137
- words: HTMLSpanElement[];
138
- lines: HTMLSpanElement[];
139
- revert: () => void;
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>` (`fetta/react`)
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
- ```ts
186
- type CallbackReturn =
187
- | void
188
- | Promise<unknown>
189
- | { finished: Promise<unknown> }
190
- | { then: (onFulfilled?: ((result: unknown) => unknown) | undefined) => unknown }
191
- | CallbackReturn[];
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
- `fetta/react` and `fetta/motion` both forward common wrapper DOM props (`id`, `role`, `tabIndex`, `aria-*`, `data-*`, event handlers) to the wrapper.
132
+ ### `<SplitText>` — [Motion docs](https://fetta.dimi.me/api/motion)
195
133
 
196
- ### `<SplitText>` (`fetta/motion`)
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
- Variant-driven component built on Motion. Includes every `fetta/react` prop plus Motion animation/triggers (`initial`, `animate`, `exit`, `whileInView`, `whileScroll`, `whileHover`, etc.).
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
- ```tsx
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
- <AnimatePresence>
234
- {isVisible && (
235
- <SplitText
236
- variants={{
237
- enter: { opacity: 1, y: 0 },
238
- exit: { opacity: 0, y: 12 },
239
- }}
240
- initial="enter"
241
- animate="enter"
242
- exit="exit"
243
- options={{ type: "words" }}
244
- >
245
- <h1>Goodbye</h1>
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(splitResult, options)` (`fetta/helpers`, optional)
180
+ ### `createSplitClones()` — [Helpers docs](https://fetta.dimi.me/api/helpers)
252
181
 
253
- Helpers are optional utilities for advanced layered reveal/swap effects.
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 cannot span multiple elements.
327
- - Authored hard breaks are preserved (`<br>` and block boundaries are kept as hard split boundaries).
328
- - `autoSplit` width resplits are debounced by default (`100ms`), configurable via `resplitDebounceMs`.
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
- ## Docs
200
+ ## Sponsors
343
201
 
344
- - https://fetta.dimi.me/
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, waitForFontsReady };
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 };
@@ -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 { waitForFontsReady, createViewportObserver, reapplyInitialStyles, reapplyInitialClasses } from './chunk-PA22PLRB.js';
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 { waitForFontsReady, createViewportObserver, reapplyInitialStyles, reapplyInitialClasses } from './chunk-PA22PLRB.js';
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.5.5",
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",