fetta 1.5.3 → 1.5.5

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,7 +2,24 @@
2
2
 
3
3
  Text splitting that keeps kerning intact.
4
4
 
5
- Split text into characters, words, and lines while preserving the original typography. Works with any animation library.
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.
6
+
7
+ Docs: https://fetta.dimi.me/
8
+
9
+ ## Installation
10
+
11
+ ```bash
12
+ npm install fetta
13
+ ```
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 |
6
23
 
7
24
  ## Features
8
25
 
@@ -14,186 +31,147 @@ Split text into characters, words, and lines while preserving the original typog
14
31
  - **Auto Revert** — Restores original HTML after animations
15
32
  - **Masking** — Wrap elements in clip containers for reveal animations
16
33
  - **Emoji Support** — Properly handles compound emojis and complex Unicode characters
17
- - **Accessible** — Automatic screen reader support, even when splitting text with nested links or emphasis
34
+ - **Accessible** — Automatic screen reader support, even with nested links or emphasis
18
35
  - **TypeScript** — Full type definitions included
19
- - **React Component** — Declarative wrapper for React projects
20
- - **Viewport Triggers** — Scroll enter/leave callbacks with configurable thresholds in React
21
- - **Library Agnostic** — Works with Motion, GSAP, CSS, or any animation library
36
+ - **Library Agnostic** — Works with Motion, GSAP, CSS, WAAPI, or custom animation code
22
37
 
23
- ## Installation
38
+ ## Quick Start
24
39
 
25
- ```bash
26
- npm install fetta
27
- ```
40
+ ### Vanilla JavaScript (`fetta`)
28
41
 
29
- **Bundle size** (minified + brotli)
30
- - `fetta`: ~7.04 kB
31
- - `fetta/react`: ~8.21 kB
32
- - `fetta/motion`: ~13.68 kB
33
- - `fetta/helpers`: ~742 B
42
+ ```ts
43
+ import { splitText } from "fetta";
44
+ import { animate, stagger } from "motion";
34
45
 
35
- ## Quick Start
46
+ const element = document.querySelector("h1");
47
+ const { chars } = splitText(element, { type: "chars" });
36
48
 
37
- ### Vanilla JavaScript
49
+ animate(chars, { opacity: [0, 1], y: [20, 0] }, { delay: stagger(0.02) });
50
+ ```
38
51
 
39
- ```js
40
- import { splitText } from 'fetta';
41
- import { animate, stagger } from 'motion';
52
+ ### React Callbacks (`fetta/react`)
42
53
 
43
- const { chars, words, lines, revert } = splitText(
44
- document.querySelector('h1'),
45
- { type: 'chars,words,lines' }
46
- );
54
+ ```tsx
55
+ import { SplitText } from "fetta/react";
56
+ import { animate, stagger } from "motion";
47
57
 
48
- animate(chars, { opacity: [0, 1], y: [20, 0] }, { delay: stagger(0.02) });
58
+ <SplitText
59
+ options={{ type: "words" }}
60
+ onSplit={({ words }) => {
61
+ animate(words, { opacity: [0, 1], y: [20, 0] }, { delay: stagger(0.05) });
62
+ }}
63
+ >
64
+ <h1>Hello World</h1>
65
+ </SplitText>;
49
66
  ```
50
67
 
51
- ### React
68
+ ### Motion Variants (`fetta/motion`)
52
69
 
53
70
  ```tsx
54
- import { SplitText } from 'fetta/react';
55
- import { animate, stagger } from 'motion';
71
+ import { SplitText } from "fetta/motion";
72
+ import { stagger } from "motion";
56
73
 
57
- function Hero() {
58
- return (
59
- <SplitText
60
- onSplit={({ words }) => {
61
- animate(words, { opacity: [0, 1], y: [20, 0] }, { delay: stagger(0.05) });
62
- }}
63
- >
64
- <h1>Hello World</h1>
65
- </SplitText>
66
- );
67
- }
74
+ <SplitText
75
+ variants={{
76
+ hidden: { words: { opacity: 0, y: 20 } },
77
+ visible: { words: { opacity: 1, y: 0 } },
78
+ }}
79
+ initial="hidden"
80
+ animate="visible"
81
+ transition={{ duration: 0.65, delay: stagger(0.04) }}
82
+ options={{ type: "words" }}
83
+ >
84
+ <h1>Hello World</h1>
85
+ </SplitText>;
68
86
  ```
69
87
 
88
+ `fetta/motion` supports standard Motion targets, per-type targets (`chars` / `words` / `lines` / `wrapper`), and split-aware function variants.
89
+
70
90
  ## API
71
91
 
72
- ### `splitText(element, options?)`
92
+ ### `splitText(element, options?)` (`fetta`)
73
93
 
74
94
  Splits text content into characters, words, and/or lines.
75
95
 
76
96
  ```ts
97
+ import { splitText } from "fetta";
98
+
77
99
  const result = splitText(element, options);
78
100
  ```
79
101
 
80
102
  #### Options
81
103
 
82
104
  | Option | Type | Default | Description |
83
- |--------|------|---------|-------------|
84
- | `type` | `string` | `"chars,words,lines"` | What to split: `"chars"`, `"words"`, `"lines"`, or combinations |
105
+ |------|------|------|------|
106
+ | `type` | `SplitType` | `"chars,words,lines"` | What to split: `"chars"`, `"words"`, `"lines"`, or combinations |
85
107
  | `charClass` | `string` | `"split-char"` | CSS class for character elements |
86
108
  | `wordClass` | `string` | `"split-word"` | CSS class for word elements |
87
109
  | `lineClass` | `string` | `"split-line"` | CSS class for line elements |
88
- | `mask` | `string` | — | Wrap elements in `overflow: clip` container: `"chars"`, `"words"`, or `"lines"` |
110
+ | `mask` | `"chars" \| "words" \| "lines"` | — | Wrap elements in `overflow: clip` container |
89
111
  | `autoSplit` | `boolean` | `false` | Re-split on container resize |
90
- | `onResplit` | `function` | | Callback after autoSplit/full-resplit replaces split output elements |
91
- | `onSplit` | `function` | — | Callback after initial split. Return animation/promise for `revertOnComplete` |
92
- | `revertOnComplete` | `boolean` | `false` | Auto-revert when animation completes |
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 |
93
116
  | `propIndex` | `boolean` | `false` | Add CSS custom properties: `--char-index`, `--word-index`, `--line-index` |
94
117
  | `disableKerning` | `boolean` | `false` | Skip kerning compensation (no margin adjustments) |
95
118
  | `initialStyles` | `object` | — | Apply initial inline styles to chars/words/lines after split. Values can be objects or `(el, index) => object` functions |
96
119
  | `initialClasses` | `object` | — | Apply initial CSS classes to chars/words/lines. Values are strings |
97
120
 
98
- #### Return Value
99
-
100
121
  ```ts
101
- {
102
- chars: HTMLSpanElement[]; // Character elements
103
- words: HTMLSpanElement[]; // Word elements
104
- lines: HTMLSpanElement[]; // Line elements
105
- revert: () => void; // Restore original HTML and cleanup
106
- }
122
+ type SplitType =
123
+ | "chars"
124
+ | "words"
125
+ | "lines"
126
+ | "chars,words"
127
+ | "words,lines"
128
+ | "chars,lines"
129
+ | "chars,words,lines";
107
130
  ```
108
131
 
109
- ### `createSplitClones(splitResult, options)` (`fetta/helpers`)
110
-
111
- Builds swap/reveal DOM layers (clones + optional wrappers) without coupling to any animation library.
132
+ #### Return Value
112
133
 
113
134
  ```ts
114
- import { splitText } from "fetta";
115
- import { createSplitClones } from "fetta/helpers";
116
-
117
- const split = splitText(element, { type: "chars", mask: "chars" });
118
- const layers = createSplitClones(split, { unit: "chars", wrap: true });
119
-
120
- // Animate with Motion, GSAP, WAAPI, or CSS
121
- // ...
122
-
123
- layers.cleanup(); // removes clones/wrappers, keeps split DOM
124
- // layers.cleanup({ revertSplit: true }) // also calls split.revert()
135
+ interface SplitTextResult {
136
+ chars: HTMLSpanElement[];
137
+ words: HTMLSpanElement[];
138
+ lines: HTMLSpanElement[];
139
+ revert: () => void;
140
+ }
125
141
  ```
126
142
 
127
- #### Behavior
128
-
129
- - Clone is always appended to the **current parent** of the original split node.
130
- - `wrap: false` (default): clone is appended to existing parent (often the mask wrapper).
131
- - `wrap: true`: original is first moved into a track wrapper, then clone is appended there.
132
- - Helper never calls `splitText` and never performs animation.
133
-
134
- #### Options
135
-
136
- | Option | Type | Default | Description |
137
- |--------|------|---------|-------------|
138
- | `unit` | `"chars" \| "words" \| "lines"` | — | Which split nodes to layer |
139
- | `wrap` | `boolean` | `false` | Wrap each original in a track wrapper (`position: relative`) |
140
- | `display` | `"auto" \| "inline-block" \| "block"` | `"auto"` | Track display when `wrap: true` (`lines` => `block`, others => `inline-block`) |
141
- | `cloneOffset.axis` | `"x" \| "y"` | `"y"` | Axis used for initial clone offset |
142
- | `cloneOffset.direction` | `"start" \| "end"` | `"start"` | Offset direction (`start` => negative) |
143
- | `cloneOffset.distance` | `string` | `"100%"` | Offset distance |
144
- | `trackClassName` / `cloneClassName` | `string \| (ctx) => string \| undefined` | — | Class names (static or per-item) |
145
- | `trackStyle` / `cloneStyle` | `object \| (ctx) => object` | — | Inline styles (static or per-item) |
146
-
147
- For reveal/swap effects, use matching `mask` in `splitText` (`"chars"`, `"words"`, or `"lines"`).
148
-
149
- ### `<SplitText>` (React)
143
+ ### `<SplitText>` (`fetta/react`)
150
144
 
151
145
  ```tsx
152
- import { SplitText } from 'fetta/react';
146
+ import { SplitText } from "fetta/react";
153
147
  ```
154
148
 
155
- `fetta/react` forwards common wrapper DOM props (`id`, `role`, `tabIndex`, `aria-*`, `data-*`, and event handlers like `onClick`) to the wrapper element.
156
-
157
- `fetta/react` props:
149
+ `fetta/react` wraps `splitText()` for React with lifecycle hooks, viewport callbacks, and automatic cleanup.
158
150
 
159
151
  #### React Props
160
152
 
161
153
  | Prop | Type | Default | Description |
162
- |------|------|---------|-------------|
154
+ |------|------|------|------|
163
155
  | `children` | `ReactElement` | — | Single React element to split |
164
156
  | `as` | `keyof JSX.IntrinsicElements` | `"div"` | Wrapper element type |
165
- | `className` | `string` | — | Class name for wrapper element |
166
- | `style` | `CSSProperties` | — | Additional styles for wrapper element |
167
- | `ref` | `Ref<HTMLElement>` | — | Ref to container element |
168
- | `onSplit` | `(result) => CallbackReturn` | — | Called after text is split |
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 |
169
161
  | `onResplit` | `(result) => void` | — | Called when autoSplit/full-resplit replaces split output elements |
170
- | `options` | `SplitTextOptions` | — | Split options (type, classes, mask, propIndex, disableKerning) |
162
+ | `options` | `SplitTextOptions` | — | Split options (`type`, classes, mask, debounce, etc.) |
171
163
  | `autoSplit` | `boolean` | `false` | Re-split on container resize |
172
- | `waitForFonts` | `boolean` | `true` | Wait for `document.fonts.ready` before splitting (recommended for stable kerning). Set `false` for immediate split. |
164
+ | `waitForFonts` | `boolean` | `true` | Wait for `document.fonts.ready` before splitting |
173
165
  | `revertOnComplete` | `boolean` | `false` | Revert after animation completes |
174
- | `onRevert` | `() => void` | — | Called when split text is reverted (manual or automatic) |
166
+ | `onRevert` | `() => void` | — | Called when split text reverts |
175
167
  | `viewport` | `ViewportOptions` | — | Configure viewport detection |
176
- | `onViewportEnter` | `(result) => CallbackReturn` | — | Called when element enters viewport |
177
- | `onViewportLeave` | `(result) => CallbackReturn` | — | Called when element leaves viewport |
178
- | `initialStyles` | `object` | — | Apply initial inline styles to chars/words/lines. Values can be objects or `(el, index) => object` functions |
179
- | `initialClasses` | `object` | — | Apply initial CSS classes to chars/words/lines. Values are strings |
180
- | `resetOnViewportLeave` | `boolean` | `false` | Re-apply initialStyles/initialClasses when leaving viewport |
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 |
181
173
 
182
- #### Shared `SplitTextOptions` (`options` prop)
183
-
184
- | Option | Type | Default | Description |
185
- |------|------|---------|-------------|
186
- | `type` | `SplitType` | `"chars,words,lines"` | What to split: `"chars"`, `"words"`, `"lines"`, or combinations |
187
- | `charClass` | `string` | `"split-char"` | CSS class for character spans |
188
- | `wordClass` | `string` | `"split-word"` | CSS class for word spans |
189
- | `lineClass` | `string` | `"split-line"` | CSS class for line spans |
190
- | `mask` | `"lines" \| "words" \| "chars"` | — | Wrap elements in `overflow: clip` mask containers |
191
- | `propIndex` | `boolean` | `false` | Add CSS index variables (`--char-index`, `--word-index`, `--line-index`) |
192
- | `disableKerning` | `boolean` | `false` | Skip kerning compensation (no margin adjustments) |
193
-
194
- #### Callback Signature
195
-
196
- All callbacks (`onSplit`, `onResplit`, `onViewportEnter`, `onViewportLeave`) receive:
174
+ All callbacks receive:
197
175
 
198
176
  ```ts
199
177
  {
@@ -213,314 +191,118 @@ type CallbackReturn =
213
191
  | CallbackReturn[];
214
192
  ```
215
193
 
216
- When using `autoSplit` with `lines` in scroll-linked or scroll-triggered animations, re-attach scroll/timeline logic inside `onResplit` so it binds to the new split element references.
217
-
218
- `onRevert` is a separate zero-argument callback that fires when a split cycle actually reverts.
219
-
220
- #### Viewport Options
221
-
222
- ```ts
223
- {
224
- amount?: number | "some" | "all"; // Enter threshold, default: 0
225
- leave?: number | "some" | "all"; // Leave threshold, default: 0
226
- margin?: string; // Root margin, default: "0px"
227
- once?: boolean; // Only trigger once, default: false
228
- root?: RefObject<Element>; // Optional root element
229
- }
230
- ```
194
+ `fetta/react` and `fetta/motion` both forward common wrapper DOM props (`id`, `role`, `tabIndex`, `aria-*`, `data-*`, event handlers) to the wrapper.
231
195
 
232
- ### `<SplitText>` (Motion)
196
+ ### `<SplitText>` (`fetta/motion`)
233
197
 
234
198
  ```tsx
235
199
  import { SplitText } from "fetta/motion";
236
200
  ```
237
201
 
238
- `fetta/motion` includes all props from `fetta/react`, plus Motion variant props. It also forwards standard Motion/DOM wrapper props (`id`, `role`, `tabIndex`, `layout`, `drag`, `data-*`, etc.) to the wrapper.
239
-
240
- Animate on exit with Motion's `AnimatePresence` (make `SplitText` the direct child):
241
-
242
- ```tsx
243
- import { AnimatePresence } from "motion/react";
244
-
245
- <AnimatePresence>
246
- {isVisible && (
247
- <SplitText
248
- variants={{
249
- enter: { opacity: 1, y: 0 },
250
- exit: { opacity: 0, y: 12 },
251
- }}
252
- initial="enter"
253
- animate="enter"
254
- exit="exit"
255
- options={{ type: "words" }}
256
- >
257
- <h1>Goodbye</h1>
258
- </SplitText>
259
- )}
260
- </AnimatePresence>
261
- ```
202
+ Variant-driven component built on Motion. Includes every `fetta/react` prop plus Motion animation/triggers (`initial`, `animate`, `exit`, `whileInView`, `whileScroll`, `whileHover`, etc.).
262
203
 
263
204
  #### Motion-only Props
264
205
 
265
206
  | Prop | Type | Default | Description |
266
- |------|------|---------|-------------|
207
+ |------|------|------|------|
267
208
  | `variants` | `Record<string, VariantDefinition<TCustom>>` | — | Named variant definitions |
268
209
  | `initial` | `string \| VariantDefinition<TCustom> \| false` | — | Initial variant applied instantly after split |
269
210
  | `animate` | `string \| VariantDefinition<TCustom>` | — | Base variant |
270
- | `exit` | `string \| VariantDefinition<TCustom> \| false` | — | Exit variant (AnimatePresence) |
211
+ | `exit` | `string \| VariantDefinition<TCustom> \| false` | — | Exit variant (`AnimatePresence`) |
271
212
  | `whileInView` | `string \| VariantDefinition<TCustom>` | — | Variant while element is in view |
272
213
  | `whileOutOfView` | `string \| VariantDefinition<TCustom>` | — | Variant after element leaves view |
273
214
  | `whileScroll` | `string \| VariantDefinition<TCustom>` | — | Scroll-driven variant (highest trigger priority) |
274
215
  | `whileHover` | `string \| VariantDefinition<TCustom>` | — | Variant on hover |
275
216
  | `whileTap` | `string \| VariantDefinition<TCustom>` | — | Variant on tap/press |
276
217
  | `whileFocus` | `string \| VariantDefinition<TCustom>` | — | Variant on focus |
277
- | `animateOnResplit` | `boolean` | `false` | Replay initial->animate on autoSplit/full-resplit |
218
+ | `animateOnResplit` | `boolean` | `false` | Replay `initial -> animate` on autoSplit/full-resplit |
278
219
  | `scroll` | `{ offset?, axis?, container? }` | — | Scroll tracking options for `whileScroll` |
279
220
  | `transition` | `AnimationOptions` | — | Global/default transition for variants |
280
221
  | `custom` | `TCustom` | — | Custom data forwarded to function variants |
281
- | `delayScope` | `"global" \| "local"` | `"global"` | Delay-function index scope (`globalIndex` vs relative `index`) |
282
- | `reducedMotion` | `"user" \| "always" \| "never"` | | Reduced-motion behavior for this component |
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 |
283
224
  | `onHoverStart` | `() => void` | — | Called when hover starts |
284
225
  | `onHoverEnd` | `() => void` | — | Called when hover ends |
285
226
 
286
- ## Examples
287
-
288
- ### Vanilla JavaScript
289
-
290
- #### Basic
291
-
292
- ```js
293
- import { splitText } from 'fetta';
294
- import { animate, stagger } from 'motion';
295
-
296
- const { words } = splitText(document.querySelector('h1'));
297
-
298
- animate(words, { opacity: [0, 1], y: [20, 0] }, { delay: stagger(0.05) });
299
- ```
300
-
301
- #### Masked Line Reveal
302
-
303
- ```js
304
- splitText(element, {
305
- type: 'lines',
306
- mask: 'lines',
307
- onSplit: ({ lines }) => {
308
- animate(lines, { y: ['100%', '0%'] }, { delay: stagger(0.1) });
309
- }
310
- });
311
- ```
312
-
313
- #### With GSAP
314
-
315
- ```js
316
- import { splitText } from 'fetta';
317
- import gsap from 'gsap';
318
-
319
- splitText(element, {
320
- revertOnComplete: true,
321
- onSplit: ({ words }) => {
322
- return gsap.from(words, {
323
- opacity: 0,
324
- y: 20,
325
- stagger: 0.05,
326
- duration: 0.6,
327
- });
328
- }
329
- });
330
- ```
331
-
332
- #### CSS-Only with Index Props
333
-
334
- ```js
335
- splitText(element, { type: 'chars', propIndex: true });
336
- ```
337
-
338
- ```css
339
- .split-char {
340
- opacity: 0;
341
- animation: fade-in 0.5s forwards;
342
- animation-delay: calc(var(--char-index) * 0.03s);
343
- }
344
-
345
- @keyframes fade-in {
346
- to { opacity: 1; }
347
- }
348
- ```
349
-
350
- ### React
351
-
352
- #### Basic
353
-
354
- ```tsx
355
- <SplitText
356
- onSplit={({ words }) => {
357
- animate(words, { opacity: [0, 1], y: [20, 0] }, { delay: stagger(0.05) });
358
- }}
359
- >
360
- <h1>Hello World</h1>
361
- </SplitText>
362
- ```
363
-
364
- #### Masked Line Reveal
365
-
366
- ```tsx
367
- <SplitText
368
- options={{ type: 'lines', mask: 'lines' }}
369
- onSplit={({ lines }) => {
370
- animate(lines, { y: ['100%', '0%'] }, { delay: stagger(0.1) });
371
- }}
372
- >
373
- <p>Each line reveals from below</p>
374
- </SplitText>
375
- ```
376
-
377
- #### Scroll-Triggered with Viewport
227
+ Exit animations use standard Motion behavior (`SplitText` must be a direct child of `AnimatePresence`):
378
228
 
379
229
  ```tsx
380
- <SplitText
381
- options={{ type: 'words' }}
382
- initialStyles={{
383
- words: { opacity: '0', transform: 'translateY(20px)' }
384
- }}
385
- viewport={{ amount: 0.5 }}
386
- onViewportEnter={({ words }) => {
387
- animate(words, { opacity: 1, y: 0 }, { delay: stagger(0.03) });
388
- }}
389
- resetOnViewportLeave
390
- >
391
- <p>Animates when scrolled into view</p>
392
- </SplitText>
393
- ```
394
-
395
- #### Auto-Revert After Animation
230
+ import { AnimatePresence } from "motion/react";
231
+ import { SplitText } from "fetta/motion";
396
232
 
397
- ```tsx
398
- <SplitText
399
- revertOnComplete
400
- onSplit={({ chars }) => {
401
- return animate(chars, { opacity: [0, 1] }, { delay: stagger(0.02) });
402
- }}
403
- >
404
- <h1>Reverts to original HTML after animation</h1>
405
- </SplitText>
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>;
406
249
  ```
407
250
 
408
- ### Motion (`fetta/motion`)
251
+ ### `createSplitClones(splitResult, options)` (`fetta/helpers`, optional)
409
252
 
410
- #### Basic Variants
253
+ Helpers are optional utilities for advanced layered reveal/swap effects.
411
254
 
412
- ```tsx
413
- import { SplitText } from 'fetta/motion';
414
- import { stagger } from 'motion';
415
-
416
- <SplitText
417
- variants={{
418
- hidden: { opacity: 0, y: 20, filter: 'blur(6px)' },
419
- visible: { opacity: 1, y: 0, filter: 'blur(0px)' },
420
- }}
421
- initial="hidden"
422
- animate="visible"
423
- transition={{ duration: 0.6, delay: stagger(0.04) }}
424
- options={{ type: 'words' }}
425
- >
426
- <h1>Hello World</h1>
427
- </SplitText>
428
- ```
429
-
430
- #### Line-Aware Stagger
431
-
432
- ```tsx
433
- import { SplitText } from 'fetta/motion';
434
- import { stagger } from 'motion';
255
+ ```ts
256
+ import { splitText } from "fetta";
257
+ import { createSplitClones } from "fetta/helpers";
435
258
 
436
- <SplitText
437
- delayScope="local"
438
- variants={{
439
- hidden: { chars: { opacity: 0 } },
440
- visible: {
441
- chars: ({ lineIndex }) => ({
442
- opacity: 1,
443
- transition: {
444
- duration: 0.3,
445
- delay: stagger(0.015, {
446
- startDelay: lineIndex * 0.2,
447
- from: lineIndex % 2 === 0 ? "first" : "last",
448
- }),
449
- },
450
- }),
451
- },
452
- }}
453
- initial="hidden"
454
- animate="visible"
455
- options={{ type: "chars,lines" }}
456
- >
457
- <p>Line-aware per-character animation</p>
458
- </SplitText>
259
+ const split = splitText(element, { type: "chars", mask: "chars" });
260
+ const layers = createSplitClones(split, { unit: "chars", wrap: true });
459
261
  ```
460
262
 
461
- #### Scroll-Driven Reveal
462
-
463
- ```tsx
464
- import { SplitText } from 'fetta/motion';
263
+ #### Helper Options
465
264
 
466
- <SplitText
467
- initialStyles={{ chars: { opacity: 0.2 } }}
468
- whileScroll={{
469
- chars: ({ globalIndex }) => ({
470
- opacity: 1,
471
- transition: {
472
- duration: 0.3,
473
- at: globalIndex * 0.025,
474
- ease: "linear",
475
- },
476
- }),
477
- }}
478
- scroll={{ offset: ["start 90%", "start 10%"] }}
479
- options={{ type: "chars" }}
480
- >
481
- <p>Characters fade in with scroll progress</p>
482
- </SplitText>
483
- ```
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) |
484
275
 
485
- #### Hover Interaction
276
+ #### Helper Behavior
486
277
 
487
- ```tsx
488
- import { SplitText } from 'fetta/motion';
489
- import { stagger } from 'motion';
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()`.
490
284
 
491
- <SplitText
492
- variants={{
493
- rest: { chars: { opacity: 0.85, y: 0 } },
494
- hover: { chars: { opacity: 1, y: -6 } },
495
- }}
496
- initial="rest"
497
- animate="rest"
498
- whileHover="hover"
499
- transition={{ duration: 0.25, delay: stagger(0.01) }}
500
- options={{ type: 'chars' }}
501
- >
502
- <p>Hover this text</p>
503
- </SplitText>
504
- ```
285
+ For reveal/swap effects, match helper `unit` with `splitText` `mask` (`"chars"`, `"words"`, `"lines"`).
505
286
 
506
287
  ## CSS Classes
507
288
 
508
- Default classes applied to split elements:
289
+ Default classes applied to split output:
509
290
 
510
- | Class | Element | Notes |
511
- |-------|---------|-------|
512
- | `.split-char` | Characters | Inline positioning |
513
- | `.split-word` | Words | Inline positioning |
514
- | `.split-line` | Lines | Block display |
291
+ | Class | Element |
292
+ |------|------|
293
+ | `.split-char` | Character span |
294
+ | `.split-word` | Word span |
295
+ | `.split-line` | Line span |
515
296
 
516
- Split elements receive typed index attributes:
517
- - Characters: `data-char-index`
518
- - Words: `data-word-index`
519
- - Lines: `data-line-index`
297
+ Split elements receive index attributes:
298
+
299
+ - `data-char-index`
300
+ - `data-word-index`
301
+ - `data-line-index`
520
302
 
521
303
  ## Font Loading
522
304
 
523
- For accurate kerning measurements, fonts must be fully loaded before splitting. When using custom fonts in vanilla JS, wait for `document.fonts.ready`:
305
+ For stable kerning measurements in vanilla usage, wait for fonts before splitting:
524
306
 
525
307
  ```ts
526
308
  document.fonts.ready.then(() => {
@@ -529,63 +311,45 @@ document.fonts.ready.then(() => {
529
311
  });
530
312
  ```
531
313
 
532
- React and Motion components wait for fonts by default (`waitForFonts={true}`), which gives the most stable kerning.
533
-
534
- If you notice a visual shift after splitting, keep the default waiting behavior enabled.
535
-
536
- If you need immediate splitting (for example, responsiveness-first UI), you can opt out with `waitForFonts={false}`:
537
-
538
- ```tsx
539
- <SplitText waitForFonts={false}>
540
- <h1>Split Immediately</h1>
541
- </SplitText>
542
- ```
314
+ React and Motion components wait for fonts by default (`waitForFonts={true}`).
543
315
 
544
316
  ## Accessibility
545
317
 
546
- Fetta automatically handles accessibility to ensure split text remains readable by screen readers.
547
-
548
- **Headings and landmarks** — For elements that support `aria-label` natively (headings, `<section>`, `<nav>`, etc.), Fetta adds `aria-hidden="true"` to each split span and an `aria-label` on the parent:
318
+ Fetta keeps split text readable by screen readers:
549
319
 
550
- ```html
551
- <!-- After splitting <h1>Hello World</h1> -->
552
- <h1 aria-label="Hello World">
553
- <span class="split-word" aria-hidden="true">Hello</span>
554
- <span class="split-word" aria-hidden="true">World</span>
555
- </h1>
556
- ```
557
-
558
- **Generic elements and nested content** — For `<span>`, `<div>`, `<p>`, or text containing inline elements like links, Fetta wraps the visual content with `aria-hidden="true"` and adds a screen-reader-only copy that preserves the semantic structure:
559
-
560
- ```html
561
- <!-- After splitting <p>Click <a href="/signup">here</a> to start</p> -->
562
- <p>
563
- <span aria-hidden="true" data-fetta-visual="true">
564
- <!-- Split visual content -->
565
- </span>
566
- <span class="fetta-sr-only" data-fetta-sr-copy="true">
567
- Click <a href="/signup">here</a> to start
568
- </span>
569
- </p>
570
- ```
571
-
572
- Pre-existing `aria-label` attributes are always preserved.
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.
573
323
 
574
324
  ## Notes
575
325
 
576
- - **Ligatures are disabled** (`font-variant-ligatures: none`) because ligatures cannot span multiple elements.
577
- - **Authored hard breaks are preserved** — Explicit `<br>` and block-level descendants are treated as hard boundaries. In `chars`/`words` modes, hard boundaries are normalized to `<br>` in the split output.
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`.
578
329
 
579
330
  ## Browser Support
580
331
 
581
332
  All modern browsers: Chrome, Firefox, Safari, Edge.
582
333
 
583
334
  Requires:
335
+
584
336
  - `ResizeObserver`
585
337
  - `IntersectionObserver`
586
338
  - `Intl.Segmenter`
587
339
 
588
- **Safari note** — Kerning compensation works but may be slightly less accurate due to Safari's unique font rendering. Differences are typically imperceptible and vary by font, but if you're using `revert()` and notice a subtle shift in some characters, you can bypass compensation with `disableKerning: true`.
340
+ Safari kerning compensation works, but font rendering precision can vary slightly by font. If you notice subtle shifts around `revert()`, use `disableKerning: true`.
341
+
342
+ ## Docs
343
+
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
589
353
 
590
354
  ## License
591
355
 
@@ -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);
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';
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,16 @@
1
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';
2
+ import { splitTextData, buildLineFingerprintFromData, normalizeToPromise, buildKerningStyleKey, resolveAutoSplitTargets, getObservedWidth, recordWidthChange, resolveAutoSplitWidth, querySplitWords, clearKerningCompensation, applyKerningCompensation } from './chunk-44MU2I5B.js';
3
3
  import { scroll, animate } from 'motion';
4
4
  import { usePresence, useReducedMotion, MotionConfig, motion } from 'motion/react';
5
5
  import { forwardRef, useRef, useState, useCallback, useMemo, useLayoutEffect, isValidElement, useEffect, createElement, cloneElement } from 'react';
6
6
 
7
+ var DEFAULT_RESPLIT_DEBOUNCE_MS = 100;
8
+ function resolveResplitDebounceMs(value) {
9
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
10
+ return DEFAULT_RESPLIT_DEBOUNCE_MS;
11
+ }
12
+ return value;
13
+ }
7
14
  var ELEMENT_TYPE_KEYS = ["chars", "words", "lines"];
8
15
  var VOID_HTML_TAGS = /* @__PURE__ */ new Set(["br", "hr", "img", "input", "meta", "link"]);
9
16
  function isPerTypeVariant(v) {
@@ -1097,6 +1104,15 @@ var SplitText = forwardRef(function SplitText2({
1097
1104
  }
1098
1105
  return safeFallbackWidth;
1099
1106
  }, []);
1107
+ const lockCurrentRenderedLines = useCallback((root) => {
1108
+ const lineClass = optionsRef.current?.lineClass ?? "split-line";
1109
+ const classTokens = lineClass.split(/\s+/).filter(Boolean);
1110
+ if (classTokens.length === 0) return;
1111
+ const selector = `.${classTokens.join(".")}`;
1112
+ root.querySelectorAll(selector).forEach((line) => {
1113
+ line.style.whiteSpace = "nowrap";
1114
+ });
1115
+ }, []);
1100
1116
  useEffect(() => {
1101
1117
  if (!childElement) return;
1102
1118
  if (hasSplitRef.current) return;
@@ -1526,6 +1542,7 @@ var SplitText = forwardRef(function SplitText2({
1526
1542
  clearTimeout(resizeTimerRef.current);
1527
1543
  resizeTimerRef.current = null;
1528
1544
  }
1545
+ lockCurrentRenderedLines(currentElement);
1529
1546
  pendingFullResplitRef.current = true;
1530
1547
  let resplitWidth;
1531
1548
  const targets = resolveAutoSplitTargets(currentElement);
@@ -1607,6 +1624,7 @@ var SplitText = forwardRef(function SplitText2({
1607
1624
  autoSplit,
1608
1625
  childTreeVersion,
1609
1626
  data,
1627
+ lockCurrentRenderedLines,
1610
1628
  measureAndSetData,
1611
1629
  resolveLineMeasureWidth
1612
1630
  ]);
@@ -1707,14 +1725,28 @@ var SplitText = forwardRef(function SplitText2({
1707
1725
  }
1708
1726
  if (resizeTimerRef.current) {
1709
1727
  clearTimeout(resizeTimerRef.current);
1728
+ resizeTimerRef.current = null;
1729
+ }
1730
+ const debounceMs = resolveResplitDebounceMs(
1731
+ optionsRef.current?.resplitDebounceMs
1732
+ );
1733
+ if (debounceMs <= 0) {
1734
+ lockCurrentRenderedLines(child2);
1735
+ pendingFullResplitRef.current = true;
1736
+ measureAndSetData(
1737
+ true,
1738
+ splitLines ? lineMeasureWidth : currentWidth
1739
+ );
1740
+ return;
1710
1741
  }
1711
1742
  resizeTimerRef.current = setTimeout(() => {
1743
+ lockCurrentRenderedLines(child2);
1712
1744
  pendingFullResplitRef.current = true;
1713
1745
  measureAndSetData(
1714
1746
  true,
1715
1747
  splitLines ? lineMeasureWidth : currentWidth
1716
1748
  );
1717
- }, 100);
1749
+ }, debounceMs);
1718
1750
  };
1719
1751
  resizeObserverRef.current = new ResizeObserver((entries) => {
1720
1752
  let changed = false;
@@ -1755,6 +1787,7 @@ var SplitText = forwardRef(function SplitText2({
1755
1787
  }, [
1756
1788
  autoSplit,
1757
1789
  data,
1790
+ lockCurrentRenderedLines,
1758
1791
  measureAndSetData,
1759
1792
  measureLineFingerprintForWidth,
1760
1793
  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,5 @@
1
1
  import { waitForFontsReady, createViewportObserver, reapplyInitialStyles, reapplyInitialClasses } from './chunk-PA22PLRB.js';
2
- import { splitText, normalizeToPromise } from './chunk-OUXSJF3P.js';
2
+ import { splitText, normalizeToPromise } from './chunk-44MU2I5B.js';
3
3
  import { forwardRef, useRef, useCallback, useState, useLayoutEffect, useEffect, isValidElement, createElement } from 'react';
4
4
 
5
5
  var SplitText = forwardRef(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fetta",
3
- "version": "1.5.3",
3
+ "version": "1.5.5",
4
4
  "description": "Text splitting library with kerning compensation for animations",
5
5
  "type": "module",
6
6
  "sideEffects": false,