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).

Images

This component ships with 4 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/orken-world-scroll-animation/bg.jpg
https://motionprompts.dev/c/orken-world-scroll-animation/card-1.jpg
https://motionprompts.dev/c/orken-world-scroll-animation/card-2.jpg
https://motionprompts.dev/c/orken-world-scroll-animation/card-3.jpg

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--ink, --paper, --muted, --amber, --line. 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 *, html, 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: a script that waits for DOMContentLoaded, wires Lenis into GSAP's ticker, drives a hand-rolled canvas render loop off requestAnimationFrame, and drives the card slide and the triangle-grid reveal out of a single ScrollTrigger.create({ onUpdate }) — and never has to undo any of it, because the tab reloads long before it could run twice. React withdraws that guarantee.

Under React 19 with StrictMode, every effect mounts, unmounts, and mounts again before anything reaches the screen. Here that produces three independent copies of state fighting each other: a second pin on .sticky racing the first to read self.progress and set the same .cards translate, a second Lenis instance pulling on the same wheel event, and a second drawGrid loop repainting outline-layer/fill-layer with a freshly reshuffled triangleStates map on top of whatever the first loop already settled. None of this throws — the cards jitter, the triangle fill blooms twice as fast or in a different random order than intended, and it will not reproduce in a production build, because React only double-invokes effects in development. Treat the cleanup as part of the effect, not an afterthought.

*(1) The entry point* — the whole body, from gsap.registerPlugin through the closing brace of ScrollTrigger.create, sits inside document.addEventListener("DOMContentLoaded", () => {...}). A React component mounts after that event has already fired on the document, so the listener is registered and never called: no Lenis instance, no triangle grid, no pin — .sticky just sits there as an ordinary, unpinned section, with nothing in the console to explain why. Delete the listener and move its body into a useEffect with an empty dependency array. gsap.registerPlugin(ScrollTrigger) can move to module scope.

*(2) Element lookups* — .sticky, .outline-layer and .fill-layer are each looked up once via document.querySelector; give the component a root ref and resolve all three off it instead. .cards is the one to watch: unlike the other three it is not hoisted — document.querySelector(".cards") runs fresh inside onUpdate, once per scroll tick, for as long as the pinned range is active. Scoping it to the root ref matters more here than for a one-time lookup: during the StrictMode remount two .cards elements exist for an instant, and an unscoped, per-frame lookup keeps re-resolving to whichever one is currently unmounting, right up until it disappears.

*(3) Cleanup* — wrap the canvas setup, initializeTriangles, the first drawGrid() call and the ScrollTrigger.create call in one gsap.context scoped to the root ref, and keep the Lenis instance, the ticker callback and the render-loop handle reachable from outside that context:

useEffect(() => {
  const root = rootRef.current;
  const lenis = new Lenis();
  const onTick = (time) => lenis.raf(time * 1000);
  const frameRef = { current: null }; // or a useRef, if you prefer

  const ctx = gsap.context(() => {
    lenis.on("scroll", ScrollTrigger.update);
    gsap.ticker.add(onTick);
    gsap.ticker.lagSmoothing(0);

    /* canvas grabs off `root`, setCanvasSize, initializeTriangles(), drawGrid(),
       and ScrollTrigger.create({ trigger: root.querySelector(".sticky"), ... }) go here,
       with drawGrid's requestAnimationFrame call writing into frameRef.current instead of
       the module-level animationFrameId variable it uses above */
  }, rootRef);

  const onResize = () => { /* setCanvasSize on both canvases, triangleStates.clear(),
                               initializeTriangles(), drawGrid() */ };
  window.addEventListener("resize", onResize);

  return () => {
    ctx.revert();
    gsap.ticker.remove(onTick);
    lenis.destroy();
    window.removeEventListener("resize", onResize);
    if (frameRef.current) cancelAnimationFrame(frameRef.current);
  };
}, []);

ctx.revert() covers exactly what GSAP created inside that factory: it unpins .sticky — removing the pin spacer and the inline styles ScrollTrigger wrote for the pinned state — and removes the trigger itself, so the StrictMode remount does not leave two triggers scrubbing the same range. Everything else in the cleanup above is deliberately outside the context, because none of it is a tween or a trigger. gsap.ticker.add(onTick) drives lenis.raf() every frame regardless of whether any GSAP object still exists, so remove it before calling lenis.destroy(), not after — a tick landing between the two calls would call .raf() on an instance that is already gone. drawGrid's own requestAnimationFrame chain is a second, independent loop: it keeps re-scheduling itself for as long as any triangle's eased scale has not yet reached its target, and ctx.revert() has no idea it exists, so cancel it explicitly through the ref. The resize listener is the last piece the context doesn't reach — it is a plain window subscription that rebuilds triangleStates and repaints both canvases whenever the viewport changes, so it needs its own removeEventListener with the exact function reference, or a resize firing after a StrictMode remount runs the handler twice and rebuilds the grid from two independently-shuffled maps into canvases only one of which is still on screen.