All components

Wodniack Work Section Scroll

GSAP animation component · Published 2026-07-27 · by vanguardia.dev

Open live demo ↗ Raw prompt (.md)

What it does

A pinned scroll section where the word WORK, built from HTML letter divs, streams along four Three.js CatmullRom curves projected to screen space while a warped WebGL plane slides a filmstrip of project images across a scrolling red dot grid. A single ScrollTrigger (pin, scrub, end +=700%) drives the progress that feeds the letter paths, the CanvasTexture card offset and the grid, with Lenis smoothing the scroll and per-frame lerping easing the letters into place.

How it's built

Categoryscroll
Techgsap, lenis, three
GSAP pluginsScrollTrigger
Complexitypage
Performance costheavy
Mobile-safedesktop-first
Scrollhijacks scrolling

scroll webgl three pin scrolltrigger lenis kinetic-typography editorial curved-plane

Rebuild it with AI

To reproduce this animation in your own project, copy the prompt below into Claude Code, Cursor or any AI coding agent. The prompt is validated — it describes the exact structure, timing and easing, so the agent rebuilds the effect faithfully and you can then adapt colors, copy and layout to your design.

The full prompt

Work Section Scroll — Curved Letter Streams + WebGL Filmstrip

Goal

Build a full-viewport, pinned "WORK" section wedged between a red intro and a red outro. While the section is pinned for 700% of the viewport height, three layered effects run in sync, all driven by one ScrollTrigger progress value:

  1. A red dot grid (2D canvas) that slides horizontally as you scroll.
  2. Sixty HTML letter <div>s — fifteen each of W, O, R, K — that stream along four Three.js CatmullRom curves, projected from 3D world space to screen pixels every scroll tick, with a per-frame lerp (0.07) gliding each letter toward its target and a snap rule that hides the wrap-around jump.
  3. A parabolically warped Three.js plane carrying a CanvasTexture filmstrip of 7 project images that slides across the screen from right to left over the full scroll.

Lenis smooths the scroll; a single ScrollTrigger (pin + scrub: 1) feeds its progress to all three layers in onUpdate.

Tech

Vanilla HTML/CSS/JS with ES module imports. Use gsap (npm) with the ScrollTrigger plugin, lenis for smooth scrolling, and three for the WebGL layers:

import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import Lenis from "lenis";
import * as THREE from "three";

gsap.registerPlugin(ScrollTrigger);

Everything runs inside a DOMContentLoaded handler declared async (the image textures are awaited before the plane is built).

Layout / HTML

Three stacked full-viewport sections; the middle one is the stage. All canvases and letters are created in JS:

<body>
  <section class="intro"><h1>( Intro )</h1></section>
  <section class="work">
    <div class="text-container"></div>
  </section>
  <section class="outro"><h1>( Outro )</h1></section>
  <script type="module" src="./script.js"></script>
</body>

JS appends into .work, in this order:

  • a 2D <canvas id="grid-canvas"> (the dot grid),
  • the letters WebGL renderer canvas (id="letters-canvas"),
  • the cards WebGL renderer canvas (id="cards-canvas"),
  • and 60 div.letter elements go inside .text-container.

Styling

Global reset * { box-sizing:border-box; margin:0; padding:0; }.

  • body: background-color: #f40c3f; overflow-x: hidden; — the signature saturated red.
  • section: width:100vw; height:100vh; position:relative;
  • .intro, .outro: flex, centered both axes, background-color:#f40c3f; color:#000;. Their h1: a display sans-serif, font-size:5vw; font-weight:lighter; text-transform:uppercase;. .outro { top:-0.125em; } (tucks it under the pinned section, hiding a hairline seam).
  • .work: position:relative; background-color:#000; overflow:hidden;
  • All canvases: position:absolute; top:0; left:0;. Stacking order matters: #grid-canvas { z-index:0; }, #letters-canvas { z-index:1; }, .text-container { z-index:2; }, #cards-canvas { z-index:10; } — the filmstrip plane renders on top of the letters, the dot grid behind everything.
  • .text-container: width:100%; height:100%; position:absolute; top:0; left:0; z-index:2; pointer-events:none; perspective:2500px; perspective-origin:center;
  • .letter: position:absolute; font-family: a heavy sans-serif; font-size:14rem; font-weight:bold; color:#f40c3f; text-shadow:1px 1px 2px rgba(0,0,0,0.1); opacity:1; z-index:2; transform-origin:center; transform-style:preserve-3d; will-change:transform;

No webfont files are required — a bold system sans-serif is fine; the look is defined by the giant 14rem red glyphs on black.

GSAP effect (be exhaustive)

1. Lenis + GSAP ticker wiring
const lenis = new Lenis();
lenis.on("scroll", ScrollTrigger.update);
gsap.ticker.add((time) => lenis.raf(time * 1000));
gsap.ticker.lagSmoothing(0);
2. The single ScrollTrigger (drives everything)
ScrollTrigger.create({
  trigger: ".work",
  start: "top top",
  end: "+=700%",
  pin: true,
  pinSpacing: true,
  scrub: 1,
  onUpdate: (self) => {
    updateTargetPositions(self.progress); // letters
    drawCardsOnCanvas(self.progress);     // filmstrip texture
    drawGrid(self.progress);              // dot grid
    cardsTexture.needsUpdate = true;      // re-upload the canvas texture
  },
});

No timelines, no tweened properties — the scrub-smoothed progress (0→1 across 7 viewport heights) is piped straight into three imperative draw/update functions. scrub: 1 gives ~1 s of catch-up smoothing on top of Lenis.

3. Dot grid canvas (z-index 0)
  • Sized to the viewport × devicePixelRatio (set canvas width/height to innerWidth*dpr / innerHeight*dpr, CSS size to innerWidth/innerHeight px, then ctx.scale(dpr, dpr)).
  • drawGrid(scrollProgress):
  • fill the whole canvas black, then fillStyle = "#f40c3f";
  • dotSize = 0.75 (arc radius, px), spacing = 20;
  • rows = Math.ceil(canvas.height / spacing), cols = Math.ceil(canvas.width / spacing) + 15 (extra columns so the wrap never shows);
  • offset = (scrollProgress * spacing * 10) % spacing;
  • draw a filled circle at (x * spacing - offset, y * spacing) for every cell.

The grid marches left as you scroll, completing 10 wrap cycles over the full pin — a subtle conveyor-belt undercurrent.

4. Three.js setup (two scenes, two renderers)
  • Two scenes: lettersScene and cardsScene.
  • Two PerspectiveCamera(50, innerWidth/innerHeight, 0.1, 1000), both at position.z = 20.
  • Two WebGLRenderer({ antialias: true, alpha: true }), both setSize(innerWidth, innerHeight) and setClearColor(0x000000, 0) (transparent). Letters renderer: setPixelRatio(devicePixelRatio). Cards renderer: setPixelRatio(Math.min(devicePixelRatio, 2)). Give their DOM elements the ids letters-canvas and cards-canvas and append both to .work.
5. The four letter paths (CatmullRom curves)
const createTextAnimationPath = (yPos, amplitude) => {
  const points = [];
  for (let i = 0; i <= 20; i++) {
    const t = i / 20;
    points.push(new THREE.Vector3(
      -25 + 50 * t,                                   // x: -25 → +25
      yPos + Math.sin(t * Math.PI) * -amplitude,      // y: bowed by a half sine
      (1 - Math.pow(Math.abs(t - 0.5) * 2, 2)) * -5   // z: 0 at ends, -5 in the middle
    ));
  }
  const curve = new THREE.CatmullRomCurve3(points);
  const line = new THREE.Line(
    new THREE.BufferGeometry().setFromPoints(curve.getPoints(100)),
    new THREE.LineBasicMaterial({ color: 0x000, linewidth: 1 })
  );
  line.curve = curve;
  return line;
};

Four paths, added to lettersScene (the black lines are invisible on the black background — they're just carriers for the curves):

const paths = [
  createTextAnimationPath(10, 2),
  createTextAnimationPath(3.5, 1),
  createTextAnimationPath(-3.5, -1),
  createTextAnimationPath(-10, -2),
];

Each row spans the full width, bows vertically (outer rows twice as much as inner rows, opposite directions), and dips away from the camera (z −5) at the center — that recession is what the screen projection turns into the curved, perspective-squeezed letter flow.

6. The 60 letter divs

For each path i (0–3), create 15 div.letter elements whose textContent is ["W","O","R","K"][i] — so row 0 is all W's, row 1 all O's, row 2 all R's, row 3 all K's. Append them to .text-container and register each in a Map with { current: {x:0,y:0}, target: {x:0,y:0} }.

7. Letter target projection (scroll-driven)
const lineSpeedMultipliers = [0.8, 1, 0.7, 0.9];
const updateTargetPositions = (scrollProgress = 0) => {
  paths.forEach((line, lineIndex) => {
    line.letterElements.forEach((element, i) => {
      const point = line.curve.getPoint(
        (i / 14 + scrollProgress * lineSpeedMultipliers[lineIndex]) % 1
      );
      const vector = point.clone().project(lettersCamera);
      const positions = letterPositions.get(element);
      positions.target = {
        x: (-vector.x * 0.5 + 0.5) * window.innerWidth,
        y: (-vector.y * 0.5 + 0.5) * window.innerHeight,
      };
    });
  });
};

Details that matter:

  • Letters are spread along the curve at i / 14 (0 → 1 across the 15 letters), then offset by progress × multiplier, wrapped % 1 — an endless conveyor along the curve.
  • Each row has a different speed multiplier (0.8, 1, 0.7, 0.9), so the rows drift out of phase as you scroll.
  • The projected NDC vector is mapped with negated x and y (-vector.x, -vector.y) — this mirrors the path both ways, making letters travel right → left and flipping the rows vertically (the yPos: 10 path renders near the bottom).
8. Per-frame letter easing (rAF loop)
const updateLetterPositions = () => {
  letterPositions.forEach((positions, element) => {
    const distX = positions.target.x - positions.current.x;
    if (Math.abs(distX) > window.innerWidth * 0.7) {
      // wrapped around the curve — snap, don't streak across the screen
      positions.current.x = positions.target.x;
      positions.current.y = positions.target.y;
    } else {
      positions.current.x = lerp(positions.current.x, positions.target.x, 0.07);
      positions.current.y = lerp(positions.current.y, positions.target.y, 0.07);
    }
    element.style.transform =
      `translate(-50%, -50%) translate3d(${positions.current.x}px, ${positions.current.y}px, 0px)`;
  });
};
  • lerp(start, end, t) = start + (end - start) * t with factor 0.07 — the letters trail their targets with heavy inertia, so fast scrolls smear them elegantly along the curves.
  • The > innerWidth * 0.7 snap is essential: when a letter's curve parameter wraps % 1, its target jumps from one screen edge to the other; snapping prevents a visible dash across the viewport.
  • The rAF loop runs continuously (independent of scroll): updateLetterPositions(), render lettersScene, render cardsScene, requestAnimationFrame(animate).
9. Filmstrip texture (2D canvas → CanvasTexture)
  • Load 7 image textures with THREE.TextureLoader (await all). On each: generateMipmaps: true, minFilter: LinearMipmapLinearFilter, magFilter: LinearFilter, anisotropy: renderer.capabilities.getMaxAnisotropy().
  • Create an offscreen canvas 4096×2048.
  • drawCardsOnCanvas(offset = 0):
ctx.clearRect(0, 0, 4096, 2048);
const cardWidth = 4096 / 3;        // ≈1365
const cardHeight = 2048 / 2;       // 1024 → 4:3 landscape cards
const spacing = 4096 / 2.5;        // ≈1638 (cards slightly overlap-spaced)
images.forEach((img, i) => {
  ctx.drawImage(img.image,
    i * spacing + (0.35 - offset) * 4096 * 5 - cardWidth,  // slides 5 canvas-widths right→left
    (2048 - cardHeight) / 2,
    cardWidth, cardHeight);
});

At offset = 0 the strip waits off-screen right; over the full scroll it travels 5 × 4096 = 20480 px left, so the 7 cards parade across mid-scroll.

  • Wrap the canvas in a THREE.CanvasTexture with the same mipmap/filter/anisotropy settings plus wrapS = wrapT = THREE.RepeatWrapping. Set needsUpdate = true in the ScrollTrigger onUpdate after redrawing.
10. The warped plane
const cardsPlane = new THREE.Mesh(
  new THREE.PlaneGeometry(30, 15, 50, 1),
  new THREE.MeshBasicMaterial({
    map: cardsTexture, side: THREE.DoubleSide,
    transparent: true, opacity: 1,
    depthTest: false, depthWrite: false,
  })
);
// parabolic bend: edges bulge toward the camera
const positions = cardsPlane.geometry.attributes.position;
for (let i = 0; i < positions.count; i++) {
  positions.setZ(i, Math.pow(positions.getX(i) / 15, 2) * 5);
}
positions.needsUpdate = true;

A 30×15 plane (50 width segments) whose z follows (x/15)² × 5 — flat at center, +5 toward the camera at the edges — so the filmstrip appears to ride the inside of a gentle cylinder, wider than the viewport, floating over the letters (its canvas has z-index 10, and transparent clear color lets the grid and letters show through the empty texture areas).

11. Init + resize
  • On load: drawGrid(0), start the rAF loop, updateTargetPositions(0) (letters assemble into their resting curve immediately, easing in via the lerp).
  • On resize: resize/rescale the grid canvas and redraw at the current ScrollTrigger.getAll()[0]?.progress || 0; update both cameras' aspect + updateProjectionMatrix(); setSize both renderers; re-clamp the cards renderer pixel ratio; recompute letter targets at the current progress.

Assets / images

7 project images, drawn into 4:3 landscape card slots (≈1365×1024 on the texture canvas). One shared role: interchangeable project/portfolio cards in the sliding filmstrip. They read as a cohesive futuristic-tech editorial set — e.g. an astronaut walking a bridge past glassy skyscrapers, a macro face wearing AR glasses with a HUD, a dark 3D render of mesh-wrapped concrete cylinders, an isometric empty cardboard box on a beige surface, a person in a VR headset beside a floating call UI, black AR sunglasses on a dark gradient, and a glowing cylindrical dwelling in a desert at dusk. Moody, saturated, high-production sci-fi/product photography works best against the black stage and red accents. Name them sequentially (img1.jpgimg7.jpg). If fewer are available, repeat in order.

Behavior notes

  • The page has real scroll height: intro (100vh) + pinned work section (held for 700% via pinSpacing: true) + outro (100vh). Lenis smooths the native scroll; ScrollTrigger's scrub: 1 adds another layer of lag, and the letters add a third (lerp 0.07) — this triple smoothing is the signature feel.
  • Scrolling back up reverses everything perfectly (all three layers are pure functions of progress).
  • The letters keep easing after the scroll stops (rAF loop never pauses).
  • Desktop-oriented: 14rem letters and fixed world-space sizes; no breakpoints, no reduced-motion handling in the original.
  • WebGL required (two renderer contexts); heavy effect — don't add more pixel ratio than the caps above.