@vysmo/text-react 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Maesto LLC
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,119 @@
1
+ # @vysmo/text-react
2
+
3
+ React bindings for [`@vysmo/text`](https://www.npmjs.com/package/@vysmo/text). One `<AnimateText>` component, one hook (`useAnimateText`), 243 presets out of the box.
4
+
5
+ [Live preset browser + Studio](https://vysmo.com/text) · [Source](https://github.com/vysmodev)
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ pnpm add @vysmo/text @vysmo/text-react
11
+ ```
12
+
13
+ `react` ≥ 18 is a peer dependency.
14
+
15
+ ## Quick start
16
+
17
+ ```tsx
18
+ import { AnimateText } from "@vysmo/text-react";
19
+
20
+ export function Hero() {
21
+ return (
22
+ <AnimateText as="h1" preset="enter/fade-up">
23
+ Hello world
24
+ </AnimateText>
25
+ );
26
+ }
27
+ ```
28
+
29
+ The component renders a single element (`<span>` by default; override via `as`), and on mount runs `animateText` against it with the props you've passed. Cleanup happens automatically on unmount.
30
+
31
+ ## Tree-shake the catalog
32
+
33
+ Pass a preset *object* instead of a name and only that preset ships in your bundle:
34
+
35
+ ```tsx
36
+ import { AnimateText } from "@vysmo/text-react";
37
+ import { fadeUp } from "@vysmo/text";
38
+
39
+ <AnimateText as="h1" preset={fadeUp}>
40
+ Hello world
41
+ </AnimateText>
42
+ ```
43
+
44
+ The string-name path pulls in the whole 243-preset registry; the object path is byte-for-byte just the preset you imported.
45
+
46
+ ## Custom choreography
47
+
48
+ Skip the preset and pass `animations` directly:
49
+
50
+ ```tsx
51
+ <AnimateText
52
+ as="h1"
53
+ split="character"
54
+ stagger={28}
55
+ animations={[
56
+ { prop: "opacity", from: 0, to: 1, duration: 600, ease: "expo.out" },
57
+ { prop: "translateY", from: 24, to: 0, duration: 800, ease: "back.out" },
58
+ ]}
59
+ >
60
+ Hand-rolled
61
+ </AnimateText>
62
+ ```
63
+
64
+ ## Props
65
+
66
+ | Prop | Type | Default | Notes |
67
+ |---|---|---|---|
68
+ | `as` | `ElementType` | `"span"` | Tag name for the wrapper element. Pass `"h1"`, `"p"`, etc. for semantic markup. |
69
+ | `children` | `ReactNode` | — | The text content. Strings are simplest; reference changes re-run the animation. |
70
+ | `preset` | `PresetName \| Preset` | — | Preset name (string) or imported preset object (tree-shakable). |
71
+ | `split` | `"character" \| "word" \| "line"` | preset's split, or `"character"` | Split granularity. |
72
+ | `stagger` | `number` | `30` | Milliseconds between consecutive slices starting. |
73
+ | `staggerOrder` | `StaggerOrder` | `"start"` | Order in which slices receive their offset. |
74
+ | `animations` | `TextAnimationSpec[]` | — | Custom specs (used when no `preset` or to override). |
75
+ | `perspective` | `number` | — | Container perspective in px. Required for visible 3D transforms. |
76
+ | `perspectiveOrigin` | `string` | — | Container perspective-origin. |
77
+ | `transformOrigin` | `TransformOrigin` | — | Origin applied to every slice. |
78
+ | `autoPlay` | `boolean` | `true` | Begin playing automatically on mount. |
79
+ | `delay` | `number` | — | Ms before the first play begins. |
80
+ | `repeat` | `number \| "infinite"` | `1` | How many cycles. |
81
+ | `repeatDelay` | `number` | `0` | Delay between cycles when `repeat > 1`. |
82
+ | `onComplete` | `() => void` | — | Fires when the choreography finishes naturally (won't fire while looping). |
83
+ | `className` | `string` | — | Forwarded to the wrapper element. |
84
+ | `style` | `CSSProperties` | — | Forwarded to the wrapper element. |
85
+
86
+ ## Replay on the same props
87
+
88
+ Re-run an animation that's already played using a `key` prop:
89
+
90
+ ```tsx
91
+ <AnimateText key={replayCount} preset="emphasis/pulse">
92
+ Important
93
+ </AnimateText>
94
+ ```
95
+
96
+ Bumping the key fully remounts the component, so the animation runs again.
97
+
98
+ ## Hook (advanced)
99
+
100
+ When you can't wrap the JSX (Markdown-rendered headings, MDX, third-party components), animate an external ref:
101
+
102
+ ```tsx
103
+ import { useRef } from "react";
104
+ import { useAnimateText } from "@vysmo/text-react";
105
+
106
+ function MyHeading({ children }) {
107
+ const ref = useRef<HTMLHeadingElement>(null);
108
+ useAnimateText(ref, { preset: "enter/fade-up" });
109
+ return <h1 ref={ref}>{children}</h1>;
110
+ }
111
+ ```
112
+
113
+ ## SSR
114
+
115
+ The wrapper is SSR-safe: `useEffect` bodies don't run on the server, and the module body itself doesn't touch `window` / `document`. Server renders the wrapping element with its raw text; client mounts the animation.
116
+
117
+ ## License
118
+
119
+ MIT.
@@ -0,0 +1,50 @@
1
+ import type { CSSProperties, ElementType, ReactElement, ReactNode } from "react";
2
+ import { type Preset, type PresetName, type SplitMode, type StaggerOrder, type TextAnimationSpec, type TransformOrigin } from "@vysmo/text";
3
+ export interface AnimateTextProps {
4
+ /** Element to render. Default `"span"`. Pass `"h1"` etc. for semantic headings. */
5
+ as?: ElementType;
6
+ /** The text content. Strings work best; complex children re-run the animation when reference changes. */
7
+ children: ReactNode;
8
+ /** Preset name (e.g. `"enter/fade-up"`) or imported preset object (tree-shakable). */
9
+ preset?: PresetName | Preset;
10
+ /** Split granularity. Defaults to the preset's split, or `"character"`. */
11
+ split?: SplitMode;
12
+ /** Milliseconds between consecutive slices starting. Default `30`. */
13
+ stagger?: number;
14
+ /** Order in which slices receive their stagger offset. */
15
+ staggerOrder?: StaggerOrder;
16
+ /** Custom animation specs (used when no `preset` is set, or to override). */
17
+ animations?: TextAnimationSpec[];
18
+ /** Container `perspective` in px. Required for visible 3D transforms. */
19
+ perspective?: number;
20
+ /** Container `perspective-origin`. */
21
+ perspectiveOrigin?: string;
22
+ /** Transform origin applied to every slice. */
23
+ transformOrigin?: TransformOrigin;
24
+ /** Begin playing automatically on mount. Default `true`. */
25
+ autoPlay?: boolean;
26
+ /** Delay before the first play begins, in ms. */
27
+ delay?: number;
28
+ /** How many cycles. `1` (default), `n > 1`, or `"infinite"`. */
29
+ repeat?: number | "infinite";
30
+ /** Delay between successive cycles when `repeat > 1`. */
31
+ repeatDelay?: number;
32
+ /** Fires when the choreography finishes naturally (won't fire while looping). */
33
+ onComplete?: () => void;
34
+ /** Forwarded to the wrapper element. */
35
+ className?: string;
36
+ /** Forwarded to the wrapper element. */
37
+ style?: CSSProperties;
38
+ }
39
+ /**
40
+ * React wrapper around `@vysmo/text`'s `animateText`. Renders a single
41
+ * element (default `<span>`, override via `as`), and on mount calls
42
+ * `animateText` against the element with the props you've passed.
43
+ *
44
+ * On unmount the handle is `.stop()`-ed, restoring un-animated styles
45
+ * before React removes the DOM. Re-animation happens on prop changes
46
+ * (preset / stagger / repeat / etc.); for explicit replay on the same
47
+ * props, change a `key` prop to fully remount.
48
+ */
49
+ export declare function AnimateText({ as, children, preset, split, stagger, staggerOrder, animations, perspective, perspectiveOrigin, transformOrigin, autoPlay, delay, repeat, repeatDelay, onComplete, className, style, }: AnimateTextProps): ReactElement;
50
+ //# sourceMappingURL=AnimateText.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"AnimateText.d.ts","sourceRoot":"","sources":["../src/AnimateText.tsx"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,aAAa,EAAE,WAAW,EAAE,YAAY,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AACjF,OAAO,EAIL,KAAK,MAAM,EACX,KAAK,UAAU,EACf,KAAK,SAAS,EACd,KAAK,YAAY,EACjB,KAAK,iBAAiB,EACtB,KAAK,eAAe,EACrB,MAAM,aAAa,CAAC;AAErB,MAAM,WAAW,gBAAgB;IAC/B,mFAAmF;IACnF,EAAE,CAAC,EAAE,WAAW,CAAC;IACjB,yGAAyG;IACzG,QAAQ,EAAE,SAAS,CAAC;IACpB,sFAAsF;IACtF,MAAM,CAAC,EAAE,UAAU,GAAG,MAAM,CAAC;IAC7B,2EAA2E;IAC3E,KAAK,CAAC,EAAE,SAAS,CAAC;IAClB,sEAAsE;IACtE,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,0DAA0D;IAC1D,YAAY,CAAC,EAAE,YAAY,CAAC;IAC5B,6EAA6E;IAC7E,UAAU,CAAC,EAAE,iBAAiB,EAAE,CAAC;IACjC,yEAAyE;IACzE,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,sCAAsC;IACtC,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,+CAA+C;IAC/C,eAAe,CAAC,EAAE,eAAe,CAAC;IAClC,4DAA4D;IAC5D,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,iDAAiD;IACjD,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,gEAAgE;IAChE,MAAM,CAAC,EAAE,MAAM,GAAG,UAAU,CAAC;IAC7B,yDAAyD;IACzD,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,iFAAiF;IACjF,UAAU,CAAC,EAAE,MAAM,IAAI,CAAC;IACxB,wCAAwC;IACxC,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,wCAAwC;IACxC,KAAK,CAAC,EAAE,aAAa,CAAC;CACvB;AAED;;;;;;;;;GASG;AACH,wBAAgB,WAAW,CAAC,EAC1B,EAAW,EACX,QAAQ,EACR,MAAM,EACN,KAAK,EACL,OAAO,EACP,YAAY,EACZ,UAAU,EACV,WAAW,EACX,iBAAiB,EACjB,eAAe,EACf,QAAQ,EACR,KAAK,EACL,MAAM,EACN,WAAW,EACX,UAAU,EACV,SAAS,EACT,KAAK,GACN,EAAE,gBAAgB,GAAG,YAAY,CAwDjC"}
@@ -0,0 +1,77 @@
1
+ "use client";
2
+ import { createElement, useEffect, useRef } from "react";
3
+ import { animateText, } from "@vysmo/text";
4
+ /**
5
+ * React wrapper around `@vysmo/text`'s `animateText`. Renders a single
6
+ * element (default `<span>`, override via `as`), and on mount calls
7
+ * `animateText` against the element with the props you've passed.
8
+ *
9
+ * On unmount the handle is `.stop()`-ed, restoring un-animated styles
10
+ * before React removes the DOM. Re-animation happens on prop changes
11
+ * (preset / stagger / repeat / etc.); for explicit replay on the same
12
+ * props, change a `key` prop to fully remount.
13
+ */
14
+ export function AnimateText({ as = "span", children, preset, split, stagger, staggerOrder, animations, perspective, perspectiveOrigin, transformOrigin, autoPlay, delay, repeat, repeatDelay, onComplete, className, style, }) {
15
+ const ref = useRef(null);
16
+ const handleRef = useRef(null);
17
+ // Stash the callback so its identity changing doesn't re-run the animation.
18
+ const onCompleteRef = useRef(onComplete);
19
+ onCompleteRef.current = onComplete;
20
+ useEffect(() => {
21
+ const el = ref.current;
22
+ if (!el)
23
+ return;
24
+ const opts = {};
25
+ if (preset !== undefined)
26
+ opts.preset = preset;
27
+ if (split !== undefined)
28
+ opts.split = split;
29
+ if (stagger !== undefined)
30
+ opts.stagger = stagger;
31
+ if (staggerOrder !== undefined)
32
+ opts.staggerOrder = staggerOrder;
33
+ if (animations !== undefined)
34
+ opts.animations = animations;
35
+ if (perspective !== undefined)
36
+ opts.perspective = perspective;
37
+ if (perspectiveOrigin !== undefined)
38
+ opts.perspectiveOrigin = perspectiveOrigin;
39
+ if (transformOrigin !== undefined)
40
+ opts.transformOrigin = transformOrigin;
41
+ if (autoPlay !== undefined)
42
+ opts.autoPlay = autoPlay;
43
+ if (delay !== undefined)
44
+ opts.delay = delay;
45
+ if (repeat !== undefined)
46
+ opts.repeat = repeat;
47
+ if (repeatDelay !== undefined)
48
+ opts.repeatDelay = repeatDelay;
49
+ const handle = animateText(el, opts);
50
+ handleRef.current = handle;
51
+ handle.finished
52
+ .then(() => onCompleteRef.current?.())
53
+ .catch(() => {
54
+ /* stopped — expected */
55
+ });
56
+ return () => {
57
+ handle.stop();
58
+ handleRef.current = null;
59
+ };
60
+ }, [
61
+ children,
62
+ preset,
63
+ split,
64
+ stagger,
65
+ staggerOrder,
66
+ animations,
67
+ perspective,
68
+ perspectiveOrigin,
69
+ transformOrigin,
70
+ autoPlay,
71
+ delay,
72
+ repeat,
73
+ repeatDelay,
74
+ ]);
75
+ return createElement(as, { ref, className, style }, children);
76
+ }
77
+ //# sourceMappingURL=AnimateText.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"AnimateText.js","sourceRoot":"","sources":["../src/AnimateText.tsx"],"names":[],"mappings":"AAAA,YAAY,CAAC;AAEb,OAAO,EAAE,aAAa,EAAE,SAAS,EAAE,MAAM,EAAE,MAAM,OAAO,CAAC;AAEzD,OAAO,EACL,WAAW,GASZ,MAAM,aAAa,CAAC;AAuCrB;;;;;;;;;GASG;AACH,MAAM,UAAU,WAAW,CAAC,EAC1B,EAAE,GAAG,MAAM,EACX,QAAQ,EACR,MAAM,EACN,KAAK,EACL,OAAO,EACP,YAAY,EACZ,UAAU,EACV,WAAW,EACX,iBAAiB,EACjB,eAAe,EACf,QAAQ,EACR,KAAK,EACL,MAAM,EACN,WAAW,EACX,UAAU,EACV,SAAS,EACT,KAAK,GACY;IACjB,MAAM,GAAG,GAAG,MAAM,CAAqB,IAAI,CAAC,CAAC;IAC7C,MAAM,SAAS,GAAG,MAAM,CAA2B,IAAI,CAAC,CAAC;IAEzD,4EAA4E;IAC5E,MAAM,aAAa,GAAG,MAAM,CAAC,UAAU,CAAC,CAAC;IACzC,aAAa,CAAC,OAAO,GAAG,UAAU,CAAC;IAEnC,SAAS,CAAC,GAAG,EAAE;QACb,MAAM,EAAE,GAAG,GAAG,CAAC,OAAO,CAAC;QACvB,IAAI,CAAC,EAAE;YAAE,OAAO;QAEhB,MAAM,IAAI,GAAuB,EAAE,CAAC;QACpC,IAAI,MAAM,KAAK,SAAS;YAAE,IAAI,CAAC,MAAM,GAAG,MAAM,CAAC;QAC/C,IAAI,KAAK,KAAK,SAAS;YAAE,IAAI,CAAC,KAAK,GAAG,KAAK,CAAC;QAC5C,IAAI,OAAO,KAAK,SAAS;YAAE,IAAI,CAAC,OAAO,GAAG,OAAO,CAAC;QAClD,IAAI,YAAY,KAAK,SAAS;YAAE,IAAI,CAAC,YAAY,GAAG,YAAY,CAAC;QACjE,IAAI,UAAU,KAAK,SAAS;YAAE,IAAI,CAAC,UAAU,GAAG,UAAU,CAAC;QAC3D,IAAI,WAAW,KAAK,SAAS;YAAE,IAAI,CAAC,WAAW,GAAG,WAAW,CAAC;QAC9D,IAAI,iBAAiB,KAAK,SAAS;YAAE,IAAI,CAAC,iBAAiB,GAAG,iBAAiB,CAAC;QAChF,IAAI,eAAe,KAAK,SAAS;YAAE,IAAI,CAAC,eAAe,GAAG,eAAe,CAAC;QAC1E,IAAI,QAAQ,KAAK,SAAS;YAAE,IAAI,CAAC,QAAQ,GAAG,QAAQ,CAAC;QACrD,IAAI,KAAK,KAAK,SAAS;YAAE,IAAI,CAAC,KAAK,GAAG,KAAK,CAAC;QAC5C,IAAI,MAAM,KAAK,SAAS;YAAE,IAAI,CAAC,MAAM,GAAG,MAAM,CAAC;QAC/C,IAAI,WAAW,KAAK,SAAS;YAAE,IAAI,CAAC,WAAW,GAAG,WAAW,CAAC;QAE9D,MAAM,MAAM,GAAG,WAAW,CAAC,EAAE,EAAE,IAAI,CAAC,CAAC;QACrC,SAAS,CAAC,OAAO,GAAG,MAAM,CAAC;QAE3B,MAAM,CAAC,QAAQ;aACZ,IAAI,CAAC,GAAG,EAAE,CAAC,aAAa,CAAC,OAAO,EAAE,EAAE,CAAC;aACrC,KAAK,CAAC,GAAG,EAAE;YACV,wBAAwB;QAC1B,CAAC,CAAC,CAAC;QAEL,OAAO,GAAG,EAAE;YACV,MAAM,CAAC,IAAI,EAAE,CAAC;YACd,SAAS,CAAC,OAAO,GAAG,IAAI,CAAC;QAC3B,CAAC,CAAC;IACJ,CAAC,EAAE;QACD,QAAQ;QACR,MAAM;QACN,KAAK;QACL,OAAO;QACP,YAAY;QACZ,UAAU;QACV,WAAW;QACX,iBAAiB;QACjB,eAAe;QACf,QAAQ;QACR,KAAK;QACL,MAAM;QACN,WAAW;KACZ,CAAC,CAAC;IAEH,OAAO,aAAa,CAAC,EAAE,EAAE,EAAE,GAAG,EAAE,SAAS,EAAE,KAAK,EAAE,EAAE,QAAQ,CAAC,CAAC;AAChE,CAAC"}
@@ -0,0 +1,3 @@
1
+ export { AnimateText, type AnimateTextProps } from "./AnimateText.js";
2
+ export { useAnimateText } from "./use-animate-text.js";
3
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,KAAK,gBAAgB,EAAE,MAAM,kBAAkB,CAAC;AACtE,OAAO,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,3 @@
1
+ export { AnimateText } from "./AnimateText.js";
2
+ export { useAnimateText } from "./use-animate-text.js";
3
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAyB,MAAM,kBAAkB,CAAC;AACtE,OAAO,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAC"}
@@ -0,0 +1,14 @@
1
+ import type { RefObject } from "react";
2
+ import { type AnimateTextOptions } from "@vysmo/text";
3
+ /**
4
+ * Run `animateText` against an element you mount yourself. Handy when
5
+ * you can't use the `<AnimateText>` wrapper because the element is
6
+ * structurally part of something else (a Markdown-rendered heading, a
7
+ * MDX block, a third-party component) — `useAnimateText(ref, options)`
8
+ * lets you animate it without owning the JSX.
9
+ *
10
+ * The handle is created on mount, re-created when any option changes,
11
+ * and `.stop()`-ed on unmount.
12
+ */
13
+ export declare function useAnimateText(ref: RefObject<HTMLElement | null>, options: AnimateTextOptions): void;
14
+ //# sourceMappingURL=use-animate-text.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"use-animate-text.d.ts","sourceRoot":"","sources":["../src/use-animate-text.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AACvC,OAAO,EAGL,KAAK,kBAAkB,EACxB,MAAM,aAAa,CAAC;AAErB;;;;;;;;;GASG;AACH,wBAAgB,cAAc,CAC5B,GAAG,EAAE,SAAS,CAAC,WAAW,GAAG,IAAI,CAAC,EAClC,OAAO,EAAE,kBAAkB,GAC1B,IAAI,CAiBN"}
@@ -0,0 +1,32 @@
1
+ "use client";
2
+ import { useEffect, useRef } from "react";
3
+ import { animateText, } from "@vysmo/text";
4
+ /**
5
+ * Run `animateText` against an element you mount yourself. Handy when
6
+ * you can't use the `<AnimateText>` wrapper because the element is
7
+ * structurally part of something else (a Markdown-rendered heading, a
8
+ * MDX block, a third-party component) — `useAnimateText(ref, options)`
9
+ * lets you animate it without owning the JSX.
10
+ *
11
+ * The handle is created on mount, re-created when any option changes,
12
+ * and `.stop()`-ed on unmount.
13
+ */
14
+ export function useAnimateText(ref, options) {
15
+ const handleRef = useRef(null);
16
+ useEffect(() => {
17
+ const el = ref.current;
18
+ if (!el)
19
+ return;
20
+ const handle = animateText(el, options);
21
+ handleRef.current = handle;
22
+ return () => {
23
+ handle.stop();
24
+ handleRef.current = null;
25
+ };
26
+ // We deliberately depend on the whole options object — callers
27
+ // memoize when they want stability. Same contract as the rest of
28
+ // React's "options object" hooks (e.g. `useQuery`).
29
+ // eslint-disable-next-line react-hooks/exhaustive-deps
30
+ }, [ref, options]);
31
+ }
32
+ //# sourceMappingURL=use-animate-text.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"use-animate-text.js","sourceRoot":"","sources":["../src/use-animate-text.ts"],"names":[],"mappings":"AAAA,YAAY,CAAC;AAEb,OAAO,EAAE,SAAS,EAAE,MAAM,EAAE,MAAM,OAAO,CAAC;AAE1C,OAAO,EACL,WAAW,GAGZ,MAAM,aAAa,CAAC;AAErB;;;;;;;;;GASG;AACH,MAAM,UAAU,cAAc,CAC5B,GAAkC,EAClC,OAA2B;IAE3B,MAAM,SAAS,GAAG,MAAM,CAA2B,IAAI,CAAC,CAAC;IAEzD,SAAS,CAAC,GAAG,EAAE;QACb,MAAM,EAAE,GAAG,GAAG,CAAC,OAAO,CAAC;QACvB,IAAI,CAAC,EAAE;YAAE,OAAO;QAChB,MAAM,MAAM,GAAG,WAAW,CAAC,EAAE,EAAE,OAAO,CAAC,CAAC;QACxC,SAAS,CAAC,OAAO,GAAG,MAAM,CAAC;QAC3B,OAAO,GAAG,EAAE;YACV,MAAM,CAAC,IAAI,EAAE,CAAC;YACd,SAAS,CAAC,OAAO,GAAG,IAAI,CAAC;QAC3B,CAAC,CAAC;QACF,+DAA+D;QAC/D,iEAAiE;QACjE,oDAAoD;QACpD,uDAAuD;IACzD,CAAC,EAAE,CAAC,GAAG,EAAE,OAAO,CAAC,CAAC,CAAC;AACrB,CAAC"}
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "@vysmo/text-react",
3
+ "version": "0.1.0",
4
+ "description": "React bindings for @vysmo/text — an <AnimateText> component that drives splitText + animateText with declarative props (preset, split, stagger, repeat, etc.) and a hook for advanced cases.",
5
+ "license": "MIT",
6
+ "keywords": [
7
+ "react",
8
+ "text",
9
+ "animation",
10
+ "split-text",
11
+ "vysmo"
12
+ ],
13
+ "type": "module",
14
+ "sideEffects": false,
15
+ "main": "./dist/index.js",
16
+ "module": "./src/index.ts",
17
+ "types": "./dist/index.d.ts",
18
+ "exports": {
19
+ ".": {
20
+ "types": "./dist/index.d.ts",
21
+ "import": "./dist/index.js"
22
+ }
23
+ },
24
+ "files": [
25
+ "dist",
26
+ "src",
27
+ "README.md",
28
+ "LICENSE"
29
+ ],
30
+ "peerDependencies": {
31
+ "react": ">=18",
32
+ "@vysmo/text": "0.1.0"
33
+ },
34
+ "devDependencies": {
35
+ "@types/react": "^19.2.14",
36
+ "@types/react-dom": "^19.2.3",
37
+ "@vitest/browser": "^3.2.4",
38
+ "playwright": "^1.59.1",
39
+ "react": "^19.2.5",
40
+ "react-dom": "^19.2.5",
41
+ "typescript": "^5.6.3",
42
+ "vitest": "^3.2.4",
43
+ "@vysmo/text": "0.1.0"
44
+ },
45
+ "scripts": {
46
+ "build": "tsc -p tsconfig.json",
47
+ "typecheck": "tsc -p tsconfig.typecheck.json",
48
+ "test": "vitest run && vitest run --config vitest.ssr.config.ts",
49
+ "test:browser": "vitest run",
50
+ "test:ssr": "vitest run --config vitest.ssr.config.ts",
51
+ "test:watch": "vitest"
52
+ }
53
+ }
@@ -0,0 +1,138 @@
1
+ "use client";
2
+
3
+ import { createElement, useEffect, useRef } from "react";
4
+ import type { CSSProperties, ElementType, ReactElement, ReactNode } from "react";
5
+ import {
6
+ animateText,
7
+ type AnimateTextHandle,
8
+ type AnimateTextOptions,
9
+ type Preset,
10
+ type PresetName,
11
+ type SplitMode,
12
+ type StaggerOrder,
13
+ type TextAnimationSpec,
14
+ type TransformOrigin,
15
+ } from "@vysmo/text";
16
+
17
+ export interface AnimateTextProps {
18
+ /** Element to render. Default `"span"`. Pass `"h1"` etc. for semantic headings. */
19
+ as?: ElementType;
20
+ /** The text content. Strings work best; complex children re-run the animation when reference changes. */
21
+ children: ReactNode;
22
+ /** Preset name (e.g. `"enter/fade-up"`) or imported preset object (tree-shakable). */
23
+ preset?: PresetName | Preset;
24
+ /** Split granularity. Defaults to the preset's split, or `"character"`. */
25
+ split?: SplitMode;
26
+ /** Milliseconds between consecutive slices starting. Default `30`. */
27
+ stagger?: number;
28
+ /** Order in which slices receive their stagger offset. */
29
+ staggerOrder?: StaggerOrder;
30
+ /** Custom animation specs (used when no `preset` is set, or to override). */
31
+ animations?: TextAnimationSpec[];
32
+ /** Container `perspective` in px. Required for visible 3D transforms. */
33
+ perspective?: number;
34
+ /** Container `perspective-origin`. */
35
+ perspectiveOrigin?: string;
36
+ /** Transform origin applied to every slice. */
37
+ transformOrigin?: TransformOrigin;
38
+ /** Begin playing automatically on mount. Default `true`. */
39
+ autoPlay?: boolean;
40
+ /** Delay before the first play begins, in ms. */
41
+ delay?: number;
42
+ /** How many cycles. `1` (default), `n > 1`, or `"infinite"`. */
43
+ repeat?: number | "infinite";
44
+ /** Delay between successive cycles when `repeat > 1`. */
45
+ repeatDelay?: number;
46
+ /** Fires when the choreography finishes naturally (won't fire while looping). */
47
+ onComplete?: () => void;
48
+ /** Forwarded to the wrapper element. */
49
+ className?: string;
50
+ /** Forwarded to the wrapper element. */
51
+ style?: CSSProperties;
52
+ }
53
+
54
+ /**
55
+ * React wrapper around `@vysmo/text`'s `animateText`. Renders a single
56
+ * element (default `<span>`, override via `as`), and on mount calls
57
+ * `animateText` against the element with the props you've passed.
58
+ *
59
+ * On unmount the handle is `.stop()`-ed, restoring un-animated styles
60
+ * before React removes the DOM. Re-animation happens on prop changes
61
+ * (preset / stagger / repeat / etc.); for explicit replay on the same
62
+ * props, change a `key` prop to fully remount.
63
+ */
64
+ export function AnimateText({
65
+ as = "span",
66
+ children,
67
+ preset,
68
+ split,
69
+ stagger,
70
+ staggerOrder,
71
+ animations,
72
+ perspective,
73
+ perspectiveOrigin,
74
+ transformOrigin,
75
+ autoPlay,
76
+ delay,
77
+ repeat,
78
+ repeatDelay,
79
+ onComplete,
80
+ className,
81
+ style,
82
+ }: AnimateTextProps): ReactElement {
83
+ const ref = useRef<HTMLElement | null>(null);
84
+ const handleRef = useRef<AnimateTextHandle | null>(null);
85
+
86
+ // Stash the callback so its identity changing doesn't re-run the animation.
87
+ const onCompleteRef = useRef(onComplete);
88
+ onCompleteRef.current = onComplete;
89
+
90
+ useEffect(() => {
91
+ const el = ref.current;
92
+ if (!el) return;
93
+
94
+ const opts: AnimateTextOptions = {};
95
+ if (preset !== undefined) opts.preset = preset;
96
+ if (split !== undefined) opts.split = split;
97
+ if (stagger !== undefined) opts.stagger = stagger;
98
+ if (staggerOrder !== undefined) opts.staggerOrder = staggerOrder;
99
+ if (animations !== undefined) opts.animations = animations;
100
+ if (perspective !== undefined) opts.perspective = perspective;
101
+ if (perspectiveOrigin !== undefined) opts.perspectiveOrigin = perspectiveOrigin;
102
+ if (transformOrigin !== undefined) opts.transformOrigin = transformOrigin;
103
+ if (autoPlay !== undefined) opts.autoPlay = autoPlay;
104
+ if (delay !== undefined) opts.delay = delay;
105
+ if (repeat !== undefined) opts.repeat = repeat;
106
+ if (repeatDelay !== undefined) opts.repeatDelay = repeatDelay;
107
+
108
+ const handle = animateText(el, opts);
109
+ handleRef.current = handle;
110
+
111
+ handle.finished
112
+ .then(() => onCompleteRef.current?.())
113
+ .catch(() => {
114
+ /* stopped — expected */
115
+ });
116
+
117
+ return () => {
118
+ handle.stop();
119
+ handleRef.current = null;
120
+ };
121
+ }, [
122
+ children,
123
+ preset,
124
+ split,
125
+ stagger,
126
+ staggerOrder,
127
+ animations,
128
+ perspective,
129
+ perspectiveOrigin,
130
+ transformOrigin,
131
+ autoPlay,
132
+ delay,
133
+ repeat,
134
+ repeatDelay,
135
+ ]);
136
+
137
+ return createElement(as, { ref, className, style }, children);
138
+ }
@@ -0,0 +1,113 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
2
+ import { createRoot, type Root } from "react-dom/client";
3
+ import { act } from "react";
4
+ import { AnimateText } from "../AnimateText.js";
5
+
6
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
7
+
8
+ describe("<AnimateText>", () => {
9
+ let container: HTMLDivElement;
10
+ let root: Root;
11
+
12
+ beforeEach(() => {
13
+ container = document.createElement("div");
14
+ document.body.appendChild(container);
15
+ root = createRoot(container);
16
+ });
17
+
18
+ afterEach(async () => {
19
+ await act(async () => {
20
+ root.unmount();
21
+ });
22
+ container.remove();
23
+ });
24
+
25
+ it("renders children inside a span by default", async () => {
26
+ await act(async () => {
27
+ root.render(<AnimateText preset="enter/fade-up">Hello world</AnimateText>);
28
+ });
29
+ const span = container.querySelector("span");
30
+ expect(span).toBeTruthy();
31
+ // animateText splits the text into per-slice spans + appends a
32
+ // hidden screen-reader copy. Both contain the full text, so
33
+ // textContent doubles — verify via the SR copy attribute instead.
34
+ const sr = span!.querySelector("[data-text-sr]");
35
+ expect(sr?.textContent).toBe("Hello world");
36
+ });
37
+
38
+ it("renders as a different tag when `as` is set", async () => {
39
+ await act(async () => {
40
+ root.render(
41
+ <AnimateText as="h1" preset="enter/fade-up">
42
+ Headline
43
+ </AnimateText>,
44
+ );
45
+ });
46
+ const h1 = container.querySelector("h1");
47
+ expect(h1).toBeTruthy();
48
+ const sr = h1!.querySelector("[data-text-sr]");
49
+ expect(sr?.textContent).toBe("Headline");
50
+ });
51
+
52
+ it("forwards className and style", async () => {
53
+ await act(async () => {
54
+ root.render(
55
+ <AnimateText
56
+ preset="enter/fade-up"
57
+ className="test-class"
58
+ style={{ color: "red" }}
59
+ >
60
+ Styled
61
+ </AnimateText>,
62
+ );
63
+ });
64
+ const span = container.querySelector("span")!;
65
+ expect(span.className).toBe("test-class");
66
+ expect(span.style.color).toBe("red");
67
+ });
68
+
69
+ it("splits the text into slices via animateText", async () => {
70
+ await act(async () => {
71
+ root.render(
72
+ <AnimateText preset="enter/fade-up" split="character">
73
+ Hi
74
+ </AnimateText>,
75
+ );
76
+ });
77
+ // animateText splits the text into per-character spans wrapped in
78
+ // word containers — both root and inner spans use data attributes.
79
+ const span = container.querySelector("span")!;
80
+ const slices = span.querySelectorAll("[data-text-slice]");
81
+ expect(slices.length).toBeGreaterThan(0);
82
+ });
83
+
84
+ it("calls onComplete after the animation finishes", async () => {
85
+ let completed = false;
86
+ await act(async () => {
87
+ root.render(
88
+ <AnimateText
89
+ preset="enter/fade-up"
90
+ onComplete={() => {
91
+ completed = true;
92
+ }}
93
+ >
94
+ x
95
+ </AnimateText>,
96
+ );
97
+ });
98
+ // fade-up is sub-second; give it 1.5s to complete.
99
+ await new Promise((r) => setTimeout(r, 1500));
100
+ expect(completed).toBe(true);
101
+ });
102
+
103
+ it("stops the handle on unmount without throwing", async () => {
104
+ await act(async () => {
105
+ root.render(<AnimateText preset="enter/fade-up">Goodbye</AnimateText>);
106
+ });
107
+ await act(async () => {
108
+ root.unmount();
109
+ });
110
+ root = createRoot(container);
111
+ expect(container.querySelector("span")).toBeNull();
112
+ });
113
+ });
@@ -0,0 +1,14 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ describe("SSR safety", () => {
4
+ it("window is undefined in this runtime", () => {
5
+ expect(typeof window).toBe("undefined");
6
+ });
7
+
8
+ it("module loads without DOM globals", async () => {
9
+ const mod = await import("../index.js");
10
+ expect(mod).toBeDefined();
11
+ expect(typeof mod.AnimateText).toBe("function");
12
+ expect(typeof mod.useAnimateText).toBe("function");
13
+ });
14
+ });
package/src/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ export { AnimateText, type AnimateTextProps } from "./AnimateText.js";
2
+ export { useAnimateText } from "./use-animate-text.js";
@@ -0,0 +1,41 @@
1
+ "use client";
2
+
3
+ import { useEffect, useRef } from "react";
4
+ import type { RefObject } from "react";
5
+ import {
6
+ animateText,
7
+ type AnimateTextHandle,
8
+ type AnimateTextOptions,
9
+ } from "@vysmo/text";
10
+
11
+ /**
12
+ * Run `animateText` against an element you mount yourself. Handy when
13
+ * you can't use the `<AnimateText>` wrapper because the element is
14
+ * structurally part of something else (a Markdown-rendered heading, a
15
+ * MDX block, a third-party component) — `useAnimateText(ref, options)`
16
+ * lets you animate it without owning the JSX.
17
+ *
18
+ * The handle is created on mount, re-created when any option changes,
19
+ * and `.stop()`-ed on unmount.
20
+ */
21
+ export function useAnimateText(
22
+ ref: RefObject<HTMLElement | null>,
23
+ options: AnimateTextOptions,
24
+ ): void {
25
+ const handleRef = useRef<AnimateTextHandle | null>(null);
26
+
27
+ useEffect(() => {
28
+ const el = ref.current;
29
+ if (!el) return;
30
+ const handle = animateText(el, options);
31
+ handleRef.current = handle;
32
+ return () => {
33
+ handle.stop();
34
+ handleRef.current = null;
35
+ };
36
+ // We deliberately depend on the whole options object — callers
37
+ // memoize when they want stability. Same contract as the rest of
38
+ // React's "options object" hooks (e.g. `useQuery`).
39
+ // eslint-disable-next-line react-hooks/exhaustive-deps
40
+ }, [ref, options]);
41
+ }