All components

OrkenWorld Scroll Animation

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

Open live demo ↗ Raw prompt (.md)

What it does

A pinned section where scrolling drags a horizontal row of product cards across the viewport while a full-screen canvas grid of triangles progressively fills with orange color. A single ScrollTrigger (pin + scrub over 5x viewport height, Lenis-smoothed) maps scroll progress via onUpdate to translate the cards on X and to reveal randomly-ordered triangles that ease-fill on two stacked canvases.

How it's built

Categoryscroll
Techgsap, lenis
GSAP pluginsScrollTrigger
Complexitypage
Performance costmedium
Mobile-safeyes
Scrollhijacks scrolling

scroll canvas pin scrub horizontal-scroll triangle-grid lenis gsap cinematic

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

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 → cardsfill-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 their h1 is text-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; } (title h1 at top, product-code p at bottom).
  • Responsive: @media (max-width: 900px) { .card { width: 25%; } }.
Palette
  • #000 black (hero/outro/card backgrounds), #fff white text.
  • #ff6b00 — the signature orange (heading spans 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 and fillStyle = "#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 orderorder 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: .cards translates on X from 0 to -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: canvasXPosition slides the triangle grid left from 0 to −200px (subtle parallax drift of the grid).
  • 65% → 100% progress: animationProgress ramps 0→1, so triangles cross their random order thresholds 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:

  1. 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 .sticky section 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 of self.progress plus the settling lerp.
  • The triangle grid is a <canvas> 2D render, not DOM elements — keep it that way for performance. perfCost is 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-motion handling in the original; the effect is desktop-first but functions on mobile (cards widen to 25% under 900px).