fetta 1.0.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.
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Custom splitText implementation with built-in kerning compensation.
3
+ * Measures character positions before splitting, applies compensation,
4
+ * then detects lines based on actual rendered positions.
5
+ */
6
+ /**
7
+ * Configuration options for the splitText function.
8
+ *
9
+ * @example
10
+ * ```typescript
11
+ * const options: SplitTextOptions = {
12
+ * type: "chars,words,lines",
13
+ * charClass: "char",
14
+ * mask: "lines",
15
+ * autoSplit: true,
16
+ * };
17
+ * ```
18
+ */
19
+ interface SplitTextOptions {
20
+ /** Split type: chars, words, lines, or combinations like "chars,words" */
21
+ type?: "chars" | "words" | "lines" | "chars,words" | "words,lines" | "chars,lines" | "chars,words,lines";
22
+ charClass?: string;
23
+ wordClass?: string;
24
+ lineClass?: string;
25
+ /** Apply overflow mask wrapper to elements for reveal animations */
26
+ mask?: "lines" | "words" | "chars";
27
+ /** Auto-split on resize (observes parent element) */
28
+ autoSplit?: boolean;
29
+ /** Callback when resize triggers re-split (does not re-trigger initial animations) */
30
+ onResize?: (result: Omit<SplitTextResult, "revert" | "dispose">) => void;
31
+ /** Callback fired after text is split, receives split elements. Return animation for revertOnComplete. */
32
+ onSplit?: (result: {
33
+ chars: HTMLSpanElement[];
34
+ words: HTMLSpanElement[];
35
+ lines: HTMLSpanElement[];
36
+ }) => void | {
37
+ finished: Promise<unknown>;
38
+ } | Array<{
39
+ finished: Promise<unknown>;
40
+ }> | Promise<unknown>;
41
+ /** Auto-revert when onSplit animation completes */
42
+ revertOnComplete?: boolean;
43
+ /** Add CSS custom properties (--char-index, --word-index, --line-index) */
44
+ propIndex?: boolean;
45
+ /** Add will-change: transform, opacity to split elements for better animation performance */
46
+ willChange?: boolean;
47
+ }
48
+ /**
49
+ * Result returned by splitText containing arrays of split elements and a revert function.
50
+ *
51
+ * Each array contains the created span elements. Empty arrays are returned for
52
+ * split types not requested (e.g., if `type: "words"`, chars and lines will be empty).
53
+ */
54
+ interface SplitTextResult {
55
+ /** Array of character span elements (empty if chars not in type) */
56
+ chars: HTMLSpanElement[];
57
+ /** Array of word span elements (empty if words not in type) */
58
+ words: HTMLSpanElement[];
59
+ /** Array of line span elements (empty if lines not in type) */
60
+ lines: HTMLSpanElement[];
61
+ /** Revert the element to its original HTML and cleanup all observers/timers */
62
+ revert: () => void;
63
+ }
64
+ /**
65
+ * Split text into characters, words, and lines with kerning compensation.
66
+ *
67
+ * Fetta measures character positions before splitting, then applies margin adjustments
68
+ * after splitting to preserve the original kerning (letter spacing). This prevents
69
+ * the visual "jumping" that occurs with naive text splitting.
70
+ *
71
+ * @param element - The HTML element containing text to split. Must have text content.
72
+ * @param options - Configuration options for splitting behavior
73
+ * @returns Object containing arrays of split elements and a revert function
74
+ *
75
+ * @throws {Error} If element is not an HTMLElement
76
+ *
77
+ * @example
78
+ * ```typescript
79
+ * import { splitText } from "fetta";
80
+ * import { animate, stagger } from "motion";
81
+ *
82
+ * // Basic usage
83
+ * const { chars, words, lines, revert } = splitText(element);
84
+ *
85
+ * // Animate words
86
+ * animate(words, { opacity: [0, 1], y: [20, 0] }, { delay: stagger(0.05) });
87
+ *
88
+ * // Revert to original HTML when done
89
+ * revert();
90
+ * ```
91
+ *
92
+ * @example
93
+ * ```typescript
94
+ * // Auto-revert after animation completes
95
+ * splitText(element, {
96
+ * onSplit: ({ words }) => animate(words, { opacity: [0, 1] }),
97
+ * revertOnComplete: true,
98
+ * });
99
+ * ```
100
+ *
101
+ * @example
102
+ * ```typescript
103
+ * // Responsive re-splitting
104
+ * splitText(element, {
105
+ * autoSplit: true,
106
+ * onResize: ({ lines }) => {
107
+ * // Re-animate after resize
108
+ * animate(lines, { opacity: [0, 1] });
109
+ * },
110
+ * });
111
+ * ```
112
+ */
113
+ declare function splitText(element: HTMLElement, { type, charClass, wordClass, lineClass, mask, autoSplit, onResize, onSplit, revertOnComplete, propIndex, willChange, }?: SplitTextOptions): SplitTextResult;
114
+
115
+ export { type SplitTextOptions, type SplitTextResult, splitText };
package/dist/index.js ADDED
@@ -0,0 +1 @@
1
+ export { splitText } from './chunk-KGMU2B53.js';
@@ -0,0 +1,121 @@
1
+ import * as react from 'react';
2
+ import { ReactElement } from 'react';
3
+ export { SplitTextOptions, SplitTextResult } from './index.js';
4
+
5
+ interface SplitTextOptions {
6
+ type?: "chars" | "words" | "lines" | "chars,words" | "words,lines" | "chars,lines" | "chars,words,lines";
7
+ charClass?: string;
8
+ wordClass?: string;
9
+ lineClass?: string;
10
+ /** Apply overflow mask wrapper to elements for reveal animations */
11
+ mask?: "lines" | "words" | "chars";
12
+ propIndex?: boolean;
13
+ willChange?: boolean;
14
+ }
15
+ interface InViewOptions {
16
+ /** How much of the element must be visible (0-1). Default: 0 */
17
+ amount?: number;
18
+ /** Root margin for IntersectionObserver. Default: "0px" */
19
+ margin?: string;
20
+ /** Only trigger once. Default: false */
21
+ once?: boolean;
22
+ }
23
+ /**
24
+ * Result passed to SplitText callbacks (onSplit, onInView, onLeaveView, onResize).
25
+ *
26
+ * Contains arrays of split elements and a revert function for manual control.
27
+ * Empty arrays are returned for split types not requested in options.
28
+ */
29
+ interface SplitTextElements {
30
+ /** Array of character span elements */
31
+ chars: HTMLSpanElement[];
32
+ /** Array of word span elements */
33
+ words: HTMLSpanElement[];
34
+ /** Array of line span elements */
35
+ lines: HTMLSpanElement[];
36
+ /** Revert to original HTML and cleanup observers */
37
+ revert: () => void;
38
+ }
39
+ /** Return type for callbacks - void, single animation, array of animations, or promise */
40
+ type CallbackReturn = void | {
41
+ finished: Promise<unknown>;
42
+ } | Array<{
43
+ finished: Promise<unknown>;
44
+ }> | Promise<unknown>;
45
+ interface SplitTextProps {
46
+ children: ReactElement;
47
+ /**
48
+ * Called after text is split.
49
+ * Return an animation or promise to enable revert (requires revertOnComplete).
50
+ * If inView is enabled, this is called immediately but animation typically runs in onInView.
51
+ */
52
+ onSplit?: (result: SplitTextElements) => CallbackReturn;
53
+ /** Called when autoSplit triggers a re-split on resize */
54
+ onResize?: (result: SplitTextElements) => void;
55
+ options?: SplitTextOptions;
56
+ autoSplit?: boolean;
57
+ /** When true, reverts to original HTML after animation promise resolves */
58
+ revertOnComplete?: boolean;
59
+ /** Enable viewport detection. Pass true for defaults or InViewOptions for customization */
60
+ inView?: boolean | InViewOptions;
61
+ /** Called when element enters viewport. Return animation for revertOnComplete support */
62
+ onInView?: (result: SplitTextElements) => CallbackReturn;
63
+ /** Called when element leaves viewport */
64
+ onLeaveView?: (result: SplitTextElements) => CallbackReturn;
65
+ }
66
+ /**
67
+ * React component wrapper for text splitting with kerning compensation.
68
+ *
69
+ * Wraps a single child element and splits its text content into characters,
70
+ * words, and/or lines. Handles lifecycle cleanup automatically on unmount.
71
+ *
72
+ * @param props - Component props including callbacks and options
73
+ * @returns The child element wrapped in a container div
74
+ *
75
+ * @example
76
+ * ```tsx
77
+ * import { SplitText } from "fetta/react";
78
+ * import { animate, stagger } from "motion";
79
+ *
80
+ * // Basic animation
81
+ * <SplitText
82
+ * onSplit={({ words }) => {
83
+ * animate(words, { opacity: [0, 1], y: [20, 0] }, { delay: stagger(0.05) });
84
+ * }}
85
+ * >
86
+ * <h1>Animated Text</h1>
87
+ * </SplitText>
88
+ * ```
89
+ *
90
+ * @example
91
+ * ```tsx
92
+ * // Scroll-triggered with auto-revert
93
+ * <SplitText
94
+ * onSplit={({ chars }) => {
95
+ * chars.forEach(c => c.style.opacity = "0");
96
+ * }}
97
+ * inView={{ amount: 0.5, once: true }}
98
+ * onInView={({ chars }) =>
99
+ * animate(chars, { opacity: 1 }, { delay: stagger(0.02) })
100
+ * }
101
+ * revertOnComplete
102
+ * >
103
+ * <p>Reveals on scroll, reverts after animation</p>
104
+ * </SplitText>
105
+ * ```
106
+ *
107
+ * @example
108
+ * ```tsx
109
+ * // Responsive re-splitting
110
+ * <SplitText
111
+ * autoSplit
112
+ * onSplit={({ lines }) => animate(lines, { opacity: [0, 1] })}
113
+ * onResize={({ lines }) => animate(lines, { opacity: [0, 1] })}
114
+ * >
115
+ * <p>Re-animates when container resizes</p>
116
+ * </SplitText>
117
+ * ```
118
+ */
119
+ declare const SplitText: react.ForwardRefExoticComponent<SplitTextProps & react.RefAttributes<HTMLDivElement>>;
120
+
121
+ export { SplitText, type SplitTextElements };
package/dist/react.js ADDED
@@ -0,0 +1,181 @@
1
+ import { splitText, __spreadProps, __spreadValues, normalizeToPromise } from './chunk-KGMU2B53.js';
2
+ import { forwardRef, useRef, useCallback, useState, useLayoutEffect, useEffect, isValidElement, cloneElement } from 'react';
3
+ import { jsx } from 'react/jsx-runtime';
4
+
5
+ var SplitText = forwardRef(
6
+ function SplitText2({
7
+ children,
8
+ onSplit,
9
+ onResize,
10
+ options,
11
+ autoSplit = false,
12
+ revertOnComplete = false,
13
+ inView,
14
+ onInView,
15
+ onLeaveView
16
+ }, forwardedRef) {
17
+ const containerRef = useRef(null);
18
+ const mergedRef = useCallback(
19
+ (node) => {
20
+ containerRef.current = node;
21
+ if (typeof forwardedRef === "function") {
22
+ forwardedRef(node);
23
+ } else if (forwardedRef) {
24
+ forwardedRef.current = node;
25
+ }
26
+ },
27
+ [forwardedRef]
28
+ );
29
+ const [childElement, setChildElement] = useState(null);
30
+ const [isInView, setIsInView] = useState(false);
31
+ const onSplitRef = useRef(onSplit);
32
+ const onResizeRef = useRef(onResize);
33
+ const optionsRef = useRef(options);
34
+ const revertOnCompleteRef = useRef(revertOnComplete);
35
+ const inViewRef = useRef(inView);
36
+ const onInViewRef = useRef(onInView);
37
+ const onLeaveViewRef = useRef(onLeaveView);
38
+ useLayoutEffect(() => {
39
+ onSplitRef.current = onSplit;
40
+ onResizeRef.current = onResize;
41
+ optionsRef.current = options;
42
+ revertOnCompleteRef.current = revertOnComplete;
43
+ inViewRef.current = inView;
44
+ onInViewRef.current = onInView;
45
+ onLeaveViewRef.current = onLeaveView;
46
+ });
47
+ const hasSplitRef = useRef(false);
48
+ const hasRevertedRef = useRef(false);
49
+ const revertFnRef = useRef(null);
50
+ const splitResultRef = useRef(null);
51
+ const observerRef = useRef(null);
52
+ const hasTriggeredOnceRef = useRef(false);
53
+ const childRefCallback = useCallback((node) => {
54
+ setChildElement(node);
55
+ }, []);
56
+ useEffect(() => {
57
+ if (!childElement) return;
58
+ if (hasSplitRef.current) return;
59
+ let isMounted = true;
60
+ document.fonts.ready.then(() => {
61
+ var _a, _b;
62
+ if (!isMounted || hasSplitRef.current) return;
63
+ if (!containerRef.current) return;
64
+ const result = splitText(childElement, __spreadProps(__spreadValues({}, optionsRef.current), {
65
+ autoSplit,
66
+ onResize: (resizeResult) => {
67
+ var _a2;
68
+ const newSplitTextElements = {
69
+ chars: resizeResult.chars,
70
+ words: resizeResult.words,
71
+ lines: resizeResult.lines,
72
+ revert: result.revert
73
+ };
74
+ splitResultRef.current = newSplitTextElements;
75
+ (_a2 = onResizeRef.current) == null ? void 0 : _a2.call(onResizeRef, newSplitTextElements);
76
+ }
77
+ }));
78
+ revertFnRef.current = result.revert;
79
+ hasSplitRef.current = true;
80
+ const splitElements = {
81
+ chars: result.chars,
82
+ words: result.words,
83
+ lines: result.lines,
84
+ revert: result.revert
85
+ };
86
+ splitResultRef.current = splitElements;
87
+ containerRef.current.style.visibility = "visible";
88
+ if (onSplitRef.current) {
89
+ const callbackResult = onSplitRef.current(splitElements);
90
+ if (!inViewRef.current && revertOnCompleteRef.current) {
91
+ const promise = normalizeToPromise(callbackResult);
92
+ if (promise) {
93
+ promise.then(() => {
94
+ if (!isMounted || hasRevertedRef.current) return;
95
+ result.revert();
96
+ hasRevertedRef.current = true;
97
+ }).catch(() => {
98
+ console.warn("[fetta] Animation rejected, text not reverted");
99
+ });
100
+ } else if (callbackResult === void 0) ; else {
101
+ console.warn(
102
+ "SplitText: revertOnComplete is enabled but onSplit did not return an animation or promise."
103
+ );
104
+ }
105
+ }
106
+ }
107
+ if (inViewRef.current && containerRef.current) {
108
+ const inViewOptions = typeof inViewRef.current === "object" ? inViewRef.current : {};
109
+ const threshold = (_a = inViewOptions.amount) != null ? _a : 0;
110
+ const rootMargin = (_b = inViewOptions.margin) != null ? _b : "0px";
111
+ observerRef.current = new IntersectionObserver(
112
+ (entries) => {
113
+ const entry = entries[0];
114
+ if (!entry) return;
115
+ const isOnce = typeof inViewRef.current === "object" && inViewRef.current.once;
116
+ if (entry.isIntersecting) {
117
+ if (isOnce && hasTriggeredOnceRef.current) return;
118
+ hasTriggeredOnceRef.current = true;
119
+ setIsInView(true);
120
+ } else {
121
+ if (!isOnce) {
122
+ setIsInView(false);
123
+ }
124
+ }
125
+ },
126
+ { threshold, rootMargin }
127
+ );
128
+ observerRef.current.observe(containerRef.current);
129
+ }
130
+ });
131
+ return () => {
132
+ isMounted = false;
133
+ if (observerRef.current) {
134
+ observerRef.current.disconnect();
135
+ observerRef.current = null;
136
+ }
137
+ if (revertFnRef.current) {
138
+ revertFnRef.current();
139
+ }
140
+ hasSplitRef.current = false;
141
+ };
142
+ }, [childElement, autoSplit]);
143
+ useEffect(() => {
144
+ if (!splitResultRef.current) return;
145
+ if (hasRevertedRef.current) return;
146
+ if (isInView && onInViewRef.current) {
147
+ const callbackResult = onInViewRef.current(splitResultRef.current);
148
+ const promise = normalizeToPromise(callbackResult);
149
+ if (revertOnCompleteRef.current && promise) {
150
+ promise.then(() => {
151
+ var _a;
152
+ if (hasRevertedRef.current) return;
153
+ (_a = splitResultRef.current) == null ? void 0 : _a.revert();
154
+ hasRevertedRef.current = true;
155
+ }).catch(() => {
156
+ console.warn("[fetta] Animation rejected, text not reverted");
157
+ });
158
+ }
159
+ } else if (!isInView && onLeaveViewRef.current && splitResultRef.current) {
160
+ onLeaveViewRef.current(splitResultRef.current);
161
+ }
162
+ }, [isInView]);
163
+ if (!isValidElement(children)) {
164
+ console.error("SplitText: children must be a single valid React element");
165
+ return null;
166
+ }
167
+ const clonedChild = cloneElement(children, {
168
+ ref: childRefCallback
169
+ });
170
+ return /* @__PURE__ */ jsx(
171
+ "div",
172
+ {
173
+ ref: mergedRef,
174
+ style: { visibility: "hidden", position: "relative" },
175
+ children: clonedChild
176
+ }
177
+ );
178
+ }
179
+ );
180
+
181
+ export { SplitText };
package/package.json ADDED
@@ -0,0 +1,85 @@
1
+ {
2
+ "name": "fetta",
3
+ "version": "1.0.0",
4
+ "description": "Text splitting library with kerning compensation for animations",
5
+ "type": "module",
6
+ "sideEffects": false,
7
+ "exports": {
8
+ ".": {
9
+ "types": "./dist/index.d.ts",
10
+ "import": "./dist/index.js"
11
+ },
12
+ "./core": {
13
+ "types": "./dist/index.d.ts",
14
+ "import": "./dist/index.js"
15
+ },
16
+ "./react": {
17
+ "types": "./dist/react.d.ts",
18
+ "import": "./dist/react.js"
19
+ }
20
+ },
21
+ "main": "./dist/index.js",
22
+ "types": "./dist/index.d.ts",
23
+ "files": [
24
+ "dist",
25
+ "README.md",
26
+ "LICENSE"
27
+ ],
28
+ "scripts": {
29
+ "build": "tsup",
30
+ "dev": "tsup --watch",
31
+ "typecheck": "tsc --noEmit",
32
+ "prepublishOnly": "pnpm run build",
33
+ "test": "vitest",
34
+ "test:ui": "vitest --ui",
35
+ "test:coverage": "vitest run --coverage",
36
+ "test:e2e": "playwright test",
37
+ "test:all": "vitest run --coverage && playwright test"
38
+ },
39
+ "peerDependencies": {
40
+ "react": ">=18.0.0"
41
+ },
42
+ "peerDependenciesMeta": {
43
+ "react": {
44
+ "optional": true
45
+ }
46
+ },
47
+ "devDependencies": {
48
+ "@playwright/test": "^1.49.0",
49
+ "@testing-library/dom": "^10.4.0",
50
+ "@testing-library/jest-dom": "^6.6.3",
51
+ "@testing-library/react": "^16.1.0",
52
+ "@types/react": "^19",
53
+ "@vitest/coverage-v8": "^2.1.8",
54
+ "@vitest/ui": "^2.1.8",
55
+ "jsdom": "^25.0.1",
56
+ "react": "^19.2.3",
57
+ "react-dom": "^19.0.0",
58
+ "serve": "^14.2.4",
59
+ "tsup": "^8.0.0",
60
+ "typescript": "^5",
61
+ "vitest": "^2.1.8"
62
+ },
63
+ "keywords": [
64
+ "text",
65
+ "split",
66
+ "animation",
67
+ "motion",
68
+ "kerning",
69
+ "typography",
70
+ "gsap"
71
+ ],
72
+ "license": "MIT",
73
+ "author": "dimi",
74
+ "repository": {
75
+ "type": "git",
76
+ "url": "git+https://github.com/dimicx/fetta.git"
77
+ },
78
+ "bugs": {
79
+ "url": "https://github.com/dimicx/fetta/issues"
80
+ },
81
+ "homepage": "https://fetta.dimi.me",
82
+ "publishConfig": {
83
+ "access": "public"
84
+ }
85
+ }