@vysmo/transitions-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,109 @@
1
+ # @vysmo/transitions-react
2
+
3
+ React bindings for [`@vysmo/transitions`](https://www.npmjs.com/package/@vysmo/transitions). One `<Transition>` component, one hook (`useTransitionRunner`), zero opinion on how you drive progress — controlled or self-driving, your call.
4
+
5
+ [Live demos + parameter playground](https://vysmo.com/transitions) · [Source](https://github.com/vysmodev)
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ pnpm add @vysmo/transitions @vysmo/transitions-react
11
+ ```
12
+
13
+ `react` ≥ 18 is a peer dependency.
14
+
15
+ ## Quick start
16
+
17
+ ```tsx
18
+ import { Transition } from "@vysmo/transitions-react";
19
+ import { paintBleed } from "@vysmo/transitions";
20
+
21
+ export function Hero() {
22
+ return (
23
+ <Transition
24
+ transition={paintBleed}
25
+ from="/images/a.jpg"
26
+ to="/images/b.jpg"
27
+ duration={1200}
28
+ style={{ width: "100%", aspectRatio: "16 / 9" }}
29
+ />
30
+ );
31
+ }
32
+ ```
33
+
34
+ That's the whole minimum. The component mounts a `<canvas>`, creates a `Runner`, resolves the URLs into decoded `Image`s, and runs an autoplay from `progress=0` to `progress=1` over `duration` ms. On unmount the runner is disposed.
35
+
36
+ ## Controlled progress
37
+
38
+ Pass a `progress` prop — autoplay is bypassed and the component renders exactly at the value you supply. Drive it from anything: scroll progress, a scrubber, a tween library, your own state.
39
+
40
+ ```tsx
41
+ // @no-check
42
+ import { Transition } from "@vysmo/transitions-react";
43
+ import { paintBleed } from "@vysmo/transitions";
44
+ import { useScrollProgress } from "./somewhere";
45
+
46
+ export function ScrollHero() {
47
+ const progress = useScrollProgress(); // 0 → 1 as the section passes
48
+ return (
49
+ <Transition
50
+ transition={paintBleed}
51
+ from="/a.jpg"
52
+ to="/b.jpg"
53
+ progress={progress}
54
+ />
55
+ );
56
+ }
57
+ ```
58
+
59
+ ## Props
60
+
61
+ | Prop | Type | Default | Notes |
62
+ |---|---|---|---|
63
+ | `transition` | `Transition` | — | Any transition exported by `@vysmo/transitions`. |
64
+ | `from` | `Source` | — | URL string, `HTMLImageElement`, canvas, video, or `ImageBitmap`. |
65
+ | `to` | `Source` | — | Same shape as `from`. |
66
+ | `progress` | `number` | — | Controlled `[0,1]`. When set, autoplay is bypassed. |
67
+ | `duration` | `number` | `1000` | Autoplay duration ms. Used only when `progress` is omitted. |
68
+ | `playing` | `boolean` | `true` | Whether autoplay is running. Used only when `progress` is omitted. |
69
+ | `loop` | `boolean` | `false` | Loop autoplay. |
70
+ | `ease` | `(t: number) => number` | linear | Easing applied to autoplay. Pair with [`@vysmo/easings`](https://www.npmjs.com/package/@vysmo/easings) for named curves. |
71
+ | `params` | `UniformParams` | — | Override shader uniform defaults (e.g. `{ blur: 0.05 }`). |
72
+ | `onComplete` | `() => void` | — | Fires when a non-loop autoplay reaches `progress=1`. |
73
+ | `className` | `string` | — | Forwarded to the `<canvas>`. |
74
+ | `style` | `CSSProperties` | — | Forwarded to the `<canvas>`. |
75
+
76
+ ## Sizing
77
+
78
+ The component renders a `<canvas>` and syncs its internal pixel dimensions to its CSS box via `ResizeObserver`, with `devicePixelRatio` applied. Style the canvas via `className` / `style` and the runner adapts.
79
+
80
+ ## Hook (advanced)
81
+
82
+ If you need direct `Runner` access — e.g. to compose multiple renders per frame, or to drive a transition off a non-React render loop — use `useTransitionRunner`:
83
+
84
+ ```tsx
85
+ import { useRef } from "react";
86
+ import { useTransitionRunner } from "@vysmo/transitions-react";
87
+ import { paintBleed } from "@vysmo/transitions";
88
+
89
+ function CustomDriver({ from, to, progress }) {
90
+ const canvasRef = useRef<HTMLCanvasElement>(null);
91
+ const runner = useTransitionRunner(canvasRef);
92
+
93
+ if (runner) {
94
+ runner.render(paintBleed, { from, to, progress });
95
+ }
96
+
97
+ return <canvas ref={canvasRef} />;
98
+ }
99
+ ```
100
+
101
+ The component is the right call for ~95% of cases; reach for the hook when the props don't expose what you need.
102
+
103
+ ## SSR
104
+
105
+ 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 an empty `<canvas>`; client mounts the runner.
106
+
107
+ ## License
108
+
109
+ MIT.
@@ -0,0 +1,47 @@
1
+ import type { CSSProperties, ReactElement } from "react";
2
+ import { type Transition as TransitionType, type UniformParams } from "@vysmo/transitions";
3
+ import { type Source } from "./resolve-source.js";
4
+ export interface TransitionProps {
5
+ /** Any transition exported by `@vysmo/transitions`. */
6
+ transition: TransitionType<UniformParams>;
7
+ /** First image. URL string, `HTMLImageElement`, canvas, video, or `ImageBitmap`. */
8
+ from: Source;
9
+ /** Second image. Same accepted shapes as `from`. */
10
+ to: Source;
11
+ /**
12
+ * Controlled progress in `[0, 1]`. When set, the component renders at
13
+ * exactly this progress and the autoplay loop is bypassed — drive it
14
+ * yourself (scroll progress, scrubber, animation library, etc.).
15
+ */
16
+ progress?: number;
17
+ /** Autoplay duration in ms. Used only when `progress` is omitted. Default `1000`. */
18
+ duration?: number;
19
+ /** Whether internal autoplay is running. Used only when `progress` is omitted. Default `true`. */
20
+ playing?: boolean;
21
+ /** Loop autoplay. Default `false`. */
22
+ loop?: boolean;
23
+ /** Easing function applied during autoplay. Default linear. */
24
+ ease?: (t: number) => number;
25
+ /** Override shader uniform defaults. */
26
+ params?: UniformParams;
27
+ /** Fires when a non-loop autoplay reaches `progress=1`. */
28
+ onComplete?: () => void;
29
+ /** Forwarded to the canvas element. */
30
+ className?: string;
31
+ /** Forwarded to the canvas element. */
32
+ style?: CSSProperties;
33
+ }
34
+ /**
35
+ * React wrapper around `@vysmo/transitions`'s `Runner`. Renders a
36
+ * `<canvas>` and drives a transition between two images — either
37
+ * controlled by a `progress` prop or self-driving via `duration` /
38
+ * `playing` / `loop` / `ease` for the common "play once on mount"
39
+ * case.
40
+ *
41
+ * The component creates one runner on mount and disposes it on
42
+ * unmount; sources are resolved (URL strings → decoded `Image`s) on
43
+ * `from` / `to` change. Canvas size syncs to its CSS box via
44
+ * `ResizeObserver`, with DPR applied so output stays sharp on retina.
45
+ */
46
+ export declare function Transition({ transition, from, to, progress, duration, playing, loop, ease, params, onComplete, className, style, }: TransitionProps): ReactElement;
47
+ //# sourceMappingURL=Transition.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"Transition.d.ts","sourceRoot":"","sources":["../src/Transition.tsx"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,aAAa,EAAE,YAAY,EAAE,MAAM,OAAO,CAAC;AACzD,OAAO,EAEL,KAAK,UAAU,IAAI,cAAc,EACjC,KAAK,aAAa,EAEnB,MAAM,oBAAoB,CAAC;AAC5B,OAAO,EAAiB,KAAK,MAAM,EAAE,MAAM,qBAAqB,CAAC;AAEjE,MAAM,WAAW,eAAe;IAC9B,uDAAuD;IACvD,UAAU,EAAE,cAAc,CAAC,aAAa,CAAC,CAAC;IAC1C,oFAAoF;IACpF,IAAI,EAAE,MAAM,CAAC;IACb,oDAAoD;IACpD,EAAE,EAAE,MAAM,CAAC;IACX;;;;OAIG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,qFAAqF;IACrF,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,kGAAkG;IAClG,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,sCAAsC;IACtC,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,+DAA+D;IAC/D,IAAI,CAAC,EAAE,CAAC,CAAC,EAAE,MAAM,KAAK,MAAM,CAAC;IAC7B,wCAAwC;IACxC,MAAM,CAAC,EAAE,aAAa,CAAC;IACvB,2DAA2D;IAC3D,UAAU,CAAC,EAAE,MAAM,IAAI,CAAC;IACxB,uCAAuC;IACvC,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,uCAAuC;IACvC,KAAK,CAAC,EAAE,aAAa,CAAC;CACvB;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,UAAU,CAAC,EACzB,UAAU,EACV,IAAI,EACJ,EAAE,EACF,QAAQ,EACR,QAAe,EACf,OAAc,EACd,IAAY,EACZ,IAAI,EACJ,MAAM,EACN,UAAU,EACV,SAAS,EACT,KAAK,GACN,EAAE,eAAe,GAAG,YAAY,CAkGhC"}
@@ -0,0 +1,113 @@
1
+ "use client";
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { useEffect, useRef, useState } from "react";
4
+ import { Runner, } from "@vysmo/transitions";
5
+ import { resolveSource } from "./resolve-source.js";
6
+ /**
7
+ * React wrapper around `@vysmo/transitions`'s `Runner`. Renders a
8
+ * `<canvas>` and drives a transition between two images — either
9
+ * controlled by a `progress` prop or self-driving via `duration` /
10
+ * `playing` / `loop` / `ease` for the common "play once on mount"
11
+ * case.
12
+ *
13
+ * The component creates one runner on mount and disposes it on
14
+ * unmount; sources are resolved (URL strings → decoded `Image`s) on
15
+ * `from` / `to` change. Canvas size syncs to its CSS box via
16
+ * `ResizeObserver`, with DPR applied so output stays sharp on retina.
17
+ */
18
+ export function Transition({ transition, from, to, progress, duration = 1000, playing = true, loop = false, ease, params, onComplete, className, style, }) {
19
+ const canvasRef = useRef(null);
20
+ const runnerRef = useRef(null);
21
+ const [sources, setSources] = useState(null);
22
+ // Stash callable / object props in refs so the autoplay effect's
23
+ // dependency list can stay narrow — callers passing inline literals
24
+ // for `params` / `onComplete` / `ease` should not restart autoplay.
25
+ const paramsRef = useRef(params);
26
+ paramsRef.current = params;
27
+ const onCompleteRef = useRef(onComplete);
28
+ onCompleteRef.current = onComplete;
29
+ const easeRef = useRef(ease);
30
+ easeRef.current = ease;
31
+ // Mount: create runner + resize observer; dispose on unmount.
32
+ useEffect(() => {
33
+ const canvas = canvasRef.current;
34
+ if (!canvas)
35
+ return;
36
+ const syncSize = () => {
37
+ const rect = canvas.getBoundingClientRect();
38
+ const dpr = window.devicePixelRatio || 1;
39
+ const w = Math.max(1, Math.round(rect.width * dpr));
40
+ const h = Math.max(1, Math.round(rect.height * dpr));
41
+ if (canvas.width !== w)
42
+ canvas.width = w;
43
+ if (canvas.height !== h)
44
+ canvas.height = h;
45
+ };
46
+ syncSize();
47
+ const runner = new Runner({ canvas });
48
+ runnerRef.current = runner;
49
+ const ro = typeof ResizeObserver !== "undefined" ? new ResizeObserver(syncSize) : null;
50
+ ro?.observe(canvas);
51
+ return () => {
52
+ ro?.disconnect();
53
+ runner.dispose();
54
+ runnerRef.current = null;
55
+ };
56
+ }, []);
57
+ // Resolve sources whenever `from` / `to` change.
58
+ useEffect(() => {
59
+ let cancelled = false;
60
+ Promise.all([resolveSource(from), resolveSource(to)]).then(([f, t]) => {
61
+ if (!cancelled)
62
+ setSources({ from: f, to: t });
63
+ });
64
+ return () => {
65
+ cancelled = true;
66
+ };
67
+ }, [from, to]);
68
+ // Render: controlled (one render per progress change) or autoplay (rAF loop).
69
+ useEffect(() => {
70
+ const runner = runnerRef.current;
71
+ if (!runner || !sources)
72
+ return;
73
+ const renderArgs = paramsRef.current
74
+ ? { from: sources.from, to: sources.to, params: paramsRef.current }
75
+ : { from: sources.from, to: sources.to };
76
+ if (progress !== undefined) {
77
+ runner.render(transition, { ...renderArgs, progress });
78
+ return;
79
+ }
80
+ if (!playing)
81
+ return;
82
+ let cancelled = false;
83
+ let raf = 0;
84
+ let start = 0;
85
+ const tick = (now) => {
86
+ if (cancelled)
87
+ return;
88
+ if (start === 0)
89
+ start = now;
90
+ const t = Math.min(1, (now - start) / duration);
91
+ const easedT = easeRef.current ? easeRef.current(t) : t;
92
+ runner.render(transition, { ...renderArgs, progress: easedT });
93
+ if (t < 1) {
94
+ raf = requestAnimationFrame(tick);
95
+ }
96
+ else if (loop) {
97
+ start = 0;
98
+ raf = requestAnimationFrame(tick);
99
+ }
100
+ else {
101
+ onCompleteRef.current?.();
102
+ }
103
+ };
104
+ raf = requestAnimationFrame(tick);
105
+ return () => {
106
+ cancelled = true;
107
+ if (raf)
108
+ cancelAnimationFrame(raf);
109
+ };
110
+ }, [sources, transition, progress, playing, loop, duration]);
111
+ return _jsx("canvas", { ref: canvasRef, className: className, style: style });
112
+ }
113
+ //# sourceMappingURL=Transition.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"Transition.js","sourceRoot":"","sources":["../src/Transition.tsx"],"names":[],"mappings":"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"}
@@ -0,0 +1,4 @@
1
+ export { Transition, type TransitionProps } from "./Transition.js";
2
+ export { useTransitionRunner } from "./use-transition-runner.js";
3
+ export { resolveSource, type Source, type ResolvedSource } from "./resolve-source.js";
4
+ //# 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,UAAU,EAAE,KAAK,eAAe,EAAE,MAAM,iBAAiB,CAAC;AACnE,OAAO,EAAE,mBAAmB,EAAE,MAAM,4BAA4B,CAAC;AACjE,OAAO,EAAE,aAAa,EAAE,KAAK,MAAM,EAAE,KAAK,cAAc,EAAE,MAAM,qBAAqB,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,4 @@
1
+ export { Transition } from "./Transition.js";
2
+ export { useTransitionRunner } from "./use-transition-runner.js";
3
+ export { resolveSource } from "./resolve-source.js";
4
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAwB,MAAM,iBAAiB,CAAC;AACnE,OAAO,EAAE,mBAAmB,EAAE,MAAM,4BAA4B,CAAC;AACjE,OAAO,EAAE,aAAa,EAAoC,MAAM,qBAAqB,CAAC"}
@@ -0,0 +1,17 @@
1
+ import type { TextureSource } from "@vysmo/transitions";
2
+ /**
3
+ * What the React wrapper accepts as a `from` / `to`. Strings are
4
+ * convenience for `<img src=…>`-style URLs; everything else passes
5
+ * straight through to the runner.
6
+ */
7
+ export type Source = string | HTMLImageElement | HTMLCanvasElement | HTMLVideoElement | ImageBitmap;
8
+ export type ResolvedSource = Exclude<Source, string>;
9
+ /**
10
+ * Resolve a `Source` to a paint-ready `TextureSource`. Strings load via
11
+ * a fresh `Image` with `crossOrigin="anonymous"` and `decode()` so the
12
+ * transition can render on the next frame without showing a blank
13
+ * canvas. Non-string sources await `decode()` if applicable, then
14
+ * pass through.
15
+ */
16
+ export declare function resolveSource(source: Source): Promise<TextureSource>;
17
+ //# sourceMappingURL=resolve-source.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"resolve-source.d.ts","sourceRoot":"","sources":["../src/resolve-source.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAC;AAExD;;;;GAIG;AACH,MAAM,MAAM,MAAM,GACd,MAAM,GACN,gBAAgB,GAChB,iBAAiB,GACjB,gBAAgB,GAChB,WAAW,CAAC;AAEhB,MAAM,MAAM,cAAc,GAAG,OAAO,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;AAErD;;;;;;GAMG;AACH,wBAAsB,aAAa,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,CAAC,CAY1E"}
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Resolve a `Source` to a paint-ready `TextureSource`. Strings load via
3
+ * a fresh `Image` with `crossOrigin="anonymous"` and `decode()` so the
4
+ * transition can render on the next frame without showing a blank
5
+ * canvas. Non-string sources await `decode()` if applicable, then
6
+ * pass through.
7
+ */
8
+ export async function resolveSource(source) {
9
+ if (typeof source === "string") {
10
+ const img = new Image();
11
+ img.crossOrigin = "anonymous";
12
+ img.src = source;
13
+ if ("decode" in img)
14
+ await img.decode();
15
+ return img;
16
+ }
17
+ if (source instanceof HTMLImageElement && !source.complete) {
18
+ await source.decode();
19
+ }
20
+ return source;
21
+ }
22
+ //# sourceMappingURL=resolve-source.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"resolve-source.js","sourceRoot":"","sources":["../src/resolve-source.ts"],"names":[],"mappings":"AAgBA;;;;;;GAMG;AACH,MAAM,CAAC,KAAK,UAAU,aAAa,CAAC,MAAc;IAChD,IAAI,OAAO,MAAM,KAAK,QAAQ,EAAE,CAAC;QAC/B,MAAM,GAAG,GAAG,IAAI,KAAK,EAAE,CAAC;QACxB,GAAG,CAAC,WAAW,GAAG,WAAW,CAAC;QAC9B,GAAG,CAAC,GAAG,GAAG,MAAM,CAAC;QACjB,IAAI,QAAQ,IAAI,GAAG;YAAE,MAAM,GAAG,CAAC,MAAM,EAAE,CAAC;QACxC,OAAO,GAAG,CAAC;IACb,CAAC;IACD,IAAI,MAAM,YAAY,gBAAgB,IAAI,CAAC,MAAM,CAAC,QAAQ,EAAE,CAAC;QAC3D,MAAM,MAAM,CAAC,MAAM,EAAE,CAAC;IACxB,CAAC;IACD,OAAO,MAAM,CAAC;AAChB,CAAC"}
@@ -0,0 +1,16 @@
1
+ import type { RefObject } from "react";
2
+ import { Runner } from "@vysmo/transitions";
3
+ /**
4
+ * Low-level escape hatch for callers who want the `<Transition>`
5
+ * component's lifecycle but their own render loop. Pass a ref to a
6
+ * canvas you mount yourself; the hook constructs a `Runner` once the
7
+ * canvas is in the DOM and disposes it on unmount, returning the
8
+ * runner instance (or `null` while it's still mounting).
9
+ *
10
+ * The component built on this is `<Transition>` — reach for the hook
11
+ * only when you're integrating with a non-React render loop or need
12
+ * direct `runner.render()` access for multi-pass / displacement cases
13
+ * the props don't expose yet.
14
+ */
15
+ export declare function useTransitionRunner(canvasRef: RefObject<HTMLCanvasElement | null>): Runner | null;
16
+ //# sourceMappingURL=use-transition-runner.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"use-transition-runner.d.ts","sourceRoot":"","sources":["../src/use-transition-runner.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AACvC,OAAO,EAAE,MAAM,EAAE,MAAM,oBAAoB,CAAC;AAE5C;;;;;;;;;;;GAWG;AACH,wBAAgB,mBAAmB,CACjC,SAAS,EAAE,SAAS,CAAC,iBAAiB,GAAG,IAAI,CAAC,GAC7C,MAAM,GAAG,IAAI,CAef"}
@@ -0,0 +1,31 @@
1
+ "use client";
2
+ import { useEffect, useState } from "react";
3
+ import { Runner } from "@vysmo/transitions";
4
+ /**
5
+ * Low-level escape hatch for callers who want the `<Transition>`
6
+ * component's lifecycle but their own render loop. Pass a ref to a
7
+ * canvas you mount yourself; the hook constructs a `Runner` once the
8
+ * canvas is in the DOM and disposes it on unmount, returning the
9
+ * runner instance (or `null` while it's still mounting).
10
+ *
11
+ * The component built on this is `<Transition>` — reach for the hook
12
+ * only when you're integrating with a non-React render loop or need
13
+ * direct `runner.render()` access for multi-pass / displacement cases
14
+ * the props don't expose yet.
15
+ */
16
+ export function useTransitionRunner(canvasRef) {
17
+ const [runner, setRunner] = useState(null);
18
+ useEffect(() => {
19
+ const canvas = canvasRef.current;
20
+ if (!canvas)
21
+ return;
22
+ const r = new Runner({ canvas });
23
+ setRunner(r);
24
+ return () => {
25
+ r.dispose();
26
+ setRunner(null);
27
+ };
28
+ }, [canvasRef]);
29
+ return runner;
30
+ }
31
+ //# sourceMappingURL=use-transition-runner.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"use-transition-runner.js","sourceRoot":"","sources":["../src/use-transition-runner.ts"],"names":[],"mappings":"AAAA,YAAY,CAAC;AAEb,OAAO,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAC;AAE5C,OAAO,EAAE,MAAM,EAAE,MAAM,oBAAoB,CAAC;AAE5C;;;;;;;;;;;GAWG;AACH,MAAM,UAAU,mBAAmB,CACjC,SAA8C;IAE9C,MAAM,CAAC,MAAM,EAAE,SAAS,CAAC,GAAG,QAAQ,CAAgB,IAAI,CAAC,CAAC;IAE1D,SAAS,CAAC,GAAG,EAAE;QACb,MAAM,MAAM,GAAG,SAAS,CAAC,OAAO,CAAC;QACjC,IAAI,CAAC,MAAM;YAAE,OAAO;QACpB,MAAM,CAAC,GAAG,IAAI,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC,CAAC;QACjC,SAAS,CAAC,CAAC,CAAC,CAAC;QACb,OAAO,GAAG,EAAE;YACV,CAAC,CAAC,OAAO,EAAE,CAAC;YACZ,SAAS,CAAC,IAAI,CAAC,CAAC;QAClB,CAAC,CAAC;IACJ,CAAC,EAAE,CAAC,SAAS,CAAC,CAAC,CAAC;IAEhB,OAAO,MAAM,CAAC;AAChB,CAAC"}
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "@vysmo/transitions-react",
3
+ "version": "0.1.0",
4
+ "description": "React bindings for @vysmo/transitions — a <Transition> component that renders any of the 60 WebGL transition shaders between two images, with controlled progress or self-driving autoplay.",
5
+ "license": "MIT",
6
+ "keywords": [
7
+ "react",
8
+ "webgl",
9
+ "webgl2",
10
+ "transitions",
11
+ "shader",
12
+ "animation",
13
+ "vysmo"
14
+ ],
15
+ "type": "module",
16
+ "sideEffects": false,
17
+ "main": "./dist/index.js",
18
+ "module": "./src/index.ts",
19
+ "types": "./dist/index.d.ts",
20
+ "exports": {
21
+ ".": {
22
+ "types": "./dist/index.d.ts",
23
+ "import": "./dist/index.js"
24
+ }
25
+ },
26
+ "files": [
27
+ "dist",
28
+ "src",
29
+ "README.md",
30
+ "LICENSE"
31
+ ],
32
+ "peerDependencies": {
33
+ "react": ">=18",
34
+ "@vysmo/transitions": "0.1.0"
35
+ },
36
+ "devDependencies": {
37
+ "@types/react": "^19.2.14",
38
+ "@types/react-dom": "^19.2.3",
39
+ "@vitest/browser": "^3.2.4",
40
+ "playwright": "^1.59.1",
41
+ "react": "^19.2.5",
42
+ "react-dom": "^19.2.5",
43
+ "typescript": "^5.6.3",
44
+ "vitest": "^3.2.4",
45
+ "@vysmo/transitions": "0.1.0"
46
+ },
47
+ "scripts": {
48
+ "build": "tsc -p tsconfig.json",
49
+ "typecheck": "tsc -p tsconfig.typecheck.json",
50
+ "test": "vitest run && vitest run --config vitest.ssr.config.ts",
51
+ "test:browser": "vitest run",
52
+ "test:ssr": "vitest run --config vitest.ssr.config.ts",
53
+ "test:watch": "vitest"
54
+ }
55
+ }
@@ -0,0 +1,167 @@
1
+ "use client";
2
+
3
+ import { useEffect, useRef, useState } from "react";
4
+ import type { CSSProperties, ReactElement } from "react";
5
+ import {
6
+ Runner,
7
+ type Transition as TransitionType,
8
+ type UniformParams,
9
+ type TextureSource,
10
+ } from "@vysmo/transitions";
11
+ import { resolveSource, type Source } from "./resolve-source.js";
12
+
13
+ export interface TransitionProps {
14
+ /** Any transition exported by `@vysmo/transitions`. */
15
+ transition: TransitionType<UniformParams>;
16
+ /** First image. URL string, `HTMLImageElement`, canvas, video, or `ImageBitmap`. */
17
+ from: Source;
18
+ /** Second image. Same accepted shapes as `from`. */
19
+ to: Source;
20
+ /**
21
+ * Controlled progress in `[0, 1]`. When set, the component renders at
22
+ * exactly this progress and the autoplay loop is bypassed — drive it
23
+ * yourself (scroll progress, scrubber, animation library, etc.).
24
+ */
25
+ progress?: number;
26
+ /** Autoplay duration in ms. Used only when `progress` is omitted. Default `1000`. */
27
+ duration?: number;
28
+ /** Whether internal autoplay is running. Used only when `progress` is omitted. Default `true`. */
29
+ playing?: boolean;
30
+ /** Loop autoplay. Default `false`. */
31
+ loop?: boolean;
32
+ /** Easing function applied during autoplay. Default linear. */
33
+ ease?: (t: number) => number;
34
+ /** Override shader uniform defaults. */
35
+ params?: UniformParams;
36
+ /** Fires when a non-loop autoplay reaches `progress=1`. */
37
+ onComplete?: () => void;
38
+ /** Forwarded to the canvas element. */
39
+ className?: string;
40
+ /** Forwarded to the canvas element. */
41
+ style?: CSSProperties;
42
+ }
43
+
44
+ /**
45
+ * React wrapper around `@vysmo/transitions`'s `Runner`. Renders a
46
+ * `<canvas>` and drives a transition between two images — either
47
+ * controlled by a `progress` prop or self-driving via `duration` /
48
+ * `playing` / `loop` / `ease` for the common "play once on mount"
49
+ * case.
50
+ *
51
+ * The component creates one runner on mount and disposes it on
52
+ * unmount; sources are resolved (URL strings → decoded `Image`s) on
53
+ * `from` / `to` change. Canvas size syncs to its CSS box via
54
+ * `ResizeObserver`, with DPR applied so output stays sharp on retina.
55
+ */
56
+ export function Transition({
57
+ transition,
58
+ from,
59
+ to,
60
+ progress,
61
+ duration = 1000,
62
+ playing = true,
63
+ loop = false,
64
+ ease,
65
+ params,
66
+ onComplete,
67
+ className,
68
+ style,
69
+ }: TransitionProps): ReactElement {
70
+ const canvasRef = useRef<HTMLCanvasElement | null>(null);
71
+ const runnerRef = useRef<Runner | null>(null);
72
+ const [sources, setSources] = useState<{ from: TextureSource; to: TextureSource } | null>(null);
73
+
74
+ // Stash callable / object props in refs so the autoplay effect's
75
+ // dependency list can stay narrow — callers passing inline literals
76
+ // for `params` / `onComplete` / `ease` should not restart autoplay.
77
+ const paramsRef = useRef(params);
78
+ paramsRef.current = params;
79
+ const onCompleteRef = useRef(onComplete);
80
+ onCompleteRef.current = onComplete;
81
+ const easeRef = useRef(ease);
82
+ easeRef.current = ease;
83
+
84
+ // Mount: create runner + resize observer; dispose on unmount.
85
+ useEffect(() => {
86
+ const canvas = canvasRef.current;
87
+ if (!canvas) return;
88
+
89
+ const syncSize = () => {
90
+ const rect = canvas.getBoundingClientRect();
91
+ const dpr = window.devicePixelRatio || 1;
92
+ const w = Math.max(1, Math.round(rect.width * dpr));
93
+ const h = Math.max(1, Math.round(rect.height * dpr));
94
+ if (canvas.width !== w) canvas.width = w;
95
+ if (canvas.height !== h) canvas.height = h;
96
+ };
97
+ syncSize();
98
+
99
+ const runner = new Runner({ canvas });
100
+ runnerRef.current = runner;
101
+
102
+ const ro = typeof ResizeObserver !== "undefined" ? new ResizeObserver(syncSize) : null;
103
+ ro?.observe(canvas);
104
+
105
+ return () => {
106
+ ro?.disconnect();
107
+ runner.dispose();
108
+ runnerRef.current = null;
109
+ };
110
+ }, []);
111
+
112
+ // Resolve sources whenever `from` / `to` change.
113
+ useEffect(() => {
114
+ let cancelled = false;
115
+ Promise.all([resolveSource(from), resolveSource(to)]).then(([f, t]) => {
116
+ if (!cancelled) setSources({ from: f, to: t });
117
+ });
118
+ return () => {
119
+ cancelled = true;
120
+ };
121
+ }, [from, to]);
122
+
123
+ // Render: controlled (one render per progress change) or autoplay (rAF loop).
124
+ useEffect(() => {
125
+ const runner = runnerRef.current;
126
+ if (!runner || !sources) return;
127
+
128
+ const renderArgs = paramsRef.current
129
+ ? { from: sources.from, to: sources.to, params: paramsRef.current }
130
+ : { from: sources.from, to: sources.to };
131
+
132
+ if (progress !== undefined) {
133
+ runner.render(transition, { ...renderArgs, progress });
134
+ return;
135
+ }
136
+
137
+ if (!playing) return;
138
+
139
+ let cancelled = false;
140
+ let raf = 0;
141
+ let start = 0;
142
+
143
+ const tick = (now: number) => {
144
+ if (cancelled) return;
145
+ if (start === 0) start = now;
146
+ const t = Math.min(1, (now - start) / duration);
147
+ const easedT = easeRef.current ? easeRef.current(t) : t;
148
+ runner.render(transition, { ...renderArgs, progress: easedT });
149
+ if (t < 1) {
150
+ raf = requestAnimationFrame(tick);
151
+ } else if (loop) {
152
+ start = 0;
153
+ raf = requestAnimationFrame(tick);
154
+ } else {
155
+ onCompleteRef.current?.();
156
+ }
157
+ };
158
+ raf = requestAnimationFrame(tick);
159
+
160
+ return () => {
161
+ cancelled = true;
162
+ if (raf) cancelAnimationFrame(raf);
163
+ };
164
+ }, [sources, transition, progress, playing, loop, duration]);
165
+
166
+ return <canvas ref={canvasRef} className={className} style={style} />;
167
+ }
@@ -0,0 +1,143 @@
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 { dissolve } from "@vysmo/transitions";
5
+ import { Transition } from "../Transition.js";
6
+
7
+ // React 19's `act` only short-circuits its scheduler when this flag is
8
+ // set; otherwise it logs a noisy "not configured to support act"
9
+ // warning even when the wrapping does happen correctly.
10
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
11
+
12
+ function makeSolid(r: number, g: number, b: number, size = 32): HTMLCanvasElement {
13
+ const canvas = document.createElement("canvas");
14
+ canvas.width = size;
15
+ canvas.height = size;
16
+ const ctx = canvas.getContext("2d");
17
+ if (!ctx) throw new Error("2D context unavailable");
18
+ ctx.fillStyle = `rgb(${r}, ${g}, ${b})`;
19
+ ctx.fillRect(0, 0, size, size);
20
+ return canvas;
21
+ }
22
+
23
+ function flush(): Promise<void> {
24
+ return new Promise((r) => setTimeout(r, 0));
25
+ }
26
+
27
+ describe("<Transition>", () => {
28
+ let container: HTMLDivElement;
29
+ let root: Root;
30
+
31
+ beforeEach(() => {
32
+ container = document.createElement("div");
33
+ container.style.width = "128px";
34
+ container.style.height = "128px";
35
+ document.body.appendChild(container);
36
+ root = createRoot(container);
37
+ });
38
+
39
+ afterEach(async () => {
40
+ await act(async () => {
41
+ root.unmount();
42
+ });
43
+ container.remove();
44
+ });
45
+
46
+ it("renders a canvas element", async () => {
47
+ const a = makeSolid(255, 0, 0);
48
+ const b = makeSolid(0, 0, 255);
49
+ await act(async () => {
50
+ root.render(<Transition transition={dissolve} from={a} to={b} progress={0} />);
51
+ });
52
+ expect(container.querySelector("canvas")).toBeTruthy();
53
+ });
54
+
55
+ it("at progress=0 renders the from-image (red)", async () => {
56
+ const a = makeSolid(255, 0, 0);
57
+ const b = makeSolid(0, 0, 255);
58
+ await act(async () => {
59
+ root.render(<Transition transition={dissolve} from={a} to={b} progress={0} />);
60
+ });
61
+ await flush();
62
+ // Re-render to ensure render effect runs after sources resolve.
63
+ await act(async () => {
64
+ root.render(<Transition transition={dissolve} from={a} to={b} progress={0} />);
65
+ });
66
+ const canvas = container.querySelector("canvas")!;
67
+ const gl = canvas.getContext("webgl2", { preserveDrawingBuffer: true });
68
+ // We're not asserting pixels here — the runner doesn't preserve the
69
+ // drawing buffer by default (and the wrapper doesn't expose that
70
+ // option yet). The fact that the component mounted, resolved the
71
+ // sources, and drove a render without throwing is the contract.
72
+ expect(gl).toBeTruthy();
73
+ });
74
+
75
+ it("forwards className and style to the canvas", async () => {
76
+ const a = makeSolid(255, 0, 0);
77
+ const b = makeSolid(0, 0, 255);
78
+ await act(async () => {
79
+ root.render(
80
+ <Transition
81
+ transition={dissolve}
82
+ from={a}
83
+ to={b}
84
+ progress={0.5}
85
+ className="test-class"
86
+ style={{ borderRadius: "12px" }}
87
+ />,
88
+ );
89
+ });
90
+ const canvas = container.querySelector("canvas")!;
91
+ expect(canvas.className).toBe("test-class");
92
+ expect(canvas.style.borderRadius).toBe("12px");
93
+ });
94
+
95
+ it("resolves a string URL to an image (data URL path)", async () => {
96
+ // 1×1 transparent PNG.
97
+ const url =
98
+ "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=";
99
+ await act(async () => {
100
+ root.render(<Transition transition={dissolve} from={url} to={url} progress={0} />);
101
+ });
102
+ // Wait for image decode.
103
+ await flush();
104
+ await flush();
105
+ expect(container.querySelector("canvas")).toBeTruthy();
106
+ });
107
+
108
+ it("autoplays when no progress prop is given and calls onComplete", async () => {
109
+ const a = makeSolid(255, 0, 0);
110
+ const b = makeSolid(0, 0, 255);
111
+ let completed = false;
112
+ await act(async () => {
113
+ root.render(
114
+ <Transition
115
+ transition={dissolve}
116
+ from={a}
117
+ to={b}
118
+ duration={50}
119
+ onComplete={() => {
120
+ completed = true;
121
+ }}
122
+ />,
123
+ );
124
+ });
125
+ // Wait for autoplay to finish (~50ms) plus rAF.
126
+ await new Promise((r) => setTimeout(r, 200));
127
+ expect(completed).toBe(true);
128
+ });
129
+
130
+ it("disposes runner on unmount without throwing", async () => {
131
+ const a = makeSolid(255, 0, 0);
132
+ const b = makeSolid(0, 0, 255);
133
+ await act(async () => {
134
+ root.render(<Transition transition={dissolve} from={a} to={b} progress={0.5} />);
135
+ });
136
+ await act(async () => {
137
+ root.unmount();
138
+ });
139
+ // Re-create root for afterEach to unmount safely.
140
+ root = createRoot(container);
141
+ expect(container.querySelector("canvas")).toBeNull();
142
+ });
143
+ });
@@ -0,0 +1,21 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ /**
4
+ * SSR safety: the module must load in Node without DOM globals. This
5
+ * verifies that no top-level code touches `window` / `document` / etc
6
+ * — `useEffect` bodies don't run on the server, so as long as the
7
+ * imports + module bodies are clean, the wrapper is SSR-safe.
8
+ */
9
+ describe("SSR safety", () => {
10
+ it("window is undefined in this runtime", () => {
11
+ expect(typeof window).toBe("undefined");
12
+ });
13
+
14
+ it("module loads without DOM globals", async () => {
15
+ const mod = await import("../index.js");
16
+ expect(mod).toBeDefined();
17
+ expect(typeof mod.Transition).toBe("function");
18
+ expect(typeof mod.useTransitionRunner).toBe("function");
19
+ expect(typeof mod.resolveSource).toBe("function");
20
+ });
21
+ });
package/src/index.ts ADDED
@@ -0,0 +1,3 @@
1
+ export { Transition, type TransitionProps } from "./Transition.js";
2
+ export { useTransitionRunner } from "./use-transition-runner.js";
3
+ export { resolveSource, type Source, type ResolvedSource } from "./resolve-source.js";
@@ -0,0 +1,36 @@
1
+ import type { TextureSource } from "@vysmo/transitions";
2
+
3
+ /**
4
+ * What the React wrapper accepts as a `from` / `to`. Strings are
5
+ * convenience for `<img src=…>`-style URLs; everything else passes
6
+ * straight through to the runner.
7
+ */
8
+ export type Source =
9
+ | string
10
+ | HTMLImageElement
11
+ | HTMLCanvasElement
12
+ | HTMLVideoElement
13
+ | ImageBitmap;
14
+
15
+ export type ResolvedSource = Exclude<Source, string>;
16
+
17
+ /**
18
+ * Resolve a `Source` to a paint-ready `TextureSource`. Strings load via
19
+ * a fresh `Image` with `crossOrigin="anonymous"` and `decode()` so the
20
+ * transition can render on the next frame without showing a blank
21
+ * canvas. Non-string sources await `decode()` if applicable, then
22
+ * pass through.
23
+ */
24
+ export async function resolveSource(source: Source): Promise<TextureSource> {
25
+ if (typeof source === "string") {
26
+ const img = new Image();
27
+ img.crossOrigin = "anonymous";
28
+ img.src = source;
29
+ if ("decode" in img) await img.decode();
30
+ return img;
31
+ }
32
+ if (source instanceof HTMLImageElement && !source.complete) {
33
+ await source.decode();
34
+ }
35
+ return source;
36
+ }
@@ -0,0 +1,36 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+ import type { RefObject } from "react";
5
+ import { Runner } from "@vysmo/transitions";
6
+
7
+ /**
8
+ * Low-level escape hatch for callers who want the `<Transition>`
9
+ * component's lifecycle but their own render loop. Pass a ref to a
10
+ * canvas you mount yourself; the hook constructs a `Runner` once the
11
+ * canvas is in the DOM and disposes it on unmount, returning the
12
+ * runner instance (or `null` while it's still mounting).
13
+ *
14
+ * The component built on this is `<Transition>` — reach for the hook
15
+ * only when you're integrating with a non-React render loop or need
16
+ * direct `runner.render()` access for multi-pass / displacement cases
17
+ * the props don't expose yet.
18
+ */
19
+ export function useTransitionRunner(
20
+ canvasRef: RefObject<HTMLCanvasElement | null>,
21
+ ): Runner | null {
22
+ const [runner, setRunner] = useState<Runner | null>(null);
23
+
24
+ useEffect(() => {
25
+ const canvas = canvasRef.current;
26
+ if (!canvas) return;
27
+ const r = new Runner({ canvas });
28
+ setRunner(r);
29
+ return () => {
30
+ r.dispose();
31
+ setRunner(null);
32
+ };
33
+ }, [canvasRef]);
34
+
35
+ return runner;
36
+ }