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.

Images

This component ships with 7 reference assets, served publicly. Use them as-is to reproduce the demo faithfully, then swap in your own — the layout expects the same aspect ratios.

https://motionprompts.dev/c/wodniack-work-section-scroll/img1.jpg
https://motionprompts.dev/c/wodniack-work-section-scroll/img2.jpg
https://motionprompts.dev/c/wodniack-work-section-scroll/img3.jpg
https://motionprompts.dev/c/wodniack-work-section-scroll/img4.jpg
https://motionprompts.dev/c/wodniack-work-section-scroll/img5.jpg
https://motionprompts.dev/c/wodniack-work-section-scroll/img6.jpg
… 1 more under https://motionprompts.dev/c/wodniack-work-section-scroll/

They are hotlinkable for prototyping. For anything you ship, replace them: they are licensed for demonstration of this component, not for redistribution.

Using this outside its demo page

This component is written as a complete page — that is how the demo is meant to look. If you are dropping it into an existing project, or combining it with other components, these are the things it declares at document level and that you need to move or reconcile first.

  • Palette on :root--riot-red, --riot-ink, --riot-bone, --riot-bone-dim, --font. These names are not namespaced and they collide: --ink is defined by 164 of the 219 components in this catalogue, --paper by 94, --muted by 80, each with different values — and they will also collide with whatever your own project defines. Move them onto the component's wrapper (.my-section { --ink: … }) or rename them with a prefix.
  • **Rules on *, body** — the demo owns the whole document, so these set the page background, typography and resets. Dropped into an existing project they restyle the entire page, not just this section. Re-target them at the component's wrapper before using it.
  • Smooth scroll (Lenis) — this creates its own Lenis instance, and a page may only have one. If your project already runs Lenis, drop the setup shown above and reuse the existing instance, keeping the lenis.on("scroll", ScrollTrigger.update) wiring once. Two instances fight over the same scroll and stutter visibly, with no error in the console.

Adapting this to React

Everything above describes a standalone document: one script that waits for DOMContentLoaded, builds two independent WebGL scenes and sixty letter <div>s with document.createElement, and drives all three visual layers — the dot grid, the letter streams, the filmstrip plane — off a single ScrollTrigger. React withdraws the guarantees this relies on, and it does so quietly: the section renders, the red dots and the giant letters look right for a moment, and then a route change away and back leaves two Lenis instances fighting over the same wheel event, or two rAF loops rendering into canvases neither one still controls. Under React 19 with StrictMode, every effect mounts, unmounts, and mounts again before anything reaches the screen, and this component has an unusually large amount of state — a Three.js scene per WebGL layer, a Map of sixty DOM-node targets, a 4096×2048 offscreen canvas — for that double mount to duplicate.

*(1) The entry point* — the script wraps its body in document.addEventListener("DOMContentLoaded", async () => { ... }). By the time a React component mounts, DOMContentLoaded has already fired, so on its own the listener would simply never run — the fix for that half is the usual one: delete the listener, move its body into a useEffect with an empty dependency array. What doesn't carry over unchanged is the async on that callback. Everything downstream of await Promise.all(images.map(loadImage)) — the cardsTexture, the cardsPlane, the ScrollTrigger.create call that is the only thing driving updateTargetPositions/drawCardsOnCanvas/drawGrid, the first drawGrid(0)/animate()/updateTargetPositions(0) calls, and the resize listener — sits behind that one await. So an async effect callback here is not just wrong in the abstract (it hands React a promise where a synchronous cleanup function belongs); the entire teardown surface this component needs literally does not exist until that promise settles, which means there is no version of "return a cleanup function" that works before it does. Split the effect: run everything that precedes the await — Lenis, the ticker wiring, both scenes, both cameras, both renderers, the grid canvas, the four curve paths, the sixty letter divs — synchronously in the effect body; fire the seven loadImage() calls without awaiting them; and build the second half inside their .then(), gated by a cancelled flag that the effect's own synchronous return flips to true.

*(2) Element lookups* — .work and .text-container are both resolved with document.querySelector, and every one of the sixty .letter divs, plus all three canvases, gets appended to whichever node those two calls happen to return. Give the component a root ref for .work, keep a second ref for .text-container scoped under it, and route the canvas/letter creation through those refs instead of the document. During the StrictMode remount two .work subtrees exist for an instant, and an unscoped querySelector is not guaranteed to resolve to the one that's staying.

*(3) Cleanup* — five kinds of live resource need their own teardown here, and one of them only starts existing after the async gate above resolves.

*Lenis and the ticker.* lenis.on("scroll", ScrollTrigger.update) and gsap.ticker.add((time) => lenis.raf(time * 1000)) both run before the await, so they exist for the life of the mount regardless of how the image loads go. The ticker subscription is not tracked by any gsap.context — revert or not, it keeps calling lenis.raf on whatever instance the closure still points to. Keep the exact arrow function reference passed to gsap.ticker.add and pass that same reference to gsap.ticker.remove in cleanup, then lenis.off("scroll", ScrollTrigger.update) and lenis.destroy(). Skipping the ticker removal is what bites hardest here: it keeps invoking lenis.raf on a destroyed instance every frame, for as long as the page stays open.

*The ScrollTrigger.* One ScrollTrigger.create({ trigger: ".work", start: "top top", end: "+=700%", pin: true, scrub: 1, onUpdate: ... }) is the only thing that ever calls updateTargetPositions, drawCardsOnCanvas and drawGrid. It can only be created after the images resolve, so wrap it in a gsap.context opened synchronously with an empty factory, and populate that context from inside the guarded .then() with the single-argument form of add. That call happens outside the factory's own synchronous pass, so capture the self the factory receives into a closure variable rather than reaching for ctx there — ctx isn't assigned until the factory returns, and referencing it early is exactly the trap this pattern exists to avoid:

useEffect(() => {
  let cancelled = false;
  let self;
  const ctx = gsap.context((context) => { self = context; }, rootRef);
  Promise.all([1, 2, 3, 4, 5, 6, 7].map(loadImage)).then((images) => {
    if (cancelled) return;
    self.add(() => {
      // build cardsTexture + cardsPlane from `images`, then ScrollTrigger.create({ trigger: rootRef.current, ... })
    });
  });
  return () => { cancelled = true; ctx.revert(); };
}, []);

Without the revert, a second .work pin stacks on the first, both scrubbing the same seven-viewport-height stretch and both writing into whichever grid/letters/cards canvases are still attached.

*The rAF loop.* animate() recurses on its own requestAnimationFrame return and is never cancelled in the original — harmless on a plain page, since the tab eventually closes. Keep the id the call returns and cancel it in cleanup. Miss this and a leftover animate() from a prior mount keeps calling lettersRenderer.render/cardsRenderer.render against a scene whose ScrollTrigger (and therefore whose updateTargetPositions) is long gone — the letters stop advancing but the render loop keeps spending a frame on them regardless.

*The texture promise itself.* loadImage's THREE.TextureLoader callback can fire after a StrictMode unmount — that is precisely the moment the .then() from point (1) runs. The cancelled check there is not incidental cleanup hygiene, it is the only thing standing between a resolved texture and building cardsTexture, appending cardsPlane to a cardsScene that belongs to an unmounted instance, or creating a ScrollTrigger against a .work ref that render already tore down.

*The WebGL contexts and the imperative DOM nodes.* lettersRenderer and cardsRenderer each own a real GPU context — call dispose() on both, plus cardsTexture.dispose() and the geometry/material on cardsPlane and each of the four curve paths, inside the same cleanup. And since the sixty .letter divs plus #grid-canvas, #letters-canvas and #cards-canvas are built with createElement/appendChild rather than declared as JSX, the better move is to stop creating them imperatively at all: render the sixty letters as regular mapped children with a ref callback per element, and let letterPositions become a plain array indexed by (lineIndex, i) instead of a Map keyed on DOM nodes that no longer exist after a remount.

*(4) Rendering this in @react-three/fiber* — three 0.185, @react-three/fiber 9, drei 10.7, React 19. The two WebGLRenderer/Scene/PerspectiveCamera triples here don't collapse into one <Canvas> — the stacking (#letters-canvas at z-index 1, #cards-canvas at z-index 10, both above the plain-2D #grid-canvas at z-index 0) is load-bearing, since the cards plane has to composite over the letters. Port it as two stacked <Canvas> elements, each absolutely positioned over the section and each with gl={{ alpha: true }} and a transparent clear color, keeping the original z-order; each keeps its own camera at the same field of view and z position the vanilla version sets.

There's no GLTFLoader.load(...) to replace with useGLTF — both scenes are entirely procedural. Build the four CatmullRomCurve3 lines (the sine-bowed, center-dipping point sets that feed createTextAnimationPath) and the warped cardsPlane geometry (its vertex Z pushed forward by the square of the normalized X, the parabola bend) each in a useMemo, the same way they're built exactly once in the vanilla version.

The rAF loop's two jobs split apart instead of folding into one useFrame. Rendering lettersScene/cardsScene is what each <Canvas>'s own internal loop already does — delete lettersRenderer.render(...), cardsRenderer.render(...) and the requestAnimationFrame(animate) recursion outright. But updateLetterPositions — the per-element lerp-toward-target-with-snap that moves the sixty letter divs — touches plain DOM nodes that live outside either canvas, not scene objects, so it has to run from a useFrame registered on a component inside one of the two <Canvas> trees and reach out through the ref array described in point (3), not through the scene graph. Because the letters keep drifting between scroll events — this interpolation is continuous, not re-armed only when onUpdate fires — leave both canvases on the default always-on frame loop; frameloop="demand" would starve updateLetterPositions of the frames it needs to keep the letters easing in after the user stops scrolling, which the vanilla version explicitly relies on.

Resize handling mostly disappears with <Canvas>: drop the manual camera.aspect/updateProjectionMatrix and renderer.setSize calls, and the pixel-ratio clamp on the cards renderer, since each <Canvas> already tracks its own container and exposes a dpr prop for the same cap. What survives resize is everything that isn't Three.js: #grid-canvas is a plain 2D context sized off window.innerWidth/innerHeight by hand, and updateTargetPositions needs recomputing at the current scroll progress — neither of those is <Canvas>'s job.

A poster is mandatory here, and it has to cover more than the model-load gap this rule usually addresses: nothing in this component paints anything at all — not the dot grid, not one letter, not a placeholder card — until every one of the seven loadImage promises has resolved, because drawGrid(0), animate() and updateTargetPositions(0) all sit on the far side of that same await. Render a static poster over the whole section (the red dot grid with the WORK wordmark centered reads close enough) and swap it out only once the guarded .then() above has actually run its first drawGrid/updateTargetPositions pass — not the instant the component mounts.

Skip drei's Environment regardless of preset: both materials here are unlit by construction (LineBasicMaterial for the invisible curve carriers, MeshBasicMaterial for the filmstrip plane), so there's no lighting for an environment map to feed. If a later variant lights the plane, point Environment at a self-hosted HDRI file, never a preset — presets resolve against a third-party CDN hard-coded into drei and leave the scene unlit the moment that host is unreachable.