import { Runner, paintBleed } from "@vysmo/transitions";
import { animate } from "@vysmo/animations";
const canvas = document.querySelector<HTMLCanvasElement>("canvas")!;
const runner = new Runner({ canvas });
const fromImg = new Image();
const toImg = new Image();
fromImg.src = "/photo-a.jpg";
toImg.src = "/photo-b.jpg";
await Promise.all([fromImg.decode(), toImg.decode()]);
animate({
from: 0,
to: 1,
duration: 1200,
onUpdate: (p) => runner.render(paintBleed, {
from: fromImg,
to: toImg,
progress: p,
}),
});
Three things going on: the Runner owns a WebGL2
context bound to your canvas; animate drives a
progress value 0 → 1 over time; runner.render is
idempotent, so on every frame you pass the current progress and a
transition function and it draws. Want a different shader? Swap
paintBleed for any other export. Want scroll-driven?
Drive progress from scroll position instead of time.
Cookbook
Recipes for the patterns the API doesn't make obvious. Every recipe is one snippet long; copy, paste, run.
Chain transitions back-to-back
runner.render is idempotent and stateless —
running one transition then another is just sequencing two animation
drivers. Wrap each in a promise that resolves on
onComplete.
import { Runner, paintBleed, crossZoom } from "@vysmo/transitions";
import { animate } from "@vysmo/animations";
async function showAtoBtoC(a: HTMLImageElement, b: HTMLImageElement, c: HTMLImageElement) {
await new Promise<void>((resolve) => {
animate({
from: 0, to: 1, duration: 800,
onUpdate: (p) => runner.render(paintBleed, { from: a, to: b, progress: p }),
onComplete: resolve,
});
});
await new Promise<void>((resolve) => {
animate({
from: 0, to: 1, duration: 800,
onUpdate: (p) => runner.render(crossZoom, { from: b, to: c, progress: p }),
onComplete: resolve,
});
});
}
Reverse a transition
Don’t flip a direction param — it produces a different
motion. Time-reverse the progress: drive
animate({ from: 1, to: 0 }) with the same
from / to sources. The shader plays
backwards, the eye reads it as the inverse motion of the forward
version.
animate({
from: 0, to: 1, duration: 800,
onUpdate: (p) => runner.render(paintBleed, { from: a, to: b, progress: p }),
});
animate({
from: 1, to: 0, duration: 800,
onUpdate: (p) => runner.render(paintBleed, { from: a, to: b, progress: p }),
});
Scroll-driven progress
@vysmo/scroll ships a createScrollTransition
helper that subscribes a Runner to a scroll progress observer.
Hero crossfades, parallax-style reveals, sticky-scroll narratives
all reduce to one call.
import { Runner, paintBleed } from "@vysmo/transitions";
import { createScrollTransition } from "@vysmo/scroll";
const runner = new Runner({ canvas });
createScrollTransition({
section: document.querySelector("#hero")!,
runner,
transition: paintBleed,
from: imgA,
to: imgB,
});
Use a video as a source
from and to accept any
HTMLVideoElement; the runner re-uploads the current
frame on each render call so the transition stays in sync with the
video’s playhead. Page-curl an image to a video, crossfade
between two camera feeds, transition between a logo and a
background loop.
const video = document.querySelector<HTMLVideoElement>("video")!;
video.play();
animate({
from: 0, to: 1, duration: 1500,
onUpdate: (p) => runner.render(crossZoom, {
from: video,
to: imgB,
progress: p,
}),
});
Author a custom shader with defineTransition
The 60 built-ins cover the common ground; for the unique-to-your-
product effect, defineTransition gives you the same
shader shell with a typed defaults object. Param types
are inferred — never hand-type
Transition<{...}>. Custom uniforms are mapped
automatically: defaults.softness → uniform
uSoftness.
import { defineTransition } from "@vysmo/transitions";
const vignetteFade = defineTransition({
name: "vignette-fade",
defaults: { softness: 0.2 },
glsl: `
uniform float uSoftness;
vec4 transition(vec2 uv) {
float r = distance(uv, vec2(0.5)); // 0 at center, ~0.71 at corners
float t = smoothstep(uProgress - uSoftness,
uProgress + uSoftness, r);
return mix(getToColor(uv), getFromColor(uv), t);
}
`,
});
runner.render(vignetteFade, { from, to, progress: 0.5,
params: { softness: 0.1 } });
Multiple canvases on one page
One Runner per canvas. They share no state —
different transitions, different sources, different progress, all
running in parallel. The browser caps WebGL contexts per page
(~16 in Chrome) so dispose unmounted runners.
const heroRunner = new Runner({ canvas: heroCanvas });
const galleryRunner = new Runner({ canvas: galleryCanvas });
heroRunner.render(pageCurl, { from: a, to: b, progress: 0.4 });
galleryRunner.render(crossZoom, { from: c, to: d, progress: 0.7 });
heroRunner.dispose();
galleryRunner.dispose();
Pause & resume mid-transition
Because render is idempotent and you own the progress
driver, “pause” is just “stop advancing
progress.” Resume picks up wherever the last frame left off.
let progress = 0;
let running = true;
function tick(t: number) {
if (running) progress = computeProgressFromTime(t);
runner.render(transition, { from, to, progress });
requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
pauseBtn.addEventListener("click", () => { running = false; });
resumeBtn.addEventListener("click", () => { running = true; });
From gl-transitions
The mental model is similar (GLSL fragment shaders that mix two
textures over progress) but the surface is different.
gl-transitions hands you a draw function bound to a
WebGL1 context; @vysmo/transitions wraps the
context, the program cache, and the texture cache behind a single
Runner. You always have a typed transition data
object, not a class.
import createTransition from "gl-transitions/lib/transition.js";
import GL from "gl-transitions/transitions/wind.js";
const gl = canvas.getContext("webgl");
const transition = createTransition(gl, GL);
transition.draw(progress, fromTex, toTex, w, h, { size: 0.2 });
import { Runner, wind } from "@vysmo/transitions";
const runner = new Runner({ canvas });
runner.render(wind, { from, to, progress, params: { size: 0.2 } });
What you gain: TypeScript inference on params (no manual
generics), a tree-shakable per-shader import surface, mesh
transitions for effects that can’t be done in a fragment
shader, multi-pass rendering for shaders that read previous
output, and the engineering invariants (endpoint correctness,
no hard cuts, full-frame always) verified per shader by tests.
Porting your own GLSL shader
If you wrote a custom shader against gl-transitions's
contract, the GLSL body usually ports as-is. Drop it into
defineTransition:
import { defineTransition } from "@vysmo/transitions";
export const myWind = defineTransition({
name: "my-wind",
defaults: { size: 0.2 },
glsl: `
uniform float uSize;
vec4 transition(vec2 uv) {
float r = fract(sin(uv.y * 1000.0) * 1000.0);
float m = 1.0 - smoothstep(-uSize, 0.0, uv.x - uProgress * (1.0 + uSize));
return mix(getFromColor(uv), getToColor(uv), m * (0.3 + 0.7 * r));
}
`,
});
runner.render(myWind, { from, to, progress, params: { size: 0.3 } });
Naming conventions: transition names are kebab-case;
export identifiers are camelCase; custom uniforms
in GLSL are uPascalCase mapped automatically from
camelCase keys in defaults. You don’t declare or
sample textures yourself — call
getFromColor(uv) / getToColor(uv).
See the API reference above for the full uniform / helper surface.
From a hand-rolled CSS crossfade
If you’re currently animating opacity on stacked
elements, the migration is roughly: replace the two
<img> elements with a single
<canvas>, decode both images yourself with
img.decode(), then drive
runner.render(transition, { from, to, progress }) from
your existing animation driver. Same timing curve, same total
duration — the visual richness comes from picking a transition
other than a literal opacity blend (crossfade works,
but so does anything else from the catalog).