DEV Community

Cover image for Build a scroll-driven WebGL hero in 30 lines
TommyDee
TommyDee

Posted on

Build a scroll-driven WebGL hero in 30 lines

Hero sections that respond to scroll are one of those features that look complicated and actually aren't, once you have the right pieces. Two images, a shader that morphs between them, scroll position drives the morph. That's it. The rest is plumbing.

This tutorial builds exactly that — a scroll-driven WebGL hero — in about 30 lines of JavaScript. Plain HTML, no framework, no build step. Drop it into a CodePen or a static HTML file and it works.

What we're building

Three behaviors, tied together:

  1. As the user scrolls down into the hero section, image A morphs into image B.
  2. While the hero is centered in the viewport, the morph holds on image B.
  3. As the user scrolls down past the hero, image B morphs back to image A. That's the standard scroll-driven hero shape. Sites like Apple's product pages, Linear's marketing, and Vercel's case studies all use this pattern. The trick is the holding part — without it, the transition feels like it ends too fast and the user never sees the destination.

The dependencies

Two npm packages, both MIT, both tiny.

pnpm add @vysmo/scroll @vysmo/transitions
Enter fullscreen mode Exit fullscreen mode

Combined, that's about 6 KB gzipped. Less than a logo SVG.

The HTML

A section, a canvas, two <img> elements that we'll use as transition sources, plus some content above and below to give the page something to scroll through:

<section style="height: 200vh; padding-top: 50vh;">
  <h2>Above the hero</h2>
  <p>Scroll down to see the effect.</p>
</section>

<section id="hero" style="height: 100vh; position: relative;">
  <canvas id="hero-canvas" style="width: 100%; height: 100%;"></canvas>
  <img id="img-a" src="/from.jpg" style="display: none;" />
  <img id="img-b" src="/to.jpg" style="display: none;" />
</section>

<section style="height: 200vh;">
  <h2>Below the hero</h2>
  <p>Keep scrolling.</p>
</section>
Enter fullscreen mode Exit fullscreen mode

The two <img> elements are display: none because we're not rendering them as DOM — we're passing them to WebGL as texture sources. The browser still decodes them, which is what we need.

The JavaScript — 30 lines

import { Runner, crossZoom } from "@vysmo/transitions";
import { createScrollTransition, scrollPlateau } from "@vysmo/scroll";

const section = document.querySelector<HTMLElement>("#hero")!;
const canvas = document.querySelector<HTMLCanvasElement>("#hero-canvas")!;
const from = document.querySelector<HTMLImageElement>("#img-a")!;
const to = document.querySelector<HTMLImageElement>("#img-b")!;

// Wait for both images to decode before we use them as textures.
await Promise.all([from.decode(), to.decode()]);

// Match the canvas backing store to its CSS size so the shader renders sharp.
const dpr = Math.min(window.devicePixelRatio, 2);
canvas.width = canvas.clientWidth * dpr;
canvas.height = canvas.clientHeight * dpr;

// One WebGL2 runner per canvas. It owns the compiled programs and FBOs.
const runner = new Runner({ canvas });

// Bind scroll progress to the transition.
createScrollTransition({
  section,
  runner,
  transition: crossZoom,
  from,
  to,
  // Hold the "to" image while the section is 30%–70% through the viewport.
  ease: scrollPlateau(0.3, 0.7),
});
Enter fullscreen mode Exit fullscreen mode

That's the whole thing. Let me walk through the parts that aren't obvious.

What scrollPlateau does

This is the part of the API I'd most like to spend a paragraph on, because it's the small idea that makes the whole pattern work.

Raw scroll progress is a 0 → 1 line as the section moves through the viewport. If you feed that directly into a transition, you get a morph that starts the moment the section enters and ends the moment it exits — which means the user only briefly sees the destination image as it flies past. Unsatisfying.

scrollPlateau(0.3, 0.7) reshapes that linear progress into a bathtub curve:

  • 0.0 → 0.3 of scroll progress: transition plays 0 → 1
  • 0.3 → 0.7 of scroll progress: transition stays at 1 (the "hold")
  • 0.7 → 1.0 of scroll progress: transition plays 1 → 0 The result: the morph happens quickly as the section enters, the destination image gets to live on screen for the comfortable middle stretch, and the morph reverses on exit. Visually, the user feels like they "arrived" at something, instead of like something whisked past them.

You can change the plateau bounds to taste — scrollPlateau(0.1, 0.9) makes the entry and exit transitions very fast, scrollPlateau(0.4, 0.6) is slower and more deliberate.

Swap the transition for a totally different feel

The line transition: crossZoom is the one that defines the look. There are 60 built-in transitions, and switching is one identifier:

import { pageCurl } from "@vysmo/transitions";
// ...
createScrollTransition({ section, runner, transition: pageCurl, from, to, ease });
Enter fullscreen mode Exit fullscreen mode

Now image A peels back like a page turn to reveal image B as you scroll. Same 30 lines of code, completely different aesthetic.

A few transitions worth trying for a hero:

  • crossZoom — the cinematic default; image A zooms in as it fades into B
  • pageCurl — editorial, like turning a magazine page
  • paintBleed — a paint-pour reveal, looks great for product launches
  • warpZoom — a chromatic warp; perfect for tech aesthetics
  • glassShatter — image A shatters into B; high-drama, use sparingly Each is a one-line change to the import and the transition prop. The catalog has all of them with live previews.

What's happening under the hood

In case the magic feels suspicious, here's what's actually running:

  1. createScrollTransition registers your section with a shared IntersectionObserver and a single requestAnimationFrame loop (one rAF for all Vysmo scroll bindings on the page — that part matters for performance when you have multiple sections).
  2. On every animation frame where the section is intersecting the viewport, it computes a raw 0 → 1 progress value from the section's bounding-box position.
  3. It passes that value through the ease function (scrollPlateau in our case) to get a morphed progress.
  4. It calls runner.render(transition, { from, to, progress }). The runner has the compiled WebGL2 shader cached, so this is just uniform updates and a draw call — sub-millisecond on a modern phone. No internal timers, no playback state, no "play" / "pause" / "reverse" — the renderer is idempotent. You give it a progress value, it draws that frame. Scroll up and the same code path runs the transition backwards. That's why the same library works equally well for rAF-driven animations, GSAP timelines, video exports, and scroll bindings.

Common gotchas

Image must be decoded first. If you skip the await Promise.all([from.decode(), to.decode()]) line, the first render might happen with one or both textures empty, and you'll see a flash of black. Always decode before passing to the Runner.

Canvas DPR. Without the canvas.width = canvas.clientWidth * dpr lines, the canvas backing store is whatever the browser defaults to (usually 300×150 px), which makes everything look like a pixelated mess. Match the backing store to the CSS size times device pixel ratio.

Don't run on iOS Low Power Mode. WebGL2 still works there but performance is throttled — the rAF callback may only fire 30 times per second instead of 60. Your transition will look choppy. Test on a real phone, not just your laptop with throttling disabled.

When you'd want more

This 30-line version covers a single hero. As your page gets more complex — multiple scroll-driven sections, scroll-driven text reveals layered on top, scroll-driven effects (a bloom that ramps as you enter, identity in the middle, fades on exit) — the same @vysmo/scroll package has primitives for those too:

  • createScrollEffect — drives a @vysmo/effects filter through the same three-zone envelope
  • createScrollProgress — raw 0 → 1 emitter you can wire to anything (opacity, transform, your own state)
  • sharedScrollObserver — for when you're building your own scroll-driven renderer and want to plug into the same single-rAF batching But you don't need any of that today. 30 lines, two images, one transition, one envelope. That's a shippable hero.

Try it

Full example, copy-pasteable, including the HTML scaffolding:

<!doctype html>
<html>
  <head><title>Vysmo Hero Demo</title></head>
  <body style="margin:0;font-family:system-ui;">
    <section style="height:200vh;padding-top:50vh;">
      <h2 style="text-align:center;">Scroll down</h2>
    </section>

    <section id="hero" style="height:100vh;position:relative;">
      <canvas id="hero-canvas" style="width:100%;height:100%;display:block;"></canvas>
      <img id="img-a" src="/from.jpg" style="display:none;" />
      <img id="img-b" src="/to.jpg" style="display:none;" />
    </section>

    <section style="height:200vh;">
      <h2 style="text-align:center;">Keep scrolling</h2>
    </section>

    <script type="module">
      import { Runner, crossZoom } from "https://esm.sh/@vysmo/transitions";
      import { createScrollTransition, scrollPlateau } from "https://esm.sh/@vysmo/scroll";

      const section = document.querySelector("#hero");
      const canvas = document.querySelector("#hero-canvas");
      const from = document.querySelector("#img-a");
      const to = document.querySelector("#img-b");

      await Promise.all([from.decode(), to.decode()]);

      const dpr = Math.min(window.devicePixelRatio, 2);
      canvas.width = canvas.clientWidth * dpr;
      canvas.height = canvas.clientHeight * dpr;

      const runner = new Runner({ canvas });
      createScrollTransition({
        section, runner,
        transition: crossZoom,
        from, to,
        ease: scrollPlateau(0.3, 0.7),
      });
    </script>
  </body>
</html>
Enter fullscreen mode Exit fullscreen mode

Drop two images named from.jpg and to.jpg next to that file, open it in a browser, and you have a working scroll-driven WebGL hero.

The full catalog of transitions with live previews is at vysmo.com/transitions, and the scroll package docs are at vysmo.com/scroll. Source: github.com/vysmodev/vysmo. All MIT.

Top comments (0)