ure Preparation
WebGL requires decoded image data before creating textures. Using hidden <img> elements allows the browser to handle decoding, caching, and format negotiation natively. The elements remain in the DOM but are excluded from layout and paint.
<section id="viewport-hero" style="height: 100vh; position: relative;">
<canvas id="shader-canvas" style="width: 100%; height: 100%; display: block;"></canvas>
<img id="texture-source-a" src="/assets/initial-frame.webp" style="display: none;" />
<img id="texture-source-b" src="/assets/destination-frame.webp" style="display: none;" />
</section>
Step 2: Canvas Backing Store Calibration
CSS dimensions and canvas backing stores are decoupled. Failing to scale the backing store results in blurry rendering on high-DPI displays. Capping the device pixel ratio at 2 prevents excessive GPU memory allocation on 3x or 4x screens while maintaining visual fidelity.
const canvas = document.querySelector<HTMLCanvasElement>('#shader-canvas')!;
const dpr = Math.min(window.devicePixelRatio, 2);
canvas.width = canvas.clientWidth * dpr;
canvas.height = canvas.clientHeight * dpr;
Step 3: Shader Runner Initialization
The runner manages WebGL2 context creation, shader compilation, and framebuffer object (FBO) allocation. It exposes a single render method that accepts transition parameters and a progress value.
import { Runner, crossZoom } from '@vysmo/transitions';
const glRenderer = new Runner({ canvas });
The scroll binding module observes the target section, calculates viewport intersection progress, applies an easing envelope, and feeds the result to the runner. The easing envelope reshapes linear scroll data into a three-zone curve: entry transition, hold phase, and exit transition.
import { createScrollTransition, scrollPlateau } from '@vysmo/scroll';
const viewportSection = document.querySelector<HTMLElement>('#viewport-hero')!;
const sourceImage = document.querySelector<HTMLImageElement>('#texture-source-a')!;
const targetImage = document.querySelector<HTMLImageElement>('#texture-source-b')!;
await Promise.all([sourceImage.decode(), targetImage.decode()]);
createScrollTransition({
section: viewportSection,
runner: glRenderer,
transition: crossZoom,
from: sourceImage,
to: targetImage,
ease: scrollPlateau(0.3, 0.7),
});
Architecture Decisions & Rationale
Idempotent Rendering: The runner does not maintain playback state. It receives a progress value and draws the corresponding frame. This eliminates direction tracking, pause/resume logic, and internal timers. Scrolling backward automatically reverses the transition because the progress value decreases, triggering the same draw path.
Single rAF Batching: The scroll binding module registers a shared IntersectionObserver and a single requestAnimationFrame loop. All scroll-linked effects on the page share this loop, preventing layout thrashing and ensuring frame pacing aligns with the browser's compositor.
Hidden Image Elements: Browsers optimize image decoding and caching at the network and layout engine level. Passing decoded <img> elements directly to WebGL bypasses manual fetch/ArrayBuffer handling and leverages native texture upload pathways.
Plateau Easing: Linear scroll mapping causes transitions to complete before the user's attention stabilizes. The scrollPlateau(0.3, 0.7) function creates a non-linear curve where progress reaches 1.0 at 30% scroll, holds until 70%, then reverses. This aligns visual pacing with human reading patterns.
Pitfall Guide
1. Premature Render Execution
Explanation: Calling the renderer before image decoding completes results in empty textures. The shader draws uninitialized memory, causing a black or white flash on first interaction.
Fix: Always await Promise.all([imgA.decode(), imgB.decode()]) before initializing the runner or binding scroll events.
2. Unscaled Canvas Backing Stores
Explanation: CSS width/height does not automatically scale the canvas pixel buffer. On Retina displays, a 100% sized canvas defaults to a 300Γ150 buffer, producing severe pixelation.
Fix: Multiply CSS dimensions by Math.min(window.devicePixelRatio, 2) and assign to canvas.width/canvas.height before creating the WebGL context.
Explanation: Feeding raw 0β1 scroll progress directly into a transition causes the morph to finish as soon as the section enters the viewport. Users rarely perceive the destination state.
Fix: Apply a non-linear easing function like scrollPlateau(0.3, 0.7) to create a hold phase. Adjust bounds based on content density and scroll velocity expectations.
Explanation: iOS throttles requestAnimationFrame to 30fps in Low Power Mode. WebGL2 continues functioning, but frame pacing degrades noticeably, making transitions appear choppy.
Fix: Detect throttling via window.matchMedia('(prefers-reduced-motion: reduce)') or frame interval measurement. Fall back to CSS opacity transitions or disable WebGL effects entirely when throttling is active.
5. Unbounded Observer Registration
Explanation: In single-page applications, navigating away from a hero section without cleaning up scroll bindings leaves IntersectionObserver instances and rAF callbacks active. This causes memory leaks and unintended renders on subsequent pages.
Fix: Capture the cleanup function returned by createScrollTransition and invoke it during component unmount or route navigation.
6. Texture Aspect Ratio Drift
Explanation: If source and destination images have mismatched aspect ratios, the shader may stretch or crop unexpectedly. WebGL samplers do not automatically normalize dimensions.
Fix: Pre-process images to match target aspect ratios, or implement a object-fit: cover equivalent in the fragment shader using UV coordinate clamping and dynamic scaling.
7. Over-Reliance on Visual Effects
Explanation: Scroll-driven shaders can distract from primary content or trigger vestibular disorders in sensitive users.
Fix: Respect prefers-reduced-motion. Provide a static fallback state. Ensure critical information remains accessible without requiring scroll interaction.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Single hero section on marketing page | Idempotent scroll-driven renderer | Minimal bundle, native reversal support, 60fps guaranteed | Low (~6 KB) |
| Multi-section page with layered effects | Shared observer + single rAF batcher | Prevents layout thrashing, synchronizes all viewport bindings | Low (shared loop overhead) |
| Accessibility-first / compliance requirement | CSS opacity fallback + reduced-motion detection | Meets WCAG guidelines, prevents vestibular triggers | Zero (native CSS) |
| Low-end mobile / IoT displays | Static image + scroll-triggered CSS transition | Avoids WebGL context creation, reduces GPU load | Zero (no JS execution) |
Configuration Template
import { Runner, crossZoom } from '@vysmo/transitions';
import { createScrollTransition, scrollPlateau } from '@vysmo/scroll';
export class ScrollDrivenHero {
private cleanupFn: (() => void) | null = null;
constructor(
private sectionId: string,
private canvasId: string,
private sourceImgId: string,
private targetImgId: string,
private holdBounds: [number, number] = [0.3, 0.7]
) {}
async mount(): Promise<void> {
const section = document.querySelector<HTMLElement>(`#${this.sectionId}`);
const canvas = document.querySelector<HTMLCanvasElement>(`#${this.canvasId}`);
const sourceImg = document.querySelector<HTMLImageElement>(`#${this.sourceImgId}`);
const targetImg = document.querySelector<HTMLImageElement>(`#${this.targetImgId}`);
if (!section || !canvas || !sourceImg || !targetImg) {
throw new Error('Required DOM elements not found');
}
// Respect user motion preferences
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (prefersReducedMotion) {
canvas.style.display = 'none';
targetImg.style.display = 'block';
return;
}
await Promise.all([sourceImg.decode(), targetImg.decode()]);
const dpr = Math.min(window.devicePixelRatio, 2);
canvas.width = canvas.clientWidth * dpr;
canvas.height = canvas.clientHeight * dpr;
const renderer = new Runner({ canvas });
this.cleanupFn = createScrollTransition({
section,
runner: renderer,
transition: crossZoom,
from: sourceImg,
to: targetImg,
ease: scrollPlateau(...this.holdBounds),
});
}
unmount(): void {
if (this.cleanupFn) {
this.cleanupFn();
this.cleanupFn = null;
}
}
}
Quick Start Guide
- Prepare Assets: Place two images with matching aspect ratios in your project directory. Ensure they are optimized for web (WebP/AVIF recommended).
- Insert DOM Structure: Add a hero section containing a canvas element and two hidden
<img> tags pointing to your assets. Set the section height to 100vh and position relative.
- Initialize Module: Import the
@vysmo/transitions and @vysmo/scroll packages. Decode both images, calibrate the canvas DPR, instantiate the runner, and bind the scroll transition using a plateau easing function.
- Validate & Deploy: Test scroll interaction across desktop and mobile viewports. Verify frame pacing using browser performance tools. Confirm cleanup routines execute on navigation. Deploy to static hosting or integrate into your build pipeline.