fetta 1.4.3 → 1.5.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
@@ -17,7 +17,7 @@ Split text into characters, words, and lines while preserving the original typog
17
17
  - **Accessible** — Automatic screen reader support, even when splitting text with nested links or emphasis
18
18
  - **TypeScript** — Full type definitions included
19
19
  - **React Component** — Declarative wrapper for React projects
20
- - **Built-in InView** — Viewport detection for scroll-triggered animations in React
20
+ - **Built-in Viewport** — Viewport detection for scroll-triggered animations in React
21
21
  - **Library Agnostic** — Works with Motion, GSAP, or any animation library
22
22
 
23
23
  ## Installation
@@ -26,7 +26,10 @@ Split text into characters, words, and lines while preserving the original typog
26
26
  npm install fetta
27
27
  ```
28
28
 
29
- **Bundle size**: ~3.9 kB (`fetta/core`) / ~4.8 kB (`fetta/react`) — minified + compressed
29
+ **Bundle size** (minified + brotli)
30
+ - `fetta`: ~5.4 kB
31
+ - `fetta/react`: ~6.7 kB
32
+ - `fetta/motion`: ~11.2 kB
30
33
 
31
34
  ## Quick Start
32
35
 
@@ -83,13 +86,13 @@ const result = splitText(element, options);
83
86
  | `lineClass` | `string` | `"split-line"` | CSS class for line elements |
84
87
  | `mask` | `string` | — | Wrap elements in `overflow: clip` container: `"chars"`, `"words"`, or `"lines"` |
85
88
  | `autoSplit` | `boolean` | `false` | Re-split on container resize |
86
- | `onResize` | `function` | — | Callback after resize re-split |
87
- | `onSplit` | `function` | — | Callback after initial split |
89
+ | `onResplit` | `function` | — | Callback after autoSplit/full-resplit replaces split output elements |
90
+ | `onSplit` | `function` | — | Callback after initial split. Return animation/promise for `revertOnComplete` |
88
91
  | `revertOnComplete` | `boolean` | `false` | Auto-revert when animation completes |
89
92
  | `propIndex` | `boolean` | `false` | Add CSS custom properties: `--char-index`, `--word-index`, `--line-index` |
90
93
  | `disableKerning` | `boolean` | `false` | Skip kerning compensation (no margin adjustments) |
91
94
  | `initialStyles` | `object` | — | Apply initial inline styles to chars/words/lines after split. Values can be objects or `(el, index) => object` functions |
92
- | `initialClasses` | `object` | — | Apply initial CSS classes to chars/words/lines after split. Values can be strings or `(el, index) => string` functions |
95
+ | `initialClasses` | `object` | — | Apply initial CSS classes to chars/words/lines. Values are strings |
93
96
 
94
97
  #### Return Value
95
98
 
@@ -102,13 +105,57 @@ const result = splitText(element, options);
102
105
  }
103
106
  ```
104
107
 
108
+ ### `createSplitClones(splitResult, options)` (`fetta/helpers`)
109
+
110
+ Builds swap/reveal DOM layers (clones + optional wrappers) without coupling to any animation library.
111
+
112
+ ```ts
113
+ import { splitText } from "fetta";
114
+ import { createSplitClones } from "fetta/helpers";
115
+
116
+ const split = splitText(element, { type: "chars", mask: "chars" });
117
+ const layers = createSplitClones(split, { unit: "chars", wrap: true });
118
+
119
+ // Animate with Motion, GSAP, WAAPI, or CSS
120
+ // ...
121
+
122
+ layers.cleanup(); // removes clones/wrappers, keeps split DOM
123
+ // layers.cleanup({ revertSplit: true }) // also calls split.revert()
124
+ ```
125
+
126
+ #### Behavior
127
+
128
+ - Clone is always appended to the **current parent** of the original split node.
129
+ - `wrap: false` (default): clone is appended to existing parent (often the mask wrapper).
130
+ - `wrap: true`: original is first moved into a track wrapper, then clone is appended there.
131
+ - Helper never calls `splitText` and never performs animation.
132
+
133
+ #### Options
134
+
135
+ | Option | Type | Default | Description |
136
+ |--------|------|---------|-------------|
137
+ | `unit` | `"chars" \| "words" \| "lines"` | — | Which split nodes to layer |
138
+ | `wrap` | `boolean` | `false` | Wrap each original in a track wrapper (`position: relative`) |
139
+ | `display` | `"auto" \| "inline-block" \| "block"` | `"auto"` | Track display when `wrap: true` (`lines` => `block`, others => `inline-block`) |
140
+ | `cloneOffset.axis` | `"x" \| "y"` | `"y"` | Axis used for initial clone offset |
141
+ | `cloneOffset.direction` | `"start" \| "end"` | `"start"` | Offset direction (`start` => negative) |
142
+ | `cloneOffset.distance` | `string` | `"100%"` | Offset distance |
143
+ | `trackClassName` / `cloneClassName` | `string \| (ctx) => string` | — | Class names (static or per-item) |
144
+ | `trackStyle` / `cloneStyle` | `object \| (ctx) => object` | — | Inline styles (static or per-item) |
145
+
146
+ For reveal/swap effects, use matching `mask` in `splitText` (`"chars"`, `"words"`, or `"lines"`).
147
+
105
148
  ### `<SplitText>` (React)
106
149
 
107
150
  ```tsx
108
151
  import { SplitText } from 'fetta/react';
109
152
  ```
110
153
 
111
- #### Props
154
+ `fetta/react` forwards common wrapper DOM props (`id`, `role`, `tabIndex`, `aria-*`, `data-*`, and event handlers like `onClick`) to the wrapper element.
155
+
156
+ `fetta/react` props:
157
+
158
+ #### React Props
112
159
 
113
160
  | Prop | Type | Default | Description |
114
161
  |------|------|---------|-------------|
@@ -117,21 +164,35 @@ import { SplitText } from 'fetta/react';
117
164
  | `className` | `string` | — | Class name for wrapper element |
118
165
  | `style` | `CSSProperties` | — | Additional styles for wrapper element |
119
166
  | `ref` | `Ref<HTMLElement>` | — | Ref to container element |
120
- | `onSplit` | `(result) => void` | — | Called after text is split |
121
- | `onResize` | `(result) => void` | — | Called on autoSplit re-split |
122
- | `options` | `SplitOptions` | — | Split options (type, classes, mask, propIndex, disableKerning) |
167
+ | `onSplit` | `(result) => CallbackReturn` | — | Called after text is split |
168
+ | `onResplit` | `(result) => void` | — | Called when autoSplit/full-resplit replaces split output elements |
169
+ | `options` | `SplitTextOptions` | — | Split options (type, classes, mask, propIndex, disableKerning) |
123
170
  | `autoSplit` | `boolean` | `false` | Re-split on container resize |
171
+ | `waitForFonts` | `boolean` | `true` | Wait for `document.fonts.ready` before splitting (recommended for stable kerning). Set `false` for immediate split. |
124
172
  | `revertOnComplete` | `boolean` | `false` | Revert after animation completes |
125
- | `inView` | `boolean \| InViewOptions` | `false` | Enable viewport detection |
126
- | `onInView` | `(result) => void` | — | Called when element enters viewport |
127
- | `onLeaveView` | `(result) => void` | — | Called when element leaves viewport |
173
+ | `onRevert` | `() => void` | | Called when split text is reverted (manual or automatic) |
174
+ | `viewport` | `ViewportOptions` | — | Configure viewport detection |
175
+ | `onViewportEnter` | `(result) => CallbackReturn` | — | Called when element enters viewport |
176
+ | `onViewportLeave` | `(result) => CallbackReturn` | — | Called when element leaves viewport |
128
177
  | `initialStyles` | `object` | — | Apply initial inline styles to chars/words/lines. Values can be objects or `(el, index) => object` functions |
129
- | `initialClasses` | `object` | — | Apply initial CSS classes to chars/words/lines. Values can be strings or `(el, index) => string` functions |
130
- | `resetOnLeave` | `boolean` | `false` | Re-apply initialStyles/initialClasses when leaving viewport |
178
+ | `initialClasses` | `object` | — | Apply initial CSS classes to chars/words/lines. Values are strings |
179
+ | `resetOnViewportLeave` | `boolean` | `false` | Re-apply initialStyles/initialClasses when leaving viewport |
180
+
181
+ #### Shared `SplitTextOptions` (`options` prop)
182
+
183
+ | Option | Type | Default | Description |
184
+ |------|------|---------|-------------|
185
+ | `type` | `SplitType` | `"chars,words,lines"` | What to split: `"chars"`, `"words"`, `"lines"`, or combinations |
186
+ | `charClass` | `string` | `"split-char"` | CSS class for character spans |
187
+ | `wordClass` | `string` | `"split-word"` | CSS class for word spans |
188
+ | `lineClass` | `string` | `"split-line"` | CSS class for line spans |
189
+ | `mask` | `"lines" \| "words" \| "chars"` | — | Wrap elements in `overflow: clip` mask containers |
190
+ | `propIndex` | `boolean` | `false` | Add CSS index variables (`--char-index`, `--word-index`, `--line-index`) |
191
+ | `disableKerning` | `boolean` | `false` | Skip kerning compensation (no margin adjustments) |
131
192
 
132
193
  #### Callback Signature
133
194
 
134
- All callbacks (`onSplit`, `onResize`, `onInView`, `onLeaveView`) receive the same result object:
195
+ All callbacks (`onSplit`, `onResplit`, `onViewportEnter`, `onViewportLeave`) receive:
135
196
 
136
197
  ```ts
137
198
  {
@@ -142,16 +203,85 @@ All callbacks (`onSplit`, `onResize`, `onInView`, `onLeaveView`) receive the sam
142
203
  }
143
204
  ```
144
205
 
145
- #### InView Options
206
+ ```ts
207
+ type CallbackReturn =
208
+ | void
209
+ | Promise<unknown>
210
+ | { finished: Promise<unknown> }
211
+ | { then: (onFulfilled?: ((result: unknown) => unknown) | undefined) => unknown }
212
+ | CallbackReturn[];
213
+ ```
214
+
215
+ 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.
216
+
217
+ `onRevert` is a separate zero-argument callback that fires when a split cycle actually reverts.
218
+
219
+ #### Viewport Options
146
220
 
147
221
  ```ts
148
222
  {
149
- amount?: number; // How much must be visible (0-1), default: 0
150
- margin?: string; // Root margin, default: "0px"
151
- once?: boolean; // Only trigger once, default: false
223
+ amount?: number | "some" | "all"; // Enter threshold, default: 0
224
+ leave?: number | "some" | "all"; // Leave threshold, default: 0
225
+ margin?: string; // Root margin, default: "0px"
226
+ once?: boolean; // Only trigger once, default: false
227
+ root?: RefObject<Element>; // Optional root element
152
228
  }
153
229
  ```
154
230
 
231
+ ### `<SplitText>` (Motion)
232
+
233
+ ```tsx
234
+ import { SplitText } from "fetta/motion";
235
+ ```
236
+
237
+ `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.
238
+
239
+ Animate on exit with Motion's `AnimatePresence` (make `SplitText` the direct child):
240
+
241
+ ```tsx
242
+ import { AnimatePresence } from "motion/react";
243
+
244
+ <AnimatePresence>
245
+ {isVisible && (
246
+ <SplitText
247
+ variants={{
248
+ enter: { opacity: 1, y: 0 },
249
+ exit: { opacity: 0, y: 12 },
250
+ }}
251
+ initial="enter"
252
+ animate="enter"
253
+ exit="exit"
254
+ options={{ type: "words" }}
255
+ >
256
+ <h1>Goodbye</h1>
257
+ </SplitText>
258
+ )}
259
+ </AnimatePresence>
260
+ ```
261
+
262
+ #### Motion-only Props
263
+
264
+ | Prop | Type | Default | Description |
265
+ |------|------|---------|-------------|
266
+ | `variants` | `Record<string, VariantDefinition<TCustom>>` | — | Named variant definitions |
267
+ | `initial` | `string \| VariantDefinition<TCustom> \| false` | — | Initial variant applied instantly after split |
268
+ | `animate` | `string \| VariantDefinition<TCustom>` | — | Base variant |
269
+ | `exit` | `string \| VariantDefinition<TCustom> \| false` | — | Exit variant (AnimatePresence) |
270
+ | `whileInView` | `string \| VariantDefinition<TCustom>` | — | Variant while element is in view |
271
+ | `whileOutOfView` | `string \| VariantDefinition<TCustom>` | — | Variant after element leaves view |
272
+ | `whileScroll` | `string \| VariantDefinition<TCustom>` | — | Scroll-driven variant (highest trigger priority) |
273
+ | `whileHover` | `string \| VariantDefinition<TCustom>` | — | Variant on hover |
274
+ | `whileTap` | `string \| VariantDefinition<TCustom>` | — | Variant on tap/press |
275
+ | `whileFocus` | `string \| VariantDefinition<TCustom>` | — | Variant on focus |
276
+ | `animateOnResplit` | `boolean` | `false` | Replay initial->animate on autoSplit/full-resplit |
277
+ | `scroll` | `{ offset?, axis?, container? }` | — | Scroll tracking options for `whileScroll` |
278
+ | `transition` | `AnimationOptions` | — | Global/default transition for variants |
279
+ | `custom` | `TCustom` | — | Custom data forwarded to function variants |
280
+ | `delayScope` | `"global" \| "local"` | `"global"` | Delay-function index scope (`globalIndex` vs relative `index`) |
281
+ | `reducedMotion` | `"user" \| "always" \| "never"` | — | Reduced-motion behavior for this component |
282
+ | `onHoverStart` | `() => void` | — | Called when hover starts |
283
+ | `onHoverEnd` | `() => void` | — | Called when hover ends |
284
+
155
285
  ## Examples
156
286
 
157
287
  ### Vanilla JavaScript
@@ -243,7 +373,7 @@ splitText(element, { type: 'chars', propIndex: true });
243
373
  </SplitText>
244
374
  ```
245
375
 
246
- #### Scroll-Triggered with InView
376
+ #### Scroll-Triggered with Viewport
247
377
 
248
378
  ```tsx
249
379
  <SplitText
@@ -251,11 +381,11 @@ splitText(element, { type: 'chars', propIndex: true });
251
381
  initialStyles={{
252
382
  words: { opacity: '0', transform: 'translateY(20px)' }
253
383
  }}
254
- inView={{ amount: 0.5 }}
255
- onInView={({ words }) => {
384
+ viewport={{ amount: 0.5 }}
385
+ onViewportEnter={({ words }) => {
256
386
  animate(words, { opacity: 1, y: 0 }, { delay: stagger(0.03) });
257
387
  }}
258
- resetOnLeave
388
+ resetOnViewportLeave
259
389
  >
260
390
  <p>Animates when scrolled into view</p>
261
391
  </SplitText>
@@ -284,7 +414,10 @@ Default classes applied to split elements:
284
414
  | `.split-word` | Words | Inline positioning |
285
415
  | `.split-line` | Lines | Block display |
286
416
 
287
- Each element also receives a `data-index` attribute with its position.
417
+ Split elements receive typed index attributes:
418
+ - Characters: `data-char-index`
419
+ - Words: `data-word-index`
420
+ - Lines: `data-line-index`
288
421
 
289
422
  ## Font Loading
290
423
 
@@ -297,7 +430,17 @@ document.fonts.ready.then(() => {
297
430
  });
298
431
  ```
299
432
 
300
- The React component handles this automatically no additional setup required.
433
+ React and Motion components wait for fonts by default (`waitForFonts={true}`), which gives the most stable kerning.
434
+
435
+ If you notice a visual shift after splitting, keep the default waiting behavior enabled.
436
+
437
+ If you need immediate splitting (for example, responsiveness-first UI), you can opt out with `waitForFonts={false}`:
438
+
439
+ ```tsx
440
+ <SplitText waitForFonts={false}>
441
+ <h1>Split Immediately</h1>
442
+ </SplitText>
443
+ ```
301
444
 
302
445
  ## Accessibility
303
446
 
@@ -332,6 +475,7 @@ Pre-existing `aria-label` attributes are always preserved.
332
475
  ## Notes
333
476
 
334
477
  - **Ligatures are disabled** (`font-variant-ligatures: none`) because ligatures cannot span multiple elements.
478
+ - **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.
335
479
 
336
480
  ## Browser Support
337
481
 
@@ -0,0 +1,43 @@
1
+ // src/internal/initialStyles.ts
2
+ function reapplyInitialStyles(elements, style) {
3
+ if (!style || elements.length === 0) return;
4
+ const isFn = typeof style === "function";
5
+ for (let i = 0; i < elements.length; i++) {
6
+ const el = elements[i];
7
+ const styles = isFn ? style(el, i) : style;
8
+ for (const [key, value] of Object.entries(styles)) {
9
+ if (value == null) continue;
10
+ if (key === "cssText") {
11
+ if (typeof value === "string") {
12
+ el.style.cssText = value;
13
+ }
14
+ continue;
15
+ }
16
+ if (typeof value !== "string" && typeof value !== "number") continue;
17
+ const cssValue = typeof value === "number" ? String(value) : value;
18
+ const cssKey = key.startsWith("--") ? key : key.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`);
19
+ el.style.setProperty(cssKey, cssValue);
20
+ }
21
+ }
22
+ }
23
+ function reapplyInitialClasses(elements, className) {
24
+ if (!className || elements.length === 0) return;
25
+ const classes = className.split(/\s+/).filter(Boolean);
26
+ for (const el of elements) {
27
+ el.classList.add(...classes);
28
+ }
29
+ }
30
+
31
+ // src/internal/waitForFontsReady.ts
32
+ async function waitForFontsReady(waitForFonts) {
33
+ if (!waitForFonts) return;
34
+ const fonts = document.fonts;
35
+ const ready = fonts == null ? void 0 : fonts.ready;
36
+ if (!ready || typeof ready.then !== "function") return;
37
+ try {
38
+ await ready;
39
+ } catch (e) {
40
+ }
41
+ }
42
+
43
+ export { reapplyInitialClasses, reapplyInitialStyles, waitForFontsReady };
@@ -0,0 +1,33 @@
1
+ var __defProp = Object.defineProperty;
2
+ var __defProps = Object.defineProperties;
3
+ var __getOwnPropDescs = Object.getOwnPropertyDescriptors;
4
+ var __getOwnPropSymbols = Object.getOwnPropertySymbols;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __propIsEnum = Object.prototype.propertyIsEnumerable;
7
+ var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
8
+ var __spreadValues = (a, b) => {
9
+ for (var prop in b || (b = {}))
10
+ if (__hasOwnProp.call(b, prop))
11
+ __defNormalProp(a, prop, b[prop]);
12
+ if (__getOwnPropSymbols)
13
+ for (var prop of __getOwnPropSymbols(b)) {
14
+ if (__propIsEnum.call(b, prop))
15
+ __defNormalProp(a, prop, b[prop]);
16
+ }
17
+ return a;
18
+ };
19
+ var __spreadProps = (a, b) => __defProps(a, __getOwnPropDescs(b));
20
+ var __objRest = (source, exclude) => {
21
+ var target = {};
22
+ for (var prop in source)
23
+ if (__hasOwnProp.call(source, prop) && exclude.indexOf(prop) < 0)
24
+ target[prop] = source[prop];
25
+ if (source != null && __getOwnPropSymbols)
26
+ for (var prop of __getOwnPropSymbols(source)) {
27
+ if (exclude.indexOf(prop) < 0 && __propIsEnum.call(source, prop))
28
+ target[prop] = source[prop];
29
+ }
30
+ return target;
31
+ };
32
+
33
+ export { __objRest, __spreadProps, __spreadValues };