Skip to content
Mateo Sonzogni
All articles

Shader Synchronization in Three.js with GSAP ScrollTrigger and Lenis in React

How to structure a WebGL render pipeline without overloading React's main thread, unifying Lenis scroll inertia with GLSL uniform updates via GSAP.

When I approached the immersive landing page for Ynara Web (ynara-web), the goal wasn't just to add 3D decoratively, but to visually represent the idea of a light node that mutates and reacts as the user navigates the page. In theory, it sounds simple: you have a WebGL scene rendered with Three.js, a smooth scroll script (lenis) to normalize scroll inertia, and gsap with ScrollTrigger to orchestrate animation progression. In practice, combining React's component tree with GPU rendering and continuous scroll events often leads to severe performance issues if you don't clearly define which layer owns the animation loop.

The common mistake when working with this stack is allowing each library to manage its own execution cycle through independent requestAnimationFrame calls. If React re-renders components due to state changes while Lenis calculates scroll inertia and Three.js tries to draw the scene on the canvas, the main JavaScript thread becomes saturated. The immediate consequence is a loss of visual smoothness and noticeable stuttering in the animation. To solve this in Ynara Web, I designed an architecture where there is a single source of truth for the animation clock, and GPU modifications are performed directly on shader memory, completely bypassing React's reconciliation system.

The Conflict Between the DOM, Virtual DOM, and GPU

React is excellent at declaring state-based interfaces, but terrible at managing variations occurring 60 times per second. If you attempt to store scroll progress in a React state (useState) to pass it as a prop to a Three.js component, you force React to run its reconciliation algorithm on every single frame. This destroys performance.

On the other hand, browsers handle DOM scrolling on a separate thread or synchronize it with screen refreshes via implicit events. Lenis intercepts the wheel event and simulates smooth scrolling by recalculating the virtual position. If Three.js attempts to read window.scrollY directly inside its own requestAnimationFrame loop, a mismatch arises between the actual scrollbar position, the position interpolated by Lenis, and the fraction of a second in which the WebGL frame is drawn.

The technical solution requires addressing two structural issues:

  1. Centralizing execution: a single animation loop must update smooth scrolling, calculate relative scroll positions, and render the 3D scene.
  2. Direct pointer mutation: values calculated by GSAP must be imperatively written to the uniforms properties of the GLSL material, bypassing React state entirely.

Synchronizing the Render Cycle via GSAP Ticker

To prevent Lenis and Three.js from competing for the main thread, I decided to pause Lenis's internal loop and tie its updates to the GSAP ticker. GSAP features a highly optimized clock that internally handles requestAnimationFrame calls and consistently adjusts time deltas.

During project initialization, I disable Lenis's auto-update mechanism and attach a listener to the GSAP ticker to update the scroll state on every clock cycle:

import gsap from 'gsap';
import ScrollTrigger from 'gsap/ScrollTrigger';
import Lenis from 'lenis';

gsap.registerPlugin(ScrollTrigger);

export function setupSmoothScroll() {
  const lenis = new Lenis({
    duration: 1.2,
    easing: (t) => Math.min(1, 1.001 - Math.pow(2, -10 * t)),
    smoothWheel: true,
  });

  // Tell ScrollTrigger to use Lenis events
  lenis.on('scroll', ScrollTrigger.update);

  // Disable Lenis auto-updating
  // and attach it directly to GSAP's central ticker
  gsap.ticker.add((time) => {
    lenis.raf(time * 1000);
  });

  // Disable GSAP lag smoothing to maintain perfect sync
  gsap.ticker.lagSmoothing(0);

  return lenis;
}

With this setup, GSAP executes Lenis's interpolation calculation on every animation frame and immediately notifies ScrollTrigger. There is no latency offset or double requestAnimationFrame invocation.

Mutating GLSL Uniforms Without Re-renders

In Ynara Web, the central entity is a sphere of light deformed by 3D Perlin noise calculated directly in a WebGL fragment shader. For the shape to mutate as the user scrolls down the page, I need to pass two fundamental variables from JavaScript into GLSL:

  • uTime: elapsed time in seconds for the base organic animation.
  • uProgress: a decimal value between 0.0 and 1.0 representing how far the user has progressed through the landing page structure.

Instead of instantiating components with React wrappers that encapsulate the Canvas object, I used useRef to maintain persistent references to the scene, camera, renderer, and custom material (ShaderMaterial).

import { useEffect, useRef } from 'react';
import * as THREE from 'three';
import gsap from 'gsap';
import ScrollTrigger from 'gsap/ScrollTrigger';

// Simple Vertex Shader passing UV coordinates and normals
const vertexShader = `
  varying vec2 vUv;
  varying vec3 vNormal;
  uniform float uProgress;
  uniform float uTime;
  
  void main() {
    vUv = uv;
    vNormal = normal;
    vec3 pos = position;
    
    // Vertex deformation based on uProgress
    float distortion = sin(pos.y * 5.0 + uTime) * uProgress * 0.2;
    pos += normal * distortion;
    
    gl_Position = projectionMatrix * modelViewMatrix * vec4(pos, 1.0);
  }
`;

// Fragment Shader for lighting and color calculation
const fragmentShader = `
  varying vec2 vUv;
  varying vec3 vNormal;
  uniform float uProgress;
  
  void main() {
    vec3 colorA = vec3(0.05, 0.05, 0.1);
    vec3 colorB = vec3(0.2, 0.5, 1.0);
    vec3 finalColor = mix(colorA, colorB, vNormal.z + uProgress);
    gl_FragColor = vec4(finalColor, 1.0);
  }
`;

When the React component mounts, I build the canvas imperatively inside a useEffect. References to shader uniforms remain stored in memory. GSAP directly mutates the properties of these objects via JavaScript pointers, completely bypassing the React lifecycle.

export function WebGLCanvas() {
  const containerRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    if (!containerRef.current) return;

    const scene = new THREE.Scene();
    const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
    const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
    
    renderer.setSize(window.innerWidth, window.innerHeight);
    renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
    containerRef.current.appendChild(renderer.domElement);

    const uniforms = {
      uTime: { value: 0 },
      uProgress: { value: 0 },
    };

    const material = new THREE.ShaderMaterial({
      vertexShader,
      fragmentShader,
      uniforms,
    });

    const geometry = new THREE.IcosahedronGeometry(2, 64);
    const mesh = new THREE.Mesh(geometry, material);
    scene.add(mesh);
    camera.position.z = 5;

    // Connect GSAP ScrollTrigger directly to shader uniforms
    const trigger = ScrollTrigger.create({
      trigger: containerRef.current,
      start: 'top top',
      end: 'bottom bottom',
      scrub: 0.5,
      onUpdate: (self) => {
        // Direct assignment to shader memory
        uniforms.uProgress.value = self.progress;
      },
    });

    // Render loop bound to central ticker
    const updateRender = (time: number) => {
      uniforms.uTime.value = time;
      renderer.render(scene, camera);
    };

    gsap.ticker.add(updateRender);

    return () => {
      gsap.ticker.remove(updateRender);
      trigger.kill();
      geometry.dispose();
      material.dispose();
      renderer.dispose();
    };
  }, []);

  return <div ref={containerRef} className="fixed inset-0 pointer-events-none" />;
}

Rigorous WebGL Resource Cleanup and Lifecycle in Next.js

A critical aspect of working with WebGL inside client-side rendering frameworks like Next.js or React is garbage collection. Browsers do not automatically free geometry buffers, textures, and GPU rendering contexts when a React component unmounts from the DOM. If the user navigates between routes and the Three.js component is destroyed without explicit cleanup, memory retained on the graphics card builds up until the tab crashes.

In the useEffect cleanup function, I implemented manual disposal for every computational resource:

  1. Removing the GSAP ticker listener: Breaks the update reference to prevent invocations on a non-existent context.
  2. Calling .dispose() on geometries and materials: Informs the WebGL API to free allocated data structures in VRAM on the graphics card.
  3. Explicitly removing the canvas from the DOM tree and destroying ScrollTrigger instances via trigger.kill().

Without this explicit procedure, every fast reload in development mode (Hot Module Replacement) doubles memory consumption on the GPU.

Lessons Learned and Architectural Trade-offs

Taking imperative control of Three.js and WebGL within a React-structured project requires sacrificing the convenience of certain abstractions, but the gain in technical control and performance stability is well worth the trade-off. The key takeaways from this implementation are:

UI state and high-frequency visual pipelines must run on separate tracks. Mixing React state with requestAnimationFrame loops is a direct recipe for ruining the user experience.

  • Loop unification: Performance isn't solely dependent on how lightweight your shaders are, but on ensuring there is a single conductor for time events. Using GSAP's ticker to drive Lenis's calculation cycle and canvas updates eliminates dropped frame conflicts.
  • Mutations outside the reconciler: Treating shader parameters as high-level mutable properties via direct references keeps React unaware of frame-by-frame changes, reserving the framework engine for what it truly excels at: managing site structure and component trees.
  • Hardware adaptation: Interpolation through GSAP's scrub property combined with Lenis smooth scrolling absorbs user device hardware variations without losing sync between physical content positions on screen and geometric light node deformations on canvas.