fetta 1.5.3 → 1.5.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/README.md +185 -423
  2. package/package.json +1 -1
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,146 @@ 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
+ | `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 |
93
115
  | `propIndex` | `boolean` | `false` | Add CSS custom properties: `--char-index`, `--word-index`, `--line-index` |
94
116
  | `disableKerning` | `boolean` | `false` | Skip kerning compensation (no margin adjustments) |
95
117
  | `initialStyles` | `object` | — | Apply initial inline styles to chars/words/lines after split. Values can be objects or `(el, index) => object` functions |
96
118
  | `initialClasses` | `object` | — | Apply initial CSS classes to chars/words/lines. Values are strings |
97
119
 
98
- #### Return Value
99
-
100
120
  ```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
- }
121
+ type SplitType =
122
+ | "chars"
123
+ | "words"
124
+ | "lines"
125
+ | "chars,words"
126
+ | "words,lines"
127
+ | "chars,lines"
128
+ | "chars,words,lines";
107
129
  ```
108
130
 
109
- ### `createSplitClones(splitResult, options)` (`fetta/helpers`)
110
-
111
- Builds swap/reveal DOM layers (clones + optional wrappers) without coupling to any animation library.
131
+ #### Return Value
112
132
 
113
133
  ```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()
134
+ interface SplitTextResult {
135
+ chars: HTMLSpanElement[];
136
+ words: HTMLSpanElement[];
137
+ lines: HTMLSpanElement[];
138
+ revert: () => void;
139
+ }
125
140
  ```
126
141
 
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)
142
+ ### `<SplitText>` (`fetta/react`)
150
143
 
151
144
  ```tsx
152
- import { SplitText } from 'fetta/react';
145
+ import { SplitText } from "fetta/react";
153
146
  ```
154
147
 
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:
148
+ `fetta/react` wraps `splitText()` for React with lifecycle hooks, viewport callbacks, and automatic cleanup.
158
149
 
159
150
  #### React Props
160
151
 
161
152
  | Prop | Type | Default | Description |
162
- |------|------|---------|-------------|
153
+ |------|------|------|------|
163
154
  | `children` | `ReactElement` | — | Single React element to split |
164
155
  | `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 |
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 |
169
160
  | `onResplit` | `(result) => void` | — | Called when autoSplit/full-resplit replaces split output elements |
170
- | `options` | `SplitTextOptions` | — | Split options (type, classes, mask, propIndex, disableKerning) |
161
+ | `options` | `SplitTextOptions` | — | Split options (`type`, classes, mask, etc.) |
171
162
  | `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. |
163
+ | `waitForFonts` | `boolean` | `true` | Wait for `document.fonts.ready` before splitting |
173
164
  | `revertOnComplete` | `boolean` | `false` | Revert after animation completes |
174
- | `onRevert` | `() => void` | — | Called when split text is reverted (manual or automatic) |
165
+ | `onRevert` | `() => void` | — | Called when split text reverts |
175
166
  | `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 |
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 |
181
172
 
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:
173
+ All callbacks receive:
197
174
 
198
175
  ```ts
199
176
  {
@@ -213,314 +190,118 @@ type CallbackReturn =
213
190
  | CallbackReturn[];
214
191
  ```
215
192
 
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
- ```
193
+ `fetta/react` and `fetta/motion` both forward common wrapper DOM props (`id`, `role`, `tabIndex`, `aria-*`, `data-*`, event handlers) to the wrapper.
231
194
 
232
- ### `<SplitText>` (Motion)
195
+ ### `<SplitText>` (`fetta/motion`)
233
196
 
234
197
  ```tsx
235
198
  import { SplitText } from "fetta/motion";
236
199
  ```
237
200
 
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
- ```
201
+ Variant-driven component built on Motion. Includes every `fetta/react` prop plus Motion animation/triggers (`initial`, `animate`, `exit`, `whileInView`, `whileScroll`, `whileHover`, etc.).
262
202
 
263
203
  #### Motion-only Props
264
204
 
265
205
  | Prop | Type | Default | Description |
266
- |------|------|---------|-------------|
206
+ |------|------|------|------|
267
207
  | `variants` | `Record<string, VariantDefinition<TCustom>>` | — | Named variant definitions |
268
208
  | `initial` | `string \| VariantDefinition<TCustom> \| false` | — | Initial variant applied instantly after split |
269
209
  | `animate` | `string \| VariantDefinition<TCustom>` | — | Base variant |
270
- | `exit` | `string \| VariantDefinition<TCustom> \| false` | — | Exit variant (AnimatePresence) |
210
+ | `exit` | `string \| VariantDefinition<TCustom> \| false` | — | Exit variant (`AnimatePresence`) |
271
211
  | `whileInView` | `string \| VariantDefinition<TCustom>` | — | Variant while element is in view |
272
212
  | `whileOutOfView` | `string \| VariantDefinition<TCustom>` | — | Variant after element leaves view |
273
213
  | `whileScroll` | `string \| VariantDefinition<TCustom>` | — | Scroll-driven variant (highest trigger priority) |
274
214
  | `whileHover` | `string \| VariantDefinition<TCustom>` | — | Variant on hover |
275
215
  | `whileTap` | `string \| VariantDefinition<TCustom>` | — | Variant on tap/press |
276
216
  | `whileFocus` | `string \| VariantDefinition<TCustom>` | — | Variant on focus |
277
- | `animateOnResplit` | `boolean` | `false` | Replay initial->animate on autoSplit/full-resplit |
217
+ | `animateOnResplit` | `boolean` | `false` | Replay `initial -> animate` on autoSplit/full-resplit |
278
218
  | `scroll` | `{ offset?, axis?, container? }` | — | Scroll tracking options for `whileScroll` |
279
219
  | `transition` | `AnimationOptions` | — | Global/default transition for variants |
280
220
  | `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 |
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 |
283
223
  | `onHoverStart` | `() => void` | — | Called when hover starts |
284
224
  | `onHoverEnd` | `() => void` | — | Called when hover ends |
285
225
 
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
226
+ Exit animations use standard Motion behavior (`SplitText` must be a direct child of `AnimatePresence`):
378
227
 
379
228
  ```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
229
+ import { AnimatePresence } from "motion/react";
230
+ import { SplitText } from "fetta/motion";
396
231
 
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>
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>;
406
248
  ```
407
249
 
408
- ### Motion (`fetta/motion`)
250
+ ### `createSplitClones(splitResult, options)` (`fetta/helpers`, optional)
409
251
 
410
- #### Basic Variants
252
+ Helpers are optional utilities for advanced layered reveal/swap effects.
411
253
 
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';
254
+ ```ts
255
+ import { splitText } from "fetta";
256
+ import { createSplitClones } from "fetta/helpers";
435
257
 
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>
258
+ const split = splitText(element, { type: "chars", mask: "chars" });
259
+ const layers = createSplitClones(split, { unit: "chars", wrap: true });
459
260
  ```
460
261
 
461
- #### Scroll-Driven Reveal
462
-
463
- ```tsx
464
- import { SplitText } from 'fetta/motion';
262
+ #### Helper Options
465
263
 
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
- ```
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) |
484
274
 
485
- #### Hover Interaction
275
+ #### Helper Behavior
486
276
 
487
- ```tsx
488
- import { SplitText } from 'fetta/motion';
489
- import { stagger } from 'motion';
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()`.
490
283
 
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
- ```
284
+ For reveal/swap effects, match helper `unit` with `splitText` `mask` (`"chars"`, `"words"`, `"lines"`).
505
285
 
506
286
  ## CSS Classes
507
287
 
508
- Default classes applied to split elements:
288
+ Default classes applied to split output:
509
289
 
510
- | Class | Element | Notes |
511
- |-------|---------|-------|
512
- | `.split-char` | Characters | Inline positioning |
513
- | `.split-word` | Words | Inline positioning |
514
- | `.split-line` | Lines | Block display |
290
+ | Class | Element |
291
+ |------|------|
292
+ | `.split-char` | Character span |
293
+ | `.split-word` | Word span |
294
+ | `.split-line` | Line span |
515
295
 
516
- Split elements receive typed index attributes:
517
- - Characters: `data-char-index`
518
- - Words: `data-word-index`
519
- - Lines: `data-line-index`
296
+ Split elements receive index attributes:
297
+
298
+ - `data-char-index`
299
+ - `data-word-index`
300
+ - `data-line-index`
520
301
 
521
302
  ## Font Loading
522
303
 
523
- For accurate kerning measurements, fonts must be fully loaded before splitting. When using custom fonts in vanilla JS, wait for `document.fonts.ready`:
304
+ For stable kerning measurements in vanilla usage, wait for fonts before splitting:
524
305
 
525
306
  ```ts
526
307
  document.fonts.ready.then(() => {
@@ -529,63 +310,44 @@ document.fonts.ready.then(() => {
529
310
  });
530
311
  ```
531
312
 
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
- ```
313
+ React and Motion components wait for fonts by default (`waitForFonts={true}`).
543
314
 
544
315
  ## Accessibility
545
316
 
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:
317
+ Fetta keeps split text readable by screen readers:
549
318
 
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.
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.
573
322
 
574
323
  ## Notes
575
324
 
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.
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).
578
327
 
579
328
  ## Browser Support
580
329
 
581
330
  All modern browsers: Chrome, Firefox, Safari, Edge.
582
331
 
583
332
  Requires:
333
+
584
334
  - `ResizeObserver`
585
335
  - `IntersectionObserver`
586
336
  - `Intl.Segmenter`
587
337
 
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`.
338
+ Safari kerning compensation works, but font rendering precision can vary slightly by font. If you notice subtle shifts around `revert()`, use `disableKerning: true`.
339
+
340
+ ## Docs
341
+
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
589
351
 
590
352
  ## License
591
353
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fetta",
3
- "version": "1.5.3",
3
+ "version": "1.5.4",
4
4
  "description": "Text splitting library with kerning compensation for animations",
5
5
  "type": "module",
6
6
  "sideEffects": false,