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

```js
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import Lenis from "lenis";
gsap.registerPlugin(ScrollTrigger);
```

Lenis wiring (standard GSAP integration):

```js
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:

```html
<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 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 `span`s **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:

```js
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

```js
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)

```js
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

```js
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

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