@vysmo/transitions Library Canvas

Transitions for the modern web.

60 WebGL2 shaders, defined as plain data. Tree-shakable to the byte. Endpoint-correct by construction. Zero runtime dependencies.

  • 60 transitions
  • ~5 KB gzipped
  • 0 runtime deps
  • SSR-safe at import
$ pnpm add @vysmo/transitions View on GitHub

Install

Pick your package manager. Adds @vysmo/transitions and the optional animation driver.

pnpm add @vysmo/transitions @vysmo/animations npm install @vysmo/transitions @vysmo/animations yarn add @vysmo/transitions @vysmo/animations

Zero runtime dependencies, SSR-safe at import, tree-shakable to the byte. Drop the second package if you have your own animation driver — runner.render() is idempotent.

Quick start

End-to-end: load two images, drive a transition between them. The runner is thin enough that this is genuinely all you need.

import { Runner, paintBleed } from "@vysmo/transitions";
import { animate } from "@vysmo/animations";

const canvas = document.querySelector<HTMLCanvasElement>("canvas")!;
const runner = new Runner({ canvas });

// Two images you want to crossfade between.
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 progress 0 → 1 and render every frame.
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.

Using with React

Drop-in <Transition> component that wraps the Runner — accepts a transition + two images and a progress prop (controlled) or duration / playing / loop / ease (self-driving). useTransitionRunner hook for advanced cases.

Install pnpm add @vysmo/transitions @vysmo/transitions-react
import { Transition } from "@vysmo/transitions-react";
import { paintBleed } from "@vysmo/transitions";

export function Hero() {
  return (
    <Transition
      transition={paintBleed}
      from="/a.jpg"
      to="/b.jpg"
      duration={1200}
      style={{ width: "100%", aspectRatio: "16 / 9" }}
    />
  );
}
Full props + advanced patterns on npm

Concepts

The mental model in six bullets. Skim if you're already comfortable with the playground.

Runner is one-per-canvas

A Runner owns a WebGL2 context bound to a single canvas. Cheap to instantiate, GPU-cheap to keep alive. Survives context loss via the standard onContextLost / onContextRestored callbacks. Always call runner.dispose() on unmount — otherwise you leak the context and shader cache.

Frame loop is yours, not ours

runner.render() is idempotent: pass it the current progress on every frame and it draws. The library owns no animation loop — you drive progress from requestAnimationFrame, an animation library, scroll progress, a timeline, anything. The same shader serves preview, render, and export.

Pages are anything browsers can draw

from and to accept HTMLImageElement, HTMLCanvasElement, HTMLVideoElement, OffscreenCanvas, ImageBitmap, and ImageData. The texture cache treats decoded images and ImageBitmaps as immutable (upload once, reuse) and re-uploads canvases / videos / ImageData every frame because their pixels can change.

Multi-pass: shaders that read themselves

Some effects (ink diffusion, displacement chains) need to read the previous frame's output. A transition can declare passes so the runner allocates two ping-pong framebuffers and exposes uPass, uPassCount, and getPrevious(uv) in the shader. You see this in inkDiffuse; most transitions use a single pass.

Mesh transitions: real geometry

Effects like pageCurl and polygonFlip can’t be done in a fragment shader — they need silhouette, depth, and self-occlusion. Mesh transitions declare a vertex shader plus subdivision count; the runner builds the buffer and runs drawArraysInstanced. Same render() call from your perspective; the difference is internal.

Endpoint correctness is enforced

Every built-in transition is verified by tests: at progress=0 the output is pixel-pure from, at progress=1 pixel-pure to. No near-misses. When you author your own with defineTransition, the same invariant applies — see the FAQ for the common pitfalls.

API reference

Five things to know. Everything else is composition.

Runner

Owns a WebGL2 context bound to a single canvas. One per surface. Cheap to instantiate, GPU-cheap to keep alive. Survives context loss via the standard onContextLost / onContextRestored callbacks.

new Runner({
  canvas: HTMLCanvasElement | OffscreenCanvas,
  contextAttributes?: WebGLContextAttributes,
  onContextLost?: () => void,
  onContextRestored?: () => void,
})

runner.render(transition, args)

Renders one frame between from and to at progress ∈ [0, 1]. Idempotent, so you can drive it from any animation library — including @vysmo/animations, GSAP, raw rAF, or scroll progress.

runner.render(transition, {
  from: HTMLImageElement | HTMLCanvasElement | HTMLVideoElement | ...,
  to:   HTMLImageElement | HTMLCanvasElement | HTMLVideoElement | ...,
  progress: number,                  // 0..1
  params?: Partial<typeof transition.defaults>,
  displacement?: TextureSource,      // optional flow-warp map
  environment?:  TextureSource,      // optional reflection map
})

defineTransition

Author your own. Param types are inferred — never hand-type Transition<{...}>. Drop your transition into the same runner.render() call as the built-ins.

import { defineTransition } from "@vysmo/transitions";

export const myWipe = defineTransition({
  name: "my-wipe",
  defaults: { angle: 0, softness: 0.05 },
  glsl: `
    uniform float uAngle;
    uniform float uSoftness;
    vec4 transition(vec2 uv) {
      vec2 dir = vec2(cos(uAngle), sin(uAngle));
      float p = dot(uv - 0.5, dir) + 0.5;
      float m = smoothstep(uProgress - uSoftness, uProgress, p);
      return mix(getToColor(uv), getFromColor(uv), m);
    }
  `,
});

Standard uniforms & helpers

The runner injects these around your transition(uv) body. You don’t declare or sample textures yourself — call the helpers.

// Available in every fragment shader:
uniform float uProgress;       // 0..1
uniform vec2  uResolution;     // canvas size in pixels
uniform float uPass;           // current fragment pass index
uniform float uPassCount;      // total passes (multi-pass only)
vec4 getFromColor(vec2 uv);    // sample 'from'
vec4 getToColor(vec2 uv);      // sample 'to'
vec4 getPrevious(vec2 uv);     // previous pass output
vec4 getDisplacement(vec2 uv); // displacement map sample
vec4 getEnvironment(vec2 uv);  // env / reflection sample
vec2 mirrorUv(vec2 uv);        // reflect out-of-range UVs (use on warp/flow samples)

// Custom uniforms: declare them and the runner maps
// camelCase keys in 'defaults' → uPascalCase uniforms.

Naming conventions

Transition names are kebab-case; export identifiers are camelCase. Custom uniforms in GLSL are uPascalCase, mapped automatically from camelCase keys in defaults.

// "cross-zoom" → import { crossZoom }
// defaults.blur → uniform uBlur
// defaults.noiseStrength → uniform uNoiseStrength

Catalog

Every built-in transition with its parameters, defaults, and accepted values. Pick a transition to inspect its prop table — same data the playground uses.

filmBurn
Prop
Type
Default
Values
center
vec2
[0.5, 0.5]
[0.5, 0.5] Center · [0.5, 0.85] Top · [0.5, 0.15] Bottom · [0.15, 0.5] Left · [0.85, 0.5] Right · [0.15, 0.85] TL · [0.85, 0.85] TR
scale
number
6
0.5 – 18 · step 0.05
edgeWidth
number
0.05
0 – 1 · step 0.05
chroma
number
0
0 – 1 · step 0.01
flameColor
vec3
[1.6, 0.7, 0.15]
[1.6, 0.7, 0.15] Ember · [1.8, 0.4, 0.05] Molten · [1.2, 0.15, 0.1] Deep red · [0.5, 1.6, 0.4] Acid · [0.4, 1, 1.6] Ice blue · [1.2, 1.6, 2.5] Electric · [1.4, 0.6, 1.8] Violet
lightLeak
Prop
Type
Default
Values
direction
vec2
[1, 0]
[1, 0] Right · [-1, 0] Left · [0, -1] Down · [0, 1] Up · [1, -1] Diagonal · [-1, -1] Anti-diag
color
vec3
[1, 0.85, 0.55]
[1, 0.85, 0.55] Sunset · [1, 0.7, 0.85] Magic hour · [1, 0.96, 0.92] Daylight · [0.5, 1, 1.1] Cyan · [1.1, 0.4, 0.95] Magenta
bandWidth
number
0.2
0 – 1 · step 0.05
intensity
number
1
0 – 3 · step 0.01
heatHaze
Prop
Type
Default
Values
intensity
number
0.04
0 – 1.5 · step 0.01
frequency
number
14
0 – 56 · step 1
flow
number
5
0 – 15 · step 1
bloomReveal
Prop
Type
Default
Values
scale
number
5
0.5 – 15 · step 0.05
softness
number
0.08
0 – 0.4 · step 0.005
threshold
number
0.55
0 – 1 · step 0.01
intensity
number
3
0 – 9 · step 0.01
directionalBurn
Prop
Type
Default
Values
direction
vec2
[1, 0]
[1, 0] Right · [-1, 0] Left · [0, -1] Down · [0, 1] Up · [1, -1] Diagonal · [-1, -1] Anti-diag
scale
number
10
0.5 – 30 · step 0.05
edgeWidth
number
0.035
0 – 1 · step 0.05
flameColor
vec3
[1.6, 0.7, 0.15]
[1.6, 0.7, 0.15] Ember · [1.8, 0.4, 0.05] Molten · [1.2, 0.15, 0.1] Deep red · [0.5, 1.6, 0.4] Acid · [0.4, 1, 1.6] Ice blue · [1.2, 1.6, 2.5] Electric · [1.4, 0.6, 1.8] Violet
prismSplit
Prop
Type
Default
Values
direction
vec2
[1, 0]
[1, 0] Right · [-1, 0] Left · [0, -1] Down · [0, 1] Up · [1, -1] Diagonal · [-1, -1] Anti-diag
intensity
number
0.04
0 – 0.07 · step 0.005
softness
number
0.2
0 – 0.4 · step 0.005
emberScatter
Prop
Type
Default
Values
count
number
5
0 – 24 · step 1
scale
number
8
0.5 – 24 · step 0.05
edgeWidth
number
0.04
0 – 1 · step 0.05
stagger
number
0.35
0 – 1 · step 0.01
flameColor
vec3
[1.6, 0.7, 0.15]
[1.6, 0.7, 0.15] Ember · [1.8, 0.4, 0.05] Molten · [1.2, 0.15, 0.1] Deep red · [0.5, 1.6, 0.4] Acid · [0.4, 1, 1.6] Ice blue · [1.2, 1.6, 2.5] Electric · [1.4, 0.6, 1.8] Violet
godRaysReveal
Prop
Type
Default
Values
scale
number
5
0.5 – 15 · step 0.05
softness
number
0.08
0 – 0.4 · step 0.005
threshold
number
0.45
0 – 1 · step 0.01
intensity
number
1.6
0 – 4.8 · step 0.01
decay
number
0.92
0.5 – 1 · step 0.005
source
vec2
[0.5, 0.5]
[0.5, 0.5] Center · [0.5, 0.85] Top · [0.5, 0.15] Bottom · [0.15, 0.5] Left · [0.85, 0.5] Right · [0.15, 0.85] TL · [0.85, 0.85] TR
chromaticPulse
Prop
Type
Default
Values
intensity
number
0.6
0 – 1.8 · step 0.01
paintBleed
Prop
Type
Default
Values
direction
vec2
[-1, 0]
[-1, 0] Right · [1, 0] Left · [0, 1] Down · [0, -1] Up · [-1, 1] Diagonal · [1, 1] Anti-diag
scale
number
10
0.5 – 30 · step 0.05
softness
number
0.02
0 – 0.4 · step 0.005
noiseStrength
number
0.35
0 – 1.5 · step 0.01
inkDiffuse
Prop
Type
Default
Values
scale
number
7
0.5 – 21 · step 0.05
softness
number
0.08
0 – 0.4 · step 0.005
inkBloom
Prop
Type
Default
Values
center
vec2
[0.5, 0.5]
[0.5, 0.5] Center · [0.5, 0.85] Top · [0.5, 0.15] Bottom · [0.15, 0.5] Left · [0.85, 0.5] Right · [0.15, 0.85] TL · [0.85, 0.85] TR
scale
number
5
0.5 – 15 · step 0.05
edgeWidth
number
0.07
0 – 1 · step 0.05
bloomWidth
number
0.12
0 – 1 · step 0.05
inkColor
vec3
[0.25, 0.08, 0.45]
[0.25, 0.08, 0.45] Indigo · [0.06, 0.06, 0.08] Sumi · [0.55, 0.05, 0.12] Crimson · [0.06, 0.32, 0.18] Forest · [0.05, 0.18, 0.55] Sapphire
fluidFlow
Prop
Type
Default
Values
strength
number
0.12
0 – 1.5 · step 0.01
scale
number
3
0.5 – 9 · step 0.05
liquidMorph
Prop
Type
Default
Values
scale
number
3
0.5 – 9 · step 0.05
strength
number
0.1
0 – 1.5 · step 0.01
flow
number
3
0 – 9 · step 0.1
dripWipe
Prop
Type
Default
Values
direction
vec2
[-1, 0]
[1, 0] Right · [-1, 0] Left · [0, -1] Down · [0, 1] Up
width
number
0.5
0 – 1.5 · step 0.05
scaleX
number
40
0.5 – 120 · step 0.05
scaleY
number
40
0.5 – 120 · step 0.05
smolderingEdge
Prop
Type
Default
Values
direction
vec2
[1, 1]
[1, 0] Right · [-1, 0] Left · [0, -1] Down · [0, 1] Up · [1, -1] Diagonal · [-1, -1] Anti-diag
scale
number
3
0.5 – 9 · step 0.05
edgeWidth
number
0.04
0 – 1 · step 0.05
trailLength
number
0.18
0 – 1 · step 0.01
emberColor
vec3
[1.4, 0.5, 0.1]
[1.4, 0.5, 0.1] Ember · [1.7, 0.3, 0.05] Molten · [1.1, 0.12, 0.08] Deep red · [0.4, 1.5, 0.3] Acid
luminaMelt
Prop
Type
Default
Values
softness
number
0.15
0 – 0.4 · step 0.005
invert
enum
0
0 Bright melts first · 1 Dark melts first
dissolve

No params.

wipeDirectional
Prop
Type
Default
Values
angle
enum
0
0 Right · -1.5708 Down · 3.1416 Left · 1.5708 Up · -0.7854 Diagonal · -2.3562 Anti-diag
softness
number
0.05
0 – 0.4 · step 0.005
slide
Prop
Type
Default
Values
direction
vec2
[-1, 0]
[1, 0] Right · [-1, 0] Left · [0, -1] Down · [0, 1] Up
feather
number
0.015
0 – 0.4 · step 0.005
blur
number
0
0 – 0.1 · step 0.005
push
Prop
Type
Default
Values
direction
vec2
[1, 0]
[-1, 0] Right · [1, 0] Left · [0, 1] Down · [0, -1] Up
split
Prop
Type
Default
Values
axis
enum
0
0 Horizontal · 1 Vertical
mode
enum
0
0 Open · 1 Close
softness
number
0.01
0 – 0.4 · step 0.005
clockWipe
Prop
Type
Default
Values
startAngle
number
-1.5708
-3.1416 – 3.1416 · step 0.01
direction
enum
1
1 Clockwise · -1 Counter-clockwise
softness
number
0.02
0 – 0.4 · step 0.005
radialReveal
Prop
Type
Default
Values
center
vec2
[0.5, 0.5]
[0.5, 0.5] Center · [0.5, 0.85] Top · [0.5, 0.15] Bottom · [0.15, 0.5] Left · [0.85, 0.5] Right · [0.15, 0.85] TL · [0.85, 0.85] TR
softness
number
0.05
0 – 0.4 · step 0.005
irisZoom
Prop
Type
Default
Values
center
vec2
[0.5, 0.5]
[0.5, 0.5] Center · [0.5, 0.85] Top · [0.5, 0.15] Bottom · [0.15, 0.5] Left · [0.85, 0.5] Right · [0.15, 0.85] TL · [0.85, 0.85] TR
width
number
0.08
0 – 1 · step 0.05
scale
number
8
0.5 – 24 · step 0.05
shapeReveal
Prop
Type
Default
Values
center
vec2
[0.5, 0.5]
[0.5, 0.5] Center · [0.5, 0.85] Top · [0.5, 0.15] Bottom · [0.15, 0.5] Left · [0.85, 0.5] Right · [0.15, 0.85] TL · [0.85, 0.85] TR
sides
enum
6
3 Triangle · 4 Diamond · 5 Pentagon · 6 Hexagon · 8 Octagon · 12 Dodecagon · 32 Circle
rotation
number
0
-6.2832 – 6.2832 · step 0.01
softness
number
0.05
0 – 0.4 · step 0.005
gridReveal
Prop
Type
Default
Values
count
number
8
3 – 24 · step 1
stagger
number
0.7
0 – 1 · step 0.01
pattern
enum
1
0 Sequential · 1 Radial · 2 Random
warpZoom
Prop
Type
Default
Values
center
vec2
[0.5, 0.5]
[0.5, 0.5] Center · [0.5, 0.85] Top · [0.5, 0.15] Bottom · [0.15, 0.5] Left · [0.85, 0.5] Right · [0.15, 0.85] TL · [0.85, 0.85] TR
strength
number
1
0 – 3 · step 0.01
rotation
number
1
-6.2832 – 6.2832 · step 0.01
blur
number
0.02
0 – 0.4 · step 0.005
crossZoom
Prop
Type
Default
Values
strength
number
1.2
0 – 3.6 · step 0.01
blur
number
0.04
0 – 0.4 · step 0.005
directionalWarp
Prop
Type
Default
Values
direction
vec2
[-1, 1]
[1, 0] Right · [-1, 0] Left · [0, -1] Down · [0, 1] Up · [1, -1] Diagonal · [-1, -1] Anti-diag
smoothness
number
0.5
0 – 1 · step 0.01
swirl
Prop
Type
Default
Values
radius
number
1
0 – 3 · step 0.1
strength
number
25.13
0 – 75.39 · step 0.01
flowWarp
Prop
Type
Default
Values
intensity
number
0.4
0 – 1.5 · step 0.01
angle1
number
0.7854
-3.1416 – 3.1416 · step 0.01
angle2
number
-2.3562
-3.1416 – 3.1416 · step 0.01
ripple
Prop
Type
Default
Values
center
vec2
[0.5, 0.5]
[0.5, 0.5] Center · [0.5, 0.85] Top · [0.5, 0.15] Bottom · [0.15, 0.5] Left · [0.85, 0.5] Right · [0.15, 0.85] TL · [0.85, 0.85] TR
amplitude
number
0.03
0 – 1.5 · step 0.01
frequency
number
6
0 – 24 · step 1
speed
number
8
0 – 24 · step 0.05
rippleWave
Prop
Type
Default
Values
amplitude
number
0.1
0 – 1.5 · step 0.01
source
vec2
[0.5, 0.5]
[0.5, 0.5] Center · [0.5, 0.85] Top · [0.5, 0.15] Bottom · [0.15, 0.5] Left · [0.85, 0.5] Right · [0.15, 0.85] TL · [0.85, 0.85] TR
shockwave
Prop
Type
Default
Values
center
vec2
[0.5, 0.5]
[0.5, 0.5] Center · [0.5, 0.85] Top · [0.5, 0.15] Bottom · [0.15, 0.5] Left · [0.85, 0.5] Right · [0.15, 0.85] TL · [0.85, 0.85] TR
thickness
number
0.15
0 – 1 · step 0.01
strength
number
0.04
0 – 1.5 · step 0.01
gravityPull
Prop
Type
Default
Values
center
vec2
[0.5, 0.5]
[0.5, 0.5] Center · [0.5, 0.85] Top · [0.5, 0.15] Bottom · [0.15, 0.5] Left · [0.85, 0.5] Right · [0.15, 0.85] TL · [0.85, 0.85] TR
intensity
number
0.15
0 – 1.5 · step 0.01
portalDive
Prop
Type
Default
Values
twist
number
3.1416
-6.2832 – 6.2832 · step 0.01
depth
number
1
0 – 3 · step 0.05
reflection
number
0
0 – 1 · step 0.01
singularity
Prop
Type
Default
Values
center
vec2
[0.5, 0.5]
[0.5, 0.5] Center · [0.5, 0.85] Top · [0.5, 0.15] Bottom · [0.15, 0.5] Left · [0.85, 0.5] Right · [0.15, 0.85] TL · [0.85, 0.85] TR
wind
Prop
Type
Default
Values
size
number
0.2
0 – 1 · step 0.01
direction
vec2
[1, 0]
[1, 0] Right · [-1, 0] Left · [0, -1] Down · [0, 1] Up · [1, -1] Diagonal · [-1, -1] Anti-diag
linearBlur
Prop
Type
Default
Values
direction
vec2
[1, 0]
[1, 0] Right · [-1, 0] Left · [0, -1] Down · [0, 1] Up · [1, -1] Diagonal · [-1, -1] Anti-diag
intensity
number
0.1
0 – 1.5 · step 0.01
tangentMotionBlur
Prop
Type
Default
Values
direction
vec2
[1, 0]
[1, 0] Right · [-1, 0] Left · [0, -1] Down · [0, 1] Up · [1, -1] Diagonal · [-1, -1] Anti-diag
intensity
number
0.08
0 – 1.5 · step 0.01
softness
number
0.2
0 – 0.4 · step 0.005
glitch
Prop
Type
Default
Values
intensity
number
0.6
0 – 1.8 · step 0.01
chroma
number
0.02
0 – 0.05 · step 0.002
blocks
number
30
0 – 90 · step 1
noiseDissolve
Prop
Type
Default
Values
scale
number
20
0.5 – 60 · step 0.05
softness
number
0.05
0 – 0.4 · step 0.005
pixelate
Prop
Type
Default
Values
maxBlockSize
number
40
0 – 120 · step 0.05
mosaic
Prop
Type
Default
Values
count
number
14
3 – 24 · step 1
jitter
number
0.08
0 – 1 · step 0.01
stagger
number
0.4
0 – 1 · step 0.01
filmGrain
Prop
Type
Default
Values
grain
number
1
0 – 3 · step 0.1
crosshatch
Prop
Type
Default
Values
center
vec2
[0.5, 0.5]
[0.5, 0.5] Center · [0.5, 0.85] Top · [0.5, 0.15] Bottom · [0.15, 0.5] Left · [0.85, 0.5] Right · [0.15, 0.85] TL · [0.85, 0.85] TR
threshold
number
3
3 – 30 · step 0.5
fadeEdge
number
0.1
0 – 1 · step 0.01
dreamy

No params.

dreamyZoom
Prop
Type
Default
Values
intensity
number
0.5
0 – 1 · step 0.01
colorPhase

No params.

liquidChrome
Prop
Type
Default
Values
shine
number
0.9
0 – 1 · step 0.01
rim
number
0.25
0 – 1 · step 0.01
wobble
number
0.12
0 – 1 · step 0.01
refraction
number
0.035
0 – 1 · step 0.01
reflection
number
0
0 – 1 · step 0.01
kineticBands
Prop
Type
Default
Values
count
number
12
2 – 24 · step 1
stagger
number
0.6
0 – 1 · step 0.01
softness
number
0.02
0 – 0.4 · step 0.005
direction
vec2
[1, 0]
[1, 0] Right · [-1, 0] Left · [0, -1] Down · [0, 1] Up
polkaDotsCurtain
Prop
Type
Default
Values
dots
number
15
1 – 45 · step 1
center
vec2
[0.5, 0.5]
[0.5, 0.5] Center · [0.5, 0.85] Top · [0.5, 0.15] Bottom · [0.15, 0.5] Left · [0.85, 0.5] Right · [0.15, 0.85] TL · [0.85, 0.85] TR
softness
number
0.05
0 – 0.4 · step 0.005
waveStripes
Prop
Type
Default
Values
direction
vec2
[1, 0]
[1, 0] Right · [-1, 0] Left · [0, -1] Down · [0, 1] Up
pinwheel
Prop
Type
Default
Values
center
vec2
[0.5, 0.5]
[0.5, 0.5] Center · [0.5, 0.85] Top · [0.5, 0.15] Bottom · [0.15, 0.5] Left · [0.85, 0.5] Right · [0.15, 0.85] TL · [0.85, 0.85] TR
spokes
number
8
0 – 24 · step 1
softness
number
0.05
0 – 0.4 · step 0.005
pageCurl
Prop
Type
Default
Values
tilt
number
0.12
0 – 1 · step 0.01
backColor
vec3
[0.97, 0.96, 0.94]
[0.97, 0.96, 0.94] Paper · [0.93, 0.86, 0.72] Parchment · [0.99, 0.98, 0.92] Ivory · [0.86, 0.88, 0.92] Cool gray · [0.32, 0.34, 0.4] Slate
polygonFlip
Prop
Type
Default
Values
rim
number
0.25
0 – 1 · step 0.01
glassShatter
Prop
Type
Default
Values
cells
number
14
0 – 42 · step 1
reflection
number
0
0 – 1 · step 0.01
tileScatter
Prop
Type
Default
Values
scatter
number
1
0 – 3 · step 0.1
lenticularFlip
Prop
Type
Default
Values
stripCount
number
22
3 – 60 · step 1

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.

// Going A → B (forward):
animate({
  from: 0, to: 1, duration: 800,
  onUpdate: (p) => runner.render(paintBleed, { from: a, to: b, progress: p }),
});

// Going B → A: time-reverse the progress, NOT the direction param.
animate({
  from: 1, to: 0, duration: 800,
  onUpdate: (p) => runner.render(paintBleed, { from: a, to: b, progress: p }),
});
// from / to stay in the same order. Flipping a direction param produces
// a visibly different motion than playing the forward transition backwards.

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,
  // progress 0 when #hero enters viewport, 1 when it exits the top.
});

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.

// Any HTMLVideoElement (or HTMLCanvasElement, ImageBitmap, OffscreenCanvas)
// is a valid source. The Runner re-uploads its pixels per render() call.

const video = document.querySelector<HTMLVideoElement>("video")!;
video.play();

animate({
  from: 0, to: 1, duration: 1500,
  onUpdate: (p) => runner.render(crossZoom, {
    from: video,    // ← live video as the from-side
    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";

// Vignette-aware reveal: center pixels swap first, corners last.
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.

// One Runner per canvas. They share no state — different transitions,
// different sources, different progress, all running in parallel.
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 });

// Always dispose on unmount — browsers cap WebGL contexts per page.
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.

// runner.render() is idempotent — pause is just "stop advancing progress".
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; });
// Resuming picks up from the last frozen progress value.

Migration

Moving from gl-transitions — the closest neighbour in the WebGL transition space — or hand-rolled crossfades.

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.

// Before — gl-transitions
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 });

// After — @vysmo/transitions
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:

// Porting a custom GLSL shader you wrote against gl-transitions.
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));
    }
  `,
});

// Then drop into the same runner.render() call as the built-ins.
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).

FAQ & gotchas

The handful of things that catch people the first time they ship a WebGL transition.

Why is my canvas blank?

Three things to check. (1) The canvas has measurable dimensions. The runner sets canvas.width / canvas.height from the canvas’s rendered size, but if the parent has collapsed to 0 it stays 0. (2) Your image sources are decoded before the first render call. runner.render doesn’t queue — if from or to hasn’t resolved an image yet, you get black. await img.decode() first. (3) Progress is in [0, 1]. Out of range values clamp visually but won’t throw.

Does it respect prefers-reduced-motion?

The library doesn’t drive any animation — you do, via your own animation library or scroll progress. So honoring user preference is just gating your driver: detect with matchMedia and either skip the animation entirely (render once at progress: 1) or run it instantly.

if (matchMedia("(prefers-reduced-motion: reduce)").matches) {
  // Skip the curl entirely — render once at the destination.
  runner.render(myTransition, { from, to, progress: 1 });
} else {
  animate({ from: 0, to: 1, duration: 1200,
    onUpdate: (p) => runner.render(myTransition, { from, to, progress: p }),
  });
}

How do I clean up the Runner?

Call runner.dispose(). Releases the WebGL context, shader / program cache, framebuffers, texture cache. Always run on unmount in component frameworks — otherwise re-renders accumulate dead contexts. Browsers cap WebGL contexts per page (~16 in Chrome) so leaking adds up fast.

What about Safari, iOS, older browsers?

WebGL2 ships in every modern browser: Safari 15+, iOS 15+, Firefox 51+, Chrome 56+, Edge, mobile Safari, mobile Chrome. The Runner constructor throws if the WebGL2 context can’t be created — wrap in try / catch and fall back to a CSS opacity crossfade for the long tail. For most sites it’s a non-issue.

How do I use this with React, Vue, or Svelte?

The vanilla API plays well with framework lifecycle hooks. The React pattern is a single useRef for the Runner plus an effect for re-render on prop change — Vue and Svelte look structurally similar.

// React
import { useEffect, useRef } from "react";
import { Runner, paintBleed } from "@vysmo/transitions";

function CrossfadeCanvas({ from, to, progress }) {
  const ref = useRef<HTMLCanvasElement>(null);
  const runnerRef = useRef<Runner | null>(null);

  useEffect(() => {
    const runner = new Runner({ canvas: ref.current! });
    runnerRef.current = runner;
    return () => runner.dispose();
  }, []);

  useEffect(() => {
    runnerRef.current?.render(paintBleed, { from, to, progress });
  }, [from, to, progress]);

  return <canvas ref={ref} />;
}

My custom transition flashes or has a hard cut. Why?

Three engineering invariants that every shader on this site has to meet — the same checklist applies to yours.

  • Endpoint correctness. At progress=0 output must be pixel-pure from; at progress=1, pixel-pure to. If you’re using smoothstep(low, high, x) for a wipe, ensure the result evaluates to exactly 0 at p=0 and exactly 1 at p=1.
  • Polish degrades at endpoints. Visual params (feather, blur, displacement, chroma split) must be zero-effect at p=0 and p=1. Common pattern: scale the effect by 4 * p * (1 - p) so it peaks mid-transition and gates cleanly to zero at both ends.
  • Continuous motion. No visual freeze at any progress. A symmetric envelope like 4*p*(1-p) peaks at 0.5 and drops to zero velocity there — if it drives a positional transform AND your crossfade also centers at 0.5, the result reads as a "pause" mid-transition. Use monotonic transforms (angle = p * strength) for motion; reserve symmetric envelopes for effects that should zero at endpoints (blur, glow, aberration).