If you've ever wanted a fancy crossfade between two images on the web, you've probably ended up at gl-transitions. It's been the de facto WebGL transition library for the better part of a decade — a community gallery of GLSL fragment shaders that mix two textures over a progress value from 0 to 1. It works. I shipped with it. You probably shipped with it.
But it's a WebGL1, untyped, class-based draw function bound to a context you manage by hand. In 2026 the rough edges show. So a few weeks ago I migrated a project to @vysmo/transitions — same conceptual model, modernized surface — and the diff is small enough to do in an afternoon and big enough to be worth writing down.
This isn't a "vysmo is better, install it" post. It's a "here's the diff, here's what changes, here's what doesn't" post. Decide for yourself.
The mental model is the same
Both libraries treat a transition as a GLSL fragment shader that samples two textures and a progress uniform, and writes a color. You drive progress from 0 to 1 over some duration and call render every frame.
That's it. If you understood gl-transitions, you understand vysmo. The differences are surface-level — until they aren't.
The five-line diff
Here's the same crossfade, in both libraries, side by side.
// 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 } });
Visually similar. But notice what disappeared:
- You no longer get the WebGL context yourself.
- You no longer create textures by hand and pass them as
fromTex/toTex— you passHTMLImageElement(or canvas, video, ImageBitmap, OffscreenCanvas, ImageData) and the library uploads, caches, and reuses them. - The
{ size: 0.2 }is type-checked. Misspell it and your editor underlines it before you save. -
createTransition(gl, GL)is gone. There's no per-shader instance to construct, dispose, or cache. The Runner owns the program cache. That's roughly 80% of the migration work right there.
What you gain (the bits that mattered for me)
Five things. In rough order of how often they showed up while I was porting.
1. TypeScript inference on every transition's params
The single biggest day-to-day improvement. In gl-transitions, params are an untyped object — you check the shader source to find out what's available and what's misspelled. In vysmo, every transition exports a typed defaults object and params is Partial<typeof transition.defaults>. Editor autocomplete tells you wind takes a size and direction, and what the value ranges are.
You never write Transition<{...}> by hand. Types are inferred from the data.
2. Tree-shaking per shader
gl-transitions ships ~80 shaders and most bundlers can't tree-shake them effectively because of how the registry imports them. You end up shipping every shader you don't use.
@vysmo/transitions exports each transition as a named export from the package root. Import paintBleed and crossZoom, and that's all that goes in your bundle. The whole library is ~5 KB gzipped if you imported everything — most apps ship 1–2 KB.
3. Sources aren't textures anymore
In gl-transitions you write your own texture upload pipeline. Decode an image, create a texture, set parameters, upload pixels, hand the texture to draw, manage its lifecycle. For a single transition this is fine. For a slideshow rotating through 12 images it gets tedious.
In vysmo, sources are anything the browser can draw: image, canvas, video, ImageBitmap, OffscreenCanvas, ImageData. The Runner has a texture cache that treats decoded images as immutable (uploaded once, reused) and re-uploads canvases/videos every frame because their pixels can change. You stop thinking about textures.
// Live video as the from-side. 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,
to: imgB,
progress: p,
}),
});
4. Mesh and multi-pass shaders work
This is the one that actually unlocked something for me. Effects like page-curl can't be done in a fragment shader — they need real geometry, silhouette, depth, self-occlusion. gl-transitions' shape (one fragment shader, one full-screen quad) rules them out. The community gallery has no working page-curl for this reason.
vysmo's Runner builds a vertex buffer and runs drawArraysInstanced for mesh transitions, and allocates ping-pong framebuffers for multi-pass shaders that need to read their previous output. Same render() call from your perspective — the difference is internal.
In practice this means pageCurl, polygonFlip, glassShatter, inkDiffuse, lenticularFlip, tileScatter all exist as built-ins. None of those are possible as pure fragment shaders.
5. Endpoint correctness is enforced
Every built-in is tested to produce pixel-pure from at progress=0 and pixel-pure to at progress=1. No near-misses, no one-frame flash at the end where blur is still half-applied. It sounds minor until you've shipped a transition that ends on a barely-visible artifact and your client emails about it on Tuesday.
If you author your own with defineTransition, the same invariant applies — and the docs lay out the three rules that make it work. Worth a separate post.
Porting a custom shader
If you wrote your own gl-transitions shader, 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 } });
The renamings you'll do mechanically:
| gl-transitions | @vysmo/transitions |
|---|---|
progress |
uProgress |
getFromColor |
getFromColor (same)
|
getToColor |
getToColor (same)
|
ratio |
derive from uResolution
|
custom uniform size
|
uniform uSize, key size in defaults
|
Naming conventions: transition names are kebab-case, export identifiers are camelCase, custom uniforms in GLSL are uPascalCase, mapped automatically from camelCase keys in defaults. So defaults.noiseStrength becomes uniform float uNoiseStrength; in your shader.
What you give up
Honest list, not a sales pitch.
-
WebGL1 support. Vysmo is WebGL2-only. Modern browsers all ship it (Safari 15+, iOS 15+, Firefox 51+, Chrome 56+), but if you support ancient mobile, you'll need a CSS opacity-crossfade fallback wrapped in a
try/catcharound theRunnerconstructor. -
The community gallery model. gl-transitions has a huge community-contributed shader library on a single registry page. Vysmo ships 60 transitions, curated, with parameters and tested invariants — and a
defineTransitionAPI for your own. Different philosophy. - Maturity. gl-transitions has been around since 2016. Vysmo shipped this year. There's no Stack Overflow long-tail yet. If those tradeoffs aren't deal-breakers, the migration is roughly an afternoon for a single transition, a day for a slideshow.
The minimal port: do this
-
pnpm add @vysmo/transitions @vysmo/animations(drop@vysmo/animationsif you have your own driver — GSAP, anime.js, raw rAF, scroll progress, anything that produces a 0→1 number works). - Replace your
createTransition(gl, GLSHADER)calls with the corresponding named import:import { wind } from "@vysmo/transitions". - Replace
new Runner({ canvas })for the canvas; remove your manualgl = canvas.getContext("webgl")call. - Replace
transition.draw(progress, fromTex, toTex, w, h, params)withrunner.render(transition, { from, to, progress, params }). Pass yourHTMLImageElementdirectly — drop the texture-creation code. - On unmount, call
runner.dispose(). (gl-transitions leaks the context if you forget; vysmo also leaks it if you forget. The difference is vysmo gives you one method to call instead of context teardown + program deletion + texture cleanup.) That's it. Same shaders, same look, smaller bundle, types.
One philosophical note
The thing that sold me on the migration wasn't the type safety or the tree-shaking — those are table stakes. It was that runner.render() is idempotent. You pass the current progress on every frame and the library draws. There's no animation loop inside the library, no internal timer, no playback state to fight.
That means scroll progress drives a transition exactly the same way a requestAnimationFrame loop does, which is exactly the same way a video editor's timeline scrubber does, which is exactly the same way an export-to-MP4 frame iterator does. Same shader, four use cases, zero code changes. gl-transitions could technically work this way too — but its API hints toward a more imperative model and most people end up wrapping it in their own loop.
Idempotent render + plain-data transitions is a small architectural decision with surprisingly long reach. It's the part of the library I'd port to my own code even if I weren't using vysmo.
Vysmo libraries are MIT, free, all of them — github.com/vysmodev/vysmo. Docs, the full transition catalog with live parameter playgrounds, and a Next.js guide at vysmo.com.


Top comments (0)