fetta 1.5.4 → 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,194 +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
- | `onResplit` | `(result) => void` | — | Callback after autoSplit/full-resplit replaces split output elements |
113
- | `onSplit` | `(result) => CallbackReturn` | — | Callback after initial split. Return animation/promise for `revertOnComplete` |
114
- | `revertOnComplete` | `boolean` | `false` | Auto-revert when returned animation completes |
115
- | `propIndex` | `boolean` | `false` | Add CSS custom properties: `--char-index`, `--word-index`, `--line-index` |
116
- | `disableKerning` | `boolean` | `false` | Skip kerning compensation (no margin adjustments) |
117
- | `initialStyles` | `object` | — | Apply initial inline styles to chars/words/lines after split. Values can be objects or `(el, index) => object` functions |
118
- | `initialClasses` | `object` | — | Apply initial CSS classes to chars/words/lines. Values are strings |
91
+ Sizes are minified + brotli.
119
92
 
120
- ```ts
121
- type SplitType =
122
- | "chars"
123
- | "words"
124
- | "lines"
125
- | "chars,words"
126
- | "words,lines"
127
- | "chars,lines"
128
- | "chars,words,lines";
129
- ```
93
+ ## API Overview
130
94
 
131
- #### 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`.
132
100
 
133
101
  ```ts
134
- interface SplitTextResult {
135
- chars: HTMLSpanElement[];
136
- words: HTMLSpanElement[];
137
- lines: HTMLSpanElement[];
138
- revert: () => void;
139
- }
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
+ });
140
109
  ```
141
110
 
142
- ### `<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`.
143
114
 
144
115
  ```tsx
145
116
  import { SplitText } from "fetta/react";
146
- ```
147
-
148
- `fetta/react` wraps `splitText()` for React with lifecycle hooks, viewport callbacks, and automatic cleanup.
149
-
150
- #### React Props
151
-
152
- | Prop | Type | Default | Description |
153
- |------|------|------|------|
154
- | `children` | `ReactElement` | — | Single React element to split |
155
- | `as` | `keyof JSX.IntrinsicElements` | `"div"` | Wrapper element type |
156
- | `className` | `string` | — | Wrapper class name |
157
- | `style` | `CSSProperties` | — | Wrapper styles |
158
- | `ref` | `Ref<HTMLElement>` | — | Ref to wrapper element |
159
- | `onSplit` | `(result) => CallbackReturn` | — | Called after initial split |
160
- | `onResplit` | `(result) => void` | — | Called when autoSplit/full-resplit replaces split output elements |
161
- | `options` | `SplitTextOptions` | — | Split options (`type`, classes, mask, etc.) |
162
- | `autoSplit` | `boolean` | `false` | Re-split on container resize |
163
- | `waitForFonts` | `boolean` | `true` | Wait for `document.fonts.ready` before splitting |
164
- | `revertOnComplete` | `boolean` | `false` | Revert after animation completes |
165
- | `onRevert` | `() => void` | — | Called when split text reverts |
166
- | `viewport` | `ViewportOptions` | — | Configure viewport detection |
167
- | `onViewportEnter` | `(result) => CallbackReturn` | — | Called when entering viewport |
168
- | `onViewportLeave` | `(result) => CallbackReturn` | — | Called when leaving viewport |
169
- | `initialStyles` | `object` | — | Apply initial inline styles to chars/words/lines |
170
- | `initialClasses` | `object` | — | Apply initial CSS classes to chars/words/lines |
171
- | `resetOnViewportLeave` | `boolean` | `false` | Re-apply initial styles/classes when leaving viewport |
172
-
173
- All callbacks receive:
174
-
175
- ```ts
176
- {
177
- chars: HTMLSpanElement[];
178
- words: HTMLSpanElement[];
179
- lines: HTMLSpanElement[];
180
- revert: () => void;
181
- }
182
- ```
117
+ import { animate, stagger } from "motion";
183
118
 
184
- ```ts
185
- type CallbackReturn =
186
- | void
187
- | Promise<unknown>
188
- | { finished: Promise<unknown> }
189
- | { then: (onFulfilled?: ((result: unknown) => unknown) | undefined) => unknown }
190
- | 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>
191
130
  ```
192
131
 
193
- `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)
194
133
 
195
- ### `<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.
196
135
 
197
136
  ```tsx
198
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>
199
159
  ```
200
160
 
201
- Variant-driven component built on Motion. Includes every `fetta/react` prop plus Motion animation/triggers (`initial`, `animate`, `exit`, `whileInView`, `whileScroll`, `whileHover`, etc.).
202
-
203
- #### Motion-only Props
204
-
205
- | Prop | Type | Default | Description |
206
- |------|------|------|------|
207
- | `variants` | `Record<string, VariantDefinition<TCustom>>` | — | Named variant definitions |
208
- | `initial` | `string \| VariantDefinition<TCustom> \| false` | — | Initial variant applied instantly after split |
209
- | `animate` | `string \| VariantDefinition<TCustom>` | — | Base variant |
210
- | `exit` | `string \| VariantDefinition<TCustom> \| false` | — | Exit variant (`AnimatePresence`) |
211
- | `whileInView` | `string \| VariantDefinition<TCustom>` | — | Variant while element is in view |
212
- | `whileOutOfView` | `string \| VariantDefinition<TCustom>` | — | Variant after element leaves view |
213
- | `whileScroll` | `string \| VariantDefinition<TCustom>` | — | Scroll-driven variant (highest trigger priority) |
214
- | `whileHover` | `string \| VariantDefinition<TCustom>` | — | Variant on hover |
215
- | `whileTap` | `string \| VariantDefinition<TCustom>` | — | Variant on tap/press |
216
- | `whileFocus` | `string \| VariantDefinition<TCustom>` | — | Variant on focus |
217
- | `animateOnResplit` | `boolean` | `false` | Replay `initial -> animate` on autoSplit/full-resplit |
218
- | `scroll` | `{ offset?, axis?, container? }` | — | Scroll tracking options for `whileScroll` |
219
- | `transition` | `AnimationOptions` | — | Global/default transition for variants |
220
- | `custom` | `TCustom` | — | Custom data forwarded to function variants |
221
- | `delayScope` | `"global" \| "local"` | `"global"` | Delay-function index scope (`globalIndex` vs local `index`) |
222
- | `reducedMotion` | `"user" \| "always" \| "never"` | `"never"` | Reduced-motion behavior for this component |
223
- | `onHoverStart` | `() => void` | — | Called when hover starts |
224
- | `onHoverEnd` | `() => void` | — | Called when hover ends |
225
-
226
- Exit animations use standard Motion behavior (`SplitText` must be a direct child of `AnimatePresence`):
161
+ ### `<MorphText>` [Morph docs](https://fetta.dimi.me/api/morph)
227
162
 
228
- ```tsx
229
- import { AnimatePresence } from "motion/react";
230
- 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.
231
164
 
232
- <AnimatePresence>
233
- {isVisible && (
234
- <SplitText
235
- variants={{
236
- enter: { opacity: 1, y: 0 },
237
- exit: { opacity: 0, y: 12 },
238
- }}
239
- initial="enter"
240
- animate="enter"
241
- exit="exit"
242
- options={{ type: "words" }}
243
- >
244
- <h1>Goodbye</h1>
245
- </SplitText>
246
- )}
247
- </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>
248
178
  ```
249
179
 
250
- ### `createSplitClones(splitResult, options)` (`fetta/helpers`, optional)
180
+ ### `createSplitClones()` — [Helpers docs](https://fetta.dimi.me/api/helpers)
251
181
 
252
- 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.
253
183
 
254
184
  ```ts
255
185
  import { splitText } from "fetta";
@@ -257,97 +187,19 @@ import { createSplitClones } from "fetta/helpers";
257
187
 
258
188
  const split = splitText(element, { type: "chars", mask: "chars" });
259
189
  const layers = createSplitClones(split, { unit: "chars", wrap: true });
190
+ // animate layers.originals + layers.clones...
191
+ layers.cleanup();
260
192
  ```
261
193
 
262
- #### Helper Options
263
-
264
- | Option | Type | Default | Description |
265
- |------|------|------|------|
266
- | `unit` | `"chars" \| "words" \| "lines"` | — | Which split nodes to layer |
267
- | `wrap` | `boolean` | `false` | Wrap each original in a track wrapper (`position: relative`) |
268
- | `display` | `"auto" \| "inline-block" \| "block"` | `"auto"` | Track display when `wrap: true` (`lines` => `block`, others => `inline-block`) |
269
- | `cloneOffset.axis` | `"x" \| "y"` | `"y"` | Axis used for initial clone offset |
270
- | `cloneOffset.direction` | `"start" \| "end"` | `"start"` | Offset direction (`start` => negative) |
271
- | `cloneOffset.distance` | `string` | `"100%"` | Offset distance |
272
- | `trackClassName` / `cloneClassName` | `string \| (ctx) => string \| undefined` | — | Class names (static or per-item) |
273
- | `trackStyle` / `cloneStyle` | `object \| (ctx) => object` | — | Inline styles (static or per-item) |
274
-
275
- #### Helper Behavior
276
-
277
- - Helper does not call `splitText()`; pass an existing split result.
278
- - Clone is appended to the current parent of each original split node.
279
- - `wrap: false` appends clone to existing parent.
280
- - `wrap: true` moves original into a generated track, then appends clone there.
281
- - `cleanup()` removes helper-created tracks/clones and is idempotent.
282
- - `cleanup({ revertSplit: true })` also calls `split.revert()`.
283
-
284
- For reveal/swap effects, match helper `unit` with `splitText` `mask` (`"chars"`, `"words"`, `"lines"`).
285
-
286
- ## CSS Classes
287
-
288
- Default classes applied to split output:
289
-
290
- | Class | Element |
291
- |------|------|
292
- | `.split-char` | Character span |
293
- | `.split-word` | Word span |
294
- | `.split-line` | Line span |
295
-
296
- Split elements receive index attributes:
297
-
298
- - `data-char-index`
299
- - `data-word-index`
300
- - `data-line-index`
301
-
302
- ## Font Loading
303
-
304
- For stable kerning measurements in vanilla usage, wait for fonts before splitting:
305
-
306
- ```ts
307
- document.fonts.ready.then(() => {
308
- const { words } = splitText(element);
309
- animate(words, { opacity: [0, 1] });
310
- });
311
- ```
312
-
313
- React and Motion components wait for fonts by default (`waitForFonts={true}`).
314
-
315
- ## Accessibility
316
-
317
- Fetta keeps split text readable by screen readers:
318
-
319
- - Headings/landmarks: split nodes are hidden from assistive tech and parent gets `aria-label`.
320
- - Generic/nested content: visual split output is hidden and a screen-reader copy preserves semantics.
321
- - Existing `aria-label` values are preserved.
322
-
323
194
  ## Notes
324
195
 
325
- - Ligatures are disabled (`font-variant-ligatures: none`) because ligatures cannot span multiple elements.
326
- - Authored hard breaks are preserved (`<br>` and block boundaries are kept as hard split boundaries).
327
-
328
- ## Browser Support
329
-
330
- All modern browsers: Chrome, Firefox, Safari, Edge.
331
-
332
- Requires:
333
-
334
- - `ResizeObserver`
335
- - `IntersectionObserver`
336
- - `Intl.Segmenter`
337
-
338
- 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.
339
199
 
340
- ## Docs
200
+ ## Sponsors
341
201
 
342
- - https://fetta.dimi.me/
343
- - https://fetta.dimi.me/installation
344
- - https://fetta.dimi.me/api/core
345
- - https://fetta.dimi.me/api/react
346
- - https://fetta.dimi.me/api/motion
347
- - https://fetta.dimi.me/api/helpers
348
- - https://fetta.dimi.me/examples/vanilla
349
- - https://fetta.dimi.me/examples/react
350
- - 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.
351
203
 
352
204
  ## License
353
205
 
@@ -470,6 +470,13 @@ var ARIA_LABEL_ALLOWED_TAGS = /* @__PURE__ */ new Set([
470
470
  "footer",
471
471
  "main"
472
472
  ]);
473
+ var DEFAULT_RESPLIT_DEBOUNCE_MS = 100;
474
+ function resolveResplitDebounceMs(value) {
475
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
476
+ return DEFAULT_RESPLIT_DEBOUNCE_MS;
477
+ }
478
+ return value;
479
+ }
473
480
  function splitTextData(element, rawOptions = {}) {
474
481
  const options = rawOptions;
475
482
  const {
@@ -1378,6 +1385,7 @@ function splitText(element, rawOptions = {}) {
1378
1385
  lineClass = "split-line",
1379
1386
  mask,
1380
1387
  autoSplit = false,
1388
+ resplitDebounceMs,
1381
1389
  onResplit,
1382
1390
  onSplit,
1383
1391
  revertOnComplete = false,
@@ -1387,6 +1395,7 @@ function splitText(element, rawOptions = {}) {
1387
1395
  initialClasses
1388
1396
  } = options;
1389
1397
  const isolateKerningMeasurement = options.isolateKerningMeasurement !== false;
1398
+ const resolvedResplitDebounceMs = resolveResplitDebounceMs(resplitDebounceMs);
1390
1399
  if (!(element instanceof HTMLElement)) {
1391
1400
  throw new Error("splitText: element must be an HTMLElement");
1392
1401
  }
@@ -1717,8 +1726,16 @@ function splitText(element, rawOptions = {}) {
1717
1726
  lastChangedTarget = changedTarget;
1718
1727
  if (autoSplitDebounceTimer) {
1719
1728
  clearTimeout(autoSplitDebounceTimer);
1729
+ autoSplitDebounceTimer = null;
1720
1730
  }
1721
- autoSplitDebounceTimer = setTimeout(handleResize, 100);
1731
+ if (resolvedResplitDebounceMs <= 0) {
1732
+ handleResize();
1733
+ return;
1734
+ }
1735
+ autoSplitDebounceTimer = setTimeout(
1736
+ handleResize,
1737
+ resolvedResplitDebounceMs
1738
+ );
1722
1739
  });
1723
1740
  targets.forEach((target) => {
1724
1741
  autoSplitObserver.observe(target);
@@ -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 };
package/dist/helpers.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { S as SplitTextResult } from './index-c1UKfWWK.js';
1
+ import { S as SplitTextResult } from './index-Box92Sue.js';
2
2
 
3
3
  type SplitUnit = "chars" | "words" | "lines";
4
4
  type StyleInput = Partial<CSSStyleDeclaration> | ((ctx: {
@@ -32,6 +32,8 @@ interface SplitTextOptions {
32
32
  mask?: "lines" | "words" | "chars";
33
33
  /** Auto-split on resize (observes parent element) */
34
34
  autoSplit?: boolean;
35
+ /** Debounce delay for autoSplit/full-resplit width updates in milliseconds (`0` disables debounce). */
36
+ resplitDebounceMs?: number;
35
37
  /** Callback when autoSplit/full-resplit replaces split output elements */
36
38
  onResplit?: (result: Omit<SplitTextResult, "revert" | "dispose">) => void;
37
39
  /** Callback fired after text is split, receives split elements. Return animation for revertOnComplete. */
package/dist/index.d.ts CHANGED
@@ -1 +1 @@
1
- export { a as SplitTextOptions, S as SplitTextResult, s as splitText } from './index-c1UKfWWK.js';
1
+ export { a as SplitTextOptions, S as SplitTextResult, s as splitText } from './index-Box92Sue.js';
package/dist/index.js CHANGED
@@ -1 +1 @@
1
- export { splitText } from './chunk-OUXSJF3P.js';
1
+ export { splitText } from './chunk-44MU2I5B.js';
@@ -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.d.ts CHANGED
@@ -2,8 +2,8 @@ import { I as InitialStyles, a as InitialClasses } from './initialStyles-BGuPp5C
2
2
  import { DOMKeyframesDefinition, AnimationOptions, scroll } from 'motion';
3
3
  import { HTMLMotionProps } from 'motion/react';
4
4
  import { ReactElement, RefAttributes } from 'react';
5
- import { A as AnimationCallbackReturn } from './index-c1UKfWWK.js';
6
- export { a as CoreSplitTextOptions, S as SplitTextResult } from './index-c1UKfWWK.js';
5
+ import { A as AnimationCallbackReturn } from './index-Box92Sue.js';
6
+ export { a as CoreSplitTextOptions, S as SplitTextResult } from './index-Box92Sue.js';
7
7
 
8
8
  interface SplitTextOptions {
9
9
  type?: "chars" | "words" | "lines" | "chars,words" | "words,lines" | "chars,lines" | "chars,words,lines";
@@ -12,6 +12,8 @@ interface SplitTextOptions {
12
12
  lineClass?: string;
13
13
  /** Apply overflow mask wrapper to elements for reveal animations */
14
14
  mask?: "lines" | "words" | "chars";
15
+ /** Debounce delay for autoSplit/full-resplit width updates in milliseconds (`0` disables debounce). */
16
+ resplitDebounceMs?: number;
15
17
  propIndex?: boolean;
16
18
  /** Skip kerning compensation (no margin adjustments applied).
17
19
  * Kerning is naturally lost when splitting into inline-block spans.
package/dist/motion.js CHANGED
@@ -1,9 +1,18 @@
1
- import { waitForFontsReady, createViewportObserver, reapplyInitialStyles, reapplyInitialClasses } from './chunk-PA22PLRB.js';
2
- import { splitTextData, buildLineFingerprintFromData, normalizeToPromise, buildKerningStyleKey, resolveAutoSplitTargets, getObservedWidth, recordWidthChange, resolveAutoSplitWidth, querySplitWords, clearKerningCompensation, applyKerningCompensation } from './chunk-OUXSJF3P.js';
1
+ import { createViewportObserver, reapplyInitialStyles, reapplyInitialClasses } from './chunk-TEDMJHF7.js';
2
+ import './chunk-HSIMGGOI.js';
3
+ import { waitForFontsReady } from './chunk-V23SUR2S.js';
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';
5
7
  import { forwardRef, useRef, useState, useCallback, useMemo, useLayoutEffect, isValidElement, useEffect, createElement, cloneElement } from 'react';
6
8
 
9
+ var DEFAULT_RESPLIT_DEBOUNCE_MS = 100;
10
+ function resolveResplitDebounceMs(value) {
11
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
12
+ return DEFAULT_RESPLIT_DEBOUNCE_MS;
13
+ }
14
+ return value;
15
+ }
7
16
  var ELEMENT_TYPE_KEYS = ["chars", "words", "lines"];
8
17
  var VOID_HTML_TAGS = /* @__PURE__ */ new Set(["br", "hr", "img", "input", "meta", "link"]);
9
18
  function isPerTypeVariant(v) {
@@ -1097,6 +1106,15 @@ var SplitText = forwardRef(function SplitText2({
1097
1106
  }
1098
1107
  return safeFallbackWidth;
1099
1108
  }, []);
1109
+ const lockCurrentRenderedLines = useCallback((root) => {
1110
+ const lineClass = optionsRef.current?.lineClass ?? "split-line";
1111
+ const classTokens = lineClass.split(/\s+/).filter(Boolean);
1112
+ if (classTokens.length === 0) return;
1113
+ const selector = `.${classTokens.join(".")}`;
1114
+ root.querySelectorAll(selector).forEach((line) => {
1115
+ line.style.whiteSpace = "nowrap";
1116
+ });
1117
+ }, []);
1100
1118
  useEffect(() => {
1101
1119
  if (!childElement) return;
1102
1120
  if (hasSplitRef.current) return;
@@ -1526,6 +1544,7 @@ var SplitText = forwardRef(function SplitText2({
1526
1544
  clearTimeout(resizeTimerRef.current);
1527
1545
  resizeTimerRef.current = null;
1528
1546
  }
1547
+ lockCurrentRenderedLines(currentElement);
1529
1548
  pendingFullResplitRef.current = true;
1530
1549
  let resplitWidth;
1531
1550
  const targets = resolveAutoSplitTargets(currentElement);
@@ -1607,6 +1626,7 @@ var SplitText = forwardRef(function SplitText2({
1607
1626
  autoSplit,
1608
1627
  childTreeVersion,
1609
1628
  data,
1629
+ lockCurrentRenderedLines,
1610
1630
  measureAndSetData,
1611
1631
  resolveLineMeasureWidth
1612
1632
  ]);
@@ -1707,14 +1727,28 @@ var SplitText = forwardRef(function SplitText2({
1707
1727
  }
1708
1728
  if (resizeTimerRef.current) {
1709
1729
  clearTimeout(resizeTimerRef.current);
1730
+ resizeTimerRef.current = null;
1731
+ }
1732
+ const debounceMs = resolveResplitDebounceMs(
1733
+ optionsRef.current?.resplitDebounceMs
1734
+ );
1735
+ if (debounceMs <= 0) {
1736
+ lockCurrentRenderedLines(child2);
1737
+ pendingFullResplitRef.current = true;
1738
+ measureAndSetData(
1739
+ true,
1740
+ splitLines ? lineMeasureWidth : currentWidth
1741
+ );
1742
+ return;
1710
1743
  }
1711
1744
  resizeTimerRef.current = setTimeout(() => {
1745
+ lockCurrentRenderedLines(child2);
1712
1746
  pendingFullResplitRef.current = true;
1713
1747
  measureAndSetData(
1714
1748
  true,
1715
1749
  splitLines ? lineMeasureWidth : currentWidth
1716
1750
  );
1717
- }, 100);
1751
+ }, debounceMs);
1718
1752
  };
1719
1753
  resizeObserverRef.current = new ResizeObserver((entries) => {
1720
1754
  let changed = false;
@@ -1755,6 +1789,7 @@ var SplitText = forwardRef(function SplitText2({
1755
1789
  }, [
1756
1790
  autoSplit,
1757
1791
  data,
1792
+ lockCurrentRenderedLines,
1758
1793
  measureAndSetData,
1759
1794
  measureLineFingerprintForWidth,
1760
1795
  resolveLineMeasureWidth
package/dist/react.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import * as react from 'react';
2
2
  import { ReactElement } from 'react';
3
- import { A as AnimationCallbackReturn } from './index-c1UKfWWK.js';
4
- export { a as CoreSplitTextOptions, S as SplitTextResult } from './index-c1UKfWWK.js';
3
+ import { A as AnimationCallbackReturn } from './index-Box92Sue.js';
4
+ export { a as CoreSplitTextOptions, S as SplitTextResult } from './index-Box92Sue.js';
5
5
  import { I as InitialStyles, a as InitialClasses } from './initialStyles-BGuPp5CS.js';
6
6
 
7
7
  interface SplitTextOptions {
@@ -11,6 +11,8 @@ interface SplitTextOptions {
11
11
  lineClass?: string;
12
12
  /** Apply overflow mask wrapper to elements for reveal animations */
13
13
  mask?: "lines" | "words" | "chars";
14
+ /** Debounce delay for autoSplit/full-resplit width updates in milliseconds (`0` disables debounce). */
15
+ resplitDebounceMs?: number;
14
16
  propIndex?: boolean;
15
17
  /** Skip kerning compensation (no margin adjustments applied).
16
18
  * Kerning is naturally lost when splitting into inline-block spans.
package/dist/react.js CHANGED
@@ -1,5 +1,6 @@
1
- import { waitForFontsReady, createViewportObserver, reapplyInitialStyles, reapplyInitialClasses } from './chunk-PA22PLRB.js';
2
- import { splitText, normalizeToPromise } from './chunk-OUXSJF3P.js';
1
+ import { createViewportObserver, reapplyInitialStyles, reapplyInitialClasses } from './chunk-TEDMJHF7.js';
2
+ import { waitForFontsReady } from './chunk-V23SUR2S.js';
3
+ import { splitText, normalizeToPromise } from './chunk-44MU2I5B.js';
3
4
  import { forwardRef, useRef, useCallback, useState, useLayoutEffect, useEffect, isValidElement, createElement } from 'react';
4
5
 
5
6
  var SplitText = forwardRef(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fetta",
3
- "version": "1.5.4",
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",