DEV Community

TommyDee
TommyDee

Posted on

Migrating from gl-transitions to @vysmo/transitions: the diff that matters

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.

WebGl Transition

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 });
Enter fullscreen mode Exit fullscreen mode
// After — @vysmo/transitions
import { Runner, wind } from "@vysmo/transitions";

const runner = new Runner({ canvas });
runner.render(wind, { from, to, progress, params: { size: 0.2 } });
Enter fullscreen mode Exit fullscreen mode

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 pass HTMLImageElement (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,
  }),
});
Enter fullscreen mode Exit fullscreen mode

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.

PageFlip Transition

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 } });
Enter fullscreen mode Exit fullscreen mode

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/catch around the Runner constructor.
  • 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 defineTransition API 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

  1. pnpm add @vysmo/transitions @vysmo/animations (drop @vysmo/animations if you have your own driver — GSAP, anime.js, raw rAF, scroll progress, anything that produces a 0→1 number works).
  2. Replace your createTransition(gl, GLSHADER) calls with the corresponding named import: import { wind } from "@vysmo/transitions".
  3. Replace new Runner({ canvas }) for the canvas; remove your manual gl = canvas.getContext("webgl") call.
  4. Replace transition.draw(progress, fromTex, toTex, w, h, params) with runner.render(transition, { from, to, progress, params }). Pass your HTMLImageElement directly — drop the texture-creation code.
  5. 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)