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:
- A red dot grid (2D canvas) that slides horizontally as you scroll.
- 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. - A parabolically warped Three.js plane carrying a
CanvasTexturefilmstrip 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.letterelements 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;. Theirh1: 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 canvaswidth/heighttoinnerWidth*dpr / innerHeight*dpr, CSS size toinnerWidth/innerHeightpx, thenctx.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:
lettersSceneandcardsScene. - Two
PerspectiveCamera(50, innerWidth/innerHeight, 0.1, 1000), both atposition.z = 20. - Two
WebGLRenderer({ antialias: true, alpha: true }), bothsetSize(innerWidth, innerHeight)andsetClearColor(0x000000, 0)(transparent). Letters renderer:setPixelRatio(devicePixelRatio). Cards renderer:setPixelRatio(Math.min(devicePixelRatio, 2)). Give their DOM elements the idsletters-canvasandcards-canvasand 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 byprogress × 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 (theyPos: 10path 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) * twith factor 0.07 — the letters trail their targets with heavy inertia, so fast scrolls smear them elegantly along the curves.- The
> innerWidth * 0.7snap 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(), renderlettersScene, rendercardsScene,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.CanvasTexturewith the same mipmap/filter/anisotropy settings pluswrapS = wrapT = THREE.RepeatWrapping. SetneedsUpdate = truein the ScrollTriggeronUpdateafter 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 currentScrollTrigger.getAll()[0]?.progress || 0; update both cameras'aspect+updateProjectionMatrix();setSizeboth 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.jpg … img7.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'sscrub: 1adds 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.