Pinned Horizontal Card Scroll with Triangle-Grid Fill Reveal
Goal
Build a full-page cinematic scroll sequence. A single section pins for 5 viewport heights while Lenis-smoothed scrolling drives two things at once: (1) a horizontal row of three tall product cards slides left across the screen (across the first ~65% of the scroll), and (2) a full-screen canvas grid of interlocking triangles — barely-visible white outlines — that, over the last ~35% of the scroll, fills in with bright orange one triangle at a time in random order, each triangle easing up from scale 0→1, until the orange grid blankets the whole viewport. A dark background image sits behind everything; a hero panel precedes the pinned section and an outro panel follows it.
Tech
Vanilla HTML/CSS/JS with ES module imports. Use gsap (npm) plus the GSAP plugin ScrollTrigger, and lenis for smooth scrolling. The reveal grid is drawn with the raw Canvas 2D API (no extra plugin — no PixiJS/Three.js). Everything runs inside a DOMContentLoaded listener.
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import Lenis from "lenis";
gsap.registerPlugin(ScrollTrigger);
Lenis wiring (standard GSAP integration):
const lenis = new Lenis();
lenis.on("scroll", ScrollTrigger.update);
gsap.ticker.add((time) => { lenis.raf(time * 1000); });
gsap.ticker.lagSmoothing(0);
Layout / HTML
Class names are load-bearing — the JS/CSS query them. One .container wrapping three sections:
<div class="container">
<section class="hero">
<h1><span>Enter a Universe</span> Powered by Imagination</h1>
</section>
<section class="sticky">
<div class="bg-img"><img src="bg.jpg" alt="" /></div>
<canvas class="outline-layer"></canvas>
<div class="cards">
<div class="card">
<div class="card-img"><img src="card-1.jpg" alt="" /></div>
<div class="card-title"><h1>Silent Veil</h1><p>PROD8372</p></div>
</div>
<div class="card">
<div class="card-img"><img src="card-2.jpg" alt="" /></div>
<div class="card-title"><h1>Crimson Echoes</h1><p>PROD4921</p></div>
</div>
<div class="card">
<div class="card-img"><img src="card-3.jpg" alt="" /></div>
<div class="card-title"><h1>Zenith Arc</h1><p>PROD7586</p></div>
</div>
</div>
<canvas class="fill-layer"></canvas>
</section>
<section class="outro">
<h1>Chase the <span>shadows</span> to embrace the light</h1>
</section>
</div>
<script type="module" src="./script.js"></script>
Note the z-order inside .sticky is defined by CSS z-index, not DOM order: background image (bottom) → outline-layer canvas → cards → fill-layer canvas (top). So the orange fill ends up painted over the cards.
The copy above is placeholder demo text — keep it generic; do not add any real brand names.
Styling
- Universal reset:
* { margin: 0; padding: 0; box-sizing: border-box; }. - Body must be tall enough to scroll through the whole pinned range:
html, body { width: 100%; height: 800vh; font-family: "PP Neue Montreal"; }(a neutral grotesque sans; there is no@font-face, so it simply falls back to the default sans — that's fine). img { width: 100%; height: 100%; object-fit: cover; }.h1:text-transform: uppercase; font-family: "FK Screamer"; font-weight: lighter; font-size: 64px; line-height: 1;(a tall condensed/heavy display face; system fallback is acceptable).h1 span { color: #ff6b00; }— the orange accent inside headings.p { font-size: 14px; font-weight: 500; }.section { position: relative; width: 100vw; height: 100vh; overflow: hidden; }..hero, .outro:display: flex; justify-content: center; align-items: center; background-color: #000; color: #fff;and theirh1istext-align: center; font-size: 80px;..bg-img:position: absolute; top: 0; left: 0; width: 100%; height: 100%;(full-bleed behind everything).- Canvas sizing is deliberately oversized — this matters for the look:
``css canvas { position: absolute; top: 0; left: 0; width: 150% !important; height: 150% !important; } canvas.outline-layer { z-index: 1; } canvas.fill-layer { z-index: 3; } ` The !important` 150% overrides the inline pixel sizes the JS sets, so both canvases are stretched to 1.5× the viewport (the triangle grid overflows the edges — intended).
.cards:position: absolute; top: 0; left: 0; width: 300%; height: 100vh; display: flex; justify-content: space-around; align-items: center; will-change: transform; z-index: 2;— the row is three viewport-widths wide, holding three cards spread across it..card:position: relative; width: 10%; height: 75%; background-color: black; display: flex; flex-direction: column; gap: 1em; padding: 1.5em;(each card is 10% of the 300% row = ~30vw, and 75vh tall)..card-img, .card-title { flex: 1; overflow: hidden; }..card-title { color: #fff; display: flex; flex-direction: column; justify-content: space-between; }(titleh1at top, product-codepat bottom).- Responsive:
@media (max-width: 900px) { .card { width: 25%; } }.
Palette
#000black (hero/outro/card backgrounds),#fffwhite text.#ff6b00— the signature orange (headingspans and the triangle fill).rgba(255,255,255,0.075)— the near-invisible white triangle outline in the resting grid.
GSAP effect (be exact)
There is one ScrollTrigger plus a hand-rolled canvas render loop. No SplitText, no CustomEase. All motion comes from onUpdate + gsap.set and a requestAnimationFrame lerp inside the canvas draw function.
Canvas setup
Grab both canvases and their 2D contexts. Size each for crisp rendering on HiDPI:
function setCanvasSize(canvas, ctx) {
const dpr = window.devicePixelRatio || 1;
canvas.width = window.innerWidth * dpr;
canvas.height = window.innerHeight * dpr;
canvas.style.width = `${window.innerWidth}px`;
canvas.style.height = `${window.innerHeight}px`;
ctx.scale(dpr, dpr);
}
(The inline style widths are then overridden by the 150% !important CSS — the drawing coordinate system stays in CSS-pixel space of innerWidth × innerHeight.)
Constants: triangleSize = 150, lineWidth = 1, SCALE_THRESHOLD = 0.01. State: const triangleStates = new Map();, let animationFrameId = null;, let canvasXPosition = 0;.
Triangle geometry
drawTriangle(ctx, x, y, fillScale = 0, flipped = false) with halfSize = triangleSize / 2 (75):
- Non-flipped triangle (points up):
moveTo(x, y - halfSize),lineTo(x + halfSize, y + halfSize),lineTo(x - halfSize, y + halfSize), close. - Flipped triangle (points down):
moveTo(x, y + halfSize),lineTo(x + halfSize, y - halfSize),lineTo(x - halfSize, y - halfSize), close. - If
fillScale < SCALE_THRESHOLD→ draw only the outline:strokeStyle = "rgba(255,255,255,0.075)",lineWidth = 1,stroke(). - If
fillScale >= SCALE_THRESHOLD→ draw the filled triangle, scaled about its own center:ctx.save(); ctx.translate(x, y); ctx.scale(fillScale, fillScale); ctx.translate(-x, -y);then trace the same path andfillStyle = "#ff6b00",strokeStyle = "#ff6b00",lineWidth = 1,stroke(),fill(),ctx.restore().
Grid initialization
function initializeTriangles() {
const cols = Math.ceil(window.innerWidth / (triangleSize * 0.5)); // ceil(w / 75)
const rows = Math.ceil(window.innerHeight / (triangleSize * 0.5)); // ceil(h / 75)
const totalTriangles = rows * cols;
// build every {row, col, key:`${r}-${c}`}, then Fisher–Yates shuffle the array,
// then assign each an "order" by its POST-SHUFFLE index:
positions.forEach((pos, index) => {
triangleStates.set(pos.key, { order: index / totalTriangles, scale: 0, row: pos.row, col: pos.col });
});
}
The shuffle is what makes the fill appear in random order — order is a 0→1 threshold; a triangle turns on when the animation progress passes its order value.
Placement for a triangle at {row, col} (used in every draw): x = col * (triangleSize * 0.5) + triangleSize / 2 + canvasXPosition (i.e. col*75 + 75 + canvasXPosition), y = row * triangleSize + triangleSize / 2 (i.e. row*150 + 75), flipped = (row + col) % 2 !== 0. Columns are spaced 75px (half a triangle) so up/down triangles interlock into continuous horizontal strips; rows are spaced 150px.
The draw loop (lerped fill)
function drawGrid(scrollProgress = 0) {
if (animationFrameId) cancelAnimationFrame(animationFrameId);
outlineCtx.clearRect(0, 0, outlineCanvas.width, outlineCanvas.height);
fillCtx.clearRect(0, 0, fillCanvas.width, fillCanvas.height);
// fill only starts after 65% of the scroll, then ramps 0→1 over the last 35%:
const animationProgress = scrollProgress <= 0.65 ? 0 : (scrollProgress - 0.65) / 0.35;
const animationSpeed = 0.15; // lerp factor
let needsUpdate = false;
// PASS 1 — outlines on outline-layer (any triangle not yet fully filled):
triangleStates.forEach((state) => {
if (state.scale < 1) {
/* compute x,y,flipped as above */ drawTriangle(outlineCtx, x, y, 0, flipped);
}
});
// PASS 2 — fills on fill-layer, each easing toward its target:
triangleStates.forEach((state) => {
const shouldBeVisible = state.order <= animationProgress;
const targetScale = shouldBeVisible ? 1 : 0;
const newScale = state.scale + (targetScale - state.scale) * animationSpeed; // lerp
if (Math.abs(newScale - state.scale) > 0.001) { state.scale = newScale; needsUpdate = true; }
if (state.scale >= SCALE_THRESHOLD) {
/* compute x,y,flipped */ drawTriangle(fillCtx, x, y, state.scale, flipped);
}
});
if (needsUpdate) animationFrameId = requestAnimationFrame(() => drawGrid(scrollProgress));
}
Key points: the two canvases are separate layers — outlines live on outline-layer (z-index 1, *behind* the cards) and orange fills on fill-layer (z-index 3, *over* the cards). Because newScale lerps toward its target with factor 0.15, each triangle "pops" in with an ease-out feel; the requestAnimationFrame self-reschedules until every triangle has settled, so the fill keeps animating smoothly even between discrete scroll events.
On init: initializeTriangles(); drawGrid();.
The single ScrollTrigger
const stickyHeight = window.innerHeight * 5;
ScrollTrigger.create({
trigger: ".sticky",
start: "top top",
end: `+=${stickyHeight}px`, // pinned for 5 viewport heights
pin: true, // default pinSpacing: true
onUpdate: (self) => {
canvasXPosition = -self.progress * 200; // grid drifts left up to 200px
drawGrid(self.progress); // repaint at current progress
const cards = document.querySelector(".cards");
const progress = Math.min(self.progress / 0.654, 1); // remap: cards finish at 65.4% scroll
gsap.set(cards, { x: -progress * window.innerWidth * 2 }); // slide left by 2 viewport widths
},
});
So over the pinned range:
- 0 → ~65.4% progress:
.cardstranslates on X from0to-2 * innerWidth. Since the row is 300% wide, moving it left by two viewport-widths pulls the second and third cards across the screen (horizontal-scroll feel). Past 65.4%, the remap clamps to 1 so the cards hold still. - The whole range:
canvasXPositionslides the triangle grid left from 0 to −200px (subtle parallax drift of the grid). - 65% → 100% progress:
animationProgressramps 0→1, so triangles cross their randomorderthresholds and the orange fill blooms across the viewport, ending fully orange (covering the cards) at the outro handoff.
Resize
window.addEventListener("resize", () => {
setCanvasSize(outlineCanvas, outlineCtx);
setCanvasSize(fillCanvas, fillCtx);
triangleStates.clear();
initializeTriangles();
drawGrid();
});
Assets / images
Four images total:
bg.jpg— one dark, moody, atmospheric full-bleed background (landscape ~16:9). Sits behind the canvases and cards; sets the cinematic tone.
2–4. card-1.jpg, card-2.jpg, card-3.jpg — three stylized product/editorial artwork images, portrait-oriented (each is cropped by object-fit: cover into a tall card cell roughly 2:3). Distinct dark/dramatic subjects work best. Place them in the three cards in order (titles "Silent Veil", "Crimson Echoes", "Zenith Arc").
Behavior notes
- Page-level component: Lenis takes over scrolling for the whole document; the
.stickysection pins for 5× viewport height, bookended by full-screen hero and outro panels. - Everything is scrub-driven through the single ScrollTrigger's
onUpdate(no tweens on the cards, no timeline). Scrolling back up reverses both the card slide and the triangle fill, because the render is a pure function ofself.progressplus the settling lerp. - The triangle grid is a
<canvas>2D render, not DOM elements — keep it that way for performance.perfCostis medium. - Two stacked canvases are essential: outlines must sit under the cards (z-index 1) and the orange fill over them (z-index 3), so the reveal reads as the scene being consumed by the triangle grid.
- No
prefers-reduced-motionhandling in the original; the effect is desktop-first but functions on mobile (cards widen to 25% under 900px).