# Scroll-Driven Arc Card Carousel with Synced Step Counter

## Goal

Build a single full-screen **pinned** section where a row of image cards is laid out along a **circular arc** (like cards resting on the rim of a giant wheel below the viewport) and **rotates through the top of the screen as you scroll** — each card swings up from the right, passes upright through the center, and swings off to the left, staying tangent to the arc the whole way. A large **step counter (01–05)** in the corner slides vertically to stay in sync with whichever card is currently centered. Smooth scroll via Lenis. The card positions are recomputed every frame with trigonometry (`cos`/`sin`) and applied through `gsap.set` — there is **no tween/timeline** on the cards themselves; only the counter uses a tween.

## Tech

Vanilla HTML/CSS/JS with ES module imports. Use `gsap` (npm) plus the GSAP plugin `ScrollTrigger`, and `lenis` (npm) for smooth scroll. No other plugins, no framework:

```js
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import Lenis from "lenis";
```

Register once: `gsap.registerPlugin(ScrollTrigger);`. Wrap all setup in a `DOMContentLoaded` listener. Card positioning is done with a native `IntersectionObserver` (for the counter) plus GSAP.

## Layout / HTML

Everything lives inside a `<div class="container">`. Structure top to bottom:

1. `<nav>` — a `<p id="logo">` reading `Voxel` (fictional brand) and a `<button>` reading `Download Now`. Absolutely positioned, spans the top of the viewport, `z-index: 2`.
2. `<section class="intro">` — empty; it is just a full-viewport panel with a full-bleed background image.
3. `<section class="steps">` — **the pinned stage.** Contains two blocks:
   - `<div class="step-counter">` → `<div class="counter-title"><h1>steps</h1></div>` and `<div class="count"><div class="count-container"> <h1>01</h1> <h1>02</h1> <h1>03</h1> <h1>04</h1> <h1>05</h1> </div></div>`. The five number `<h1>`s are stacked vertically inside `.count-container`; only one shows at a time through the clipped `.count` window.
   - `<div class="cards">` → **seven** `<div class="card">` elements. The first **five** each contain `<div class="card-img"><img src="…" alt="" /></div>` and `<div class="card-content"><p>…</p></div>`. The last **two** are `<div class="card empty"><p>EMPTY</p></div>` (invisible spacers — see note). All seven must exist; the arc math depends on the count being 7.
4. `<section class="outro">` — a single centered `<p>` with one highlighted `<span>`, e.g. `Our 3D design tool is built to enhance your creative workflow, <span>providing an all-in-one solution</span> for crafting stunning visuals and prototypes.`

Card paragraph copy (steps 1→5, neutral product copy):
1. "Effortlessly import your 3D models and assets into our intuitive design tool, ensuring that projects are set up quickly."
2. "Take full control of your designs with our advanced customization tools. Adjust lighting and geometry with precision."
3. "Bring your designs to life with our seamless animation presets and tools, allowing you to create dynamic interactions."
4. "Collaborate in real time with your team, sharing ideas and updates instantly to streamline the creative process."
5. "When your design is complete, export it in various formats optimized for production or further editing."

## Styling

Palette & type:
- Page: `background: #000; color: #fff;`. Body `font-family: "Helvetica Now Display"` (a neutral grotesque sans; system sans fallback is fine).
- Logo and all big display type: `font-family: "PP Monument Extended"; font-weight: 900; text-transform: uppercase;` (a heavy, extra-wide extended display face).
- `button`: `color: #000; background: #fff; border: none; padding: 0.75em 1em; border-radius: 0.25em; font-weight: 500;`.
- `.outro`: `display:flex; justify-content:center; align-items:center; background: linear-gradient(180deg, #000000, #364549);`. `.outro p`: `width:75%; text-align:center; font-size:52px; font-weight:400; line-height:1.125;`. `.outro p span { color:#75e1ff; }` (cyan highlight).
- `img { width:100%; height:100%; object-fit:cover; }`. Global reset `* { margin:0; padding:0; box-sizing:border-box; }`.

Critical structural CSS (the effect hangs on these exact values):

- `section { position:relative; width:100vw; height:100vh; overflow:hidden; }`
- `html, body { width:100vw; height:900vh; }` (tall page; the real scroll distance is actually created by the pin spacer, below).
- `.intro { background: url("…hero…") no-repeat 50% 50%; background-size:cover; }`
- `.cards { position:absolute; top:25%; left:50%; transform:translate(-50%,-50%); width:150vw; height:600px; will-change:transform; }` — the card layer is centered horizontally and sits with its center at **25% of viewport height** (high up, so the arc's top passes through the upper third).
- `.card { position:absolute; width:500px; height:550px; left:50%; top:50%; margin-left:-250px; transform-origin:center center; display:flex; flex-direction:column; gap:1em; will-change:transform; }` — every card is stacked at the same origin point; GSAP then moves each one out onto the arc.
- `.card-img { flex:1; background:#fff; border-radius:0.5em; overflow:hidden; }`
- `.card-content { width:100%; height:60px; }` with `.card-content p { font-size:16px; font-weight:500; line-height:1.25; text-align:left; }`
- `.empty { opacity:0; }` — the two spacer cards are laid out on the arc but invisible.
- Step counter:
  - `.step-counter { position:absolute; display:flex; flex-direction:column; margin:2em; }`
  - `.counter-title, .count { position:relative; width:1200px; height:150px; overflow:hidden; clip-path: polygon(0 0, 100% 0, 100% 100%, 0% 100%); }` (a clipped 150px-tall window).
  - `.count { top:-10px; }`
  - `.count-container { position:relative; transform:translateY(150px); will-change:transform; }` — **starts translated down 150px** (initial rest state = blank window above "01").
  - `.step-counter h1 { font-size:150px; line-height:1; letter-spacing:-0.04em; font-weight:900; text-transform:uppercase; }` — so each number is exactly 150px tall = one window height. Stacking five of them makes a 750px column that slides through the 150px window.

## GSAP effect (be exhaustive — this is the whole component)

### Smooth-scroll wiring (Lenis + GSAP ticker)

```js
const lenis = new Lenis();
lenis.on("scroll", ScrollTrigger.update);
gsap.ticker.add((time) => { lenis.raf(time * 1000); });
gsap.ticker.lagSmoothing(0);
```

Default Lenis options. Lenis scroll events feed `ScrollTrigger.update`; the GSAP ticker drives `lenis.raf`; lag smoothing is disabled so the scrub stays glued to scroll position.

### Constants

```js
const stickySection = document.querySelector(".steps");
const stickyHeight  = window.innerHeight * 7;   // 7 viewport heights of scroll while pinned
const cards         = document.querySelectorAll(".card"); // 7 (5 real + 2 empty)
const countContainer= document.querySelector(".count-container");
const totalCards    = cards.length;             // 7

const arcAngle   = Math.PI * 0.4;               // 72° arc span
const startAngle = Math.PI / 2 - arcAngle / 2;  // 54° (arc runs 54°→126°, centered on the vertical 90°)
```

`getRadius()` is responsive:

```js
const getRadius = () =>
  window.innerWidth < 900 ? window.innerWidth * 7.5 : window.innerWidth * 2.5;
```

So on desktop the arc radius is `2.5 × viewport width` (a very shallow, wide arc — the wheel is huge and mostly below the screen).

### The ScrollTrigger (single pinned, no scrub — driven by `onUpdate`)

```js
ScrollTrigger.create({
  trigger: stickySection,
  start: "top top",
  end: `+=${stickyHeight}px`,   // pin lasts innerHeight*7
  pin: true,
  pinSpacing: true,
  onUpdate: (self) => { positionCards(self.progress); },
});
```

- The `.steps` section pins at `top top` and stays pinned for **7× the viewport height** of scroll. `pinSpacing:true` inserts a real spacer so the outro appears only after the whole arc sequence finishes.
- **No `scrub` and no tween** — every frame, `onUpdate` recomputes card transforms from `self.progress` (0→1) via `positionCards`. Smoothness comes entirely from Lenis. Call `positionCards(0)` once at startup so the initial pose is correct before any scroll.

### `positionCards(progress)` — the trigonometric layout (the star effect)

```js
function positionCards(progress = 0) {
  const radius = getRadius();
  const totalTravel = 1 + totalCards / 7.5;              // 1 + 7/7.5 = 1.9333…
  const adjustedProgress = (progress * totalTravel - 1) * 0.75; // ranges −0.75 → +0.70

  cards.forEach((card, i) => {
    const normalizedProgress = (totalCards - 1 - i) / totalCards; // i=0 → 6/7 … i=6 → 0
    const cardProgress = normalizedProgress + adjustedProgress;
    const angle = startAngle + arcAngle * cardProgress;

    const x = Math.cos(angle) * radius;
    const y = Math.sin(angle) * radius;
    const rotation = (angle - Math.PI / 2) * (180 / Math.PI); // degrees off vertical

    gsap.set(card, {
      x: x,
      y: -y + radius,            // radius*(1 − sin θ): 0 at the top (θ=90°), grows downward off-axis
      rotation: -rotation,       // keep the card tangent to the arc (upright at the top)
      transformOrigin: "center center",
    });
  });
}
```

Exact mechanics to reproduce:

- Each card `i` is given a fixed **phase offset** `normalizedProgress = (totalCards−1−i)/totalCards`, so the seven cards are evenly spread along the arc, `1/7` apart, with the **first DOM card (i=0) furthest along** (largest angle) and the last (i=6) furthest behind.
- `adjustedProgress` shifts every card's phase together as you scroll: at `progress=0` it is `(0·1.9333−1)·0.75 = −0.75`; at `progress=1` it is `(1.9333−1)·0.75 = +0.70`. That's the conveyor that walks all cards along the arc.
- `angle = startAngle + arcAngle·cardProgress`. A card is **centered/upright** when `angle = 90° (π/2)`, i.e. `cardProgress = 0.5`. As the shared `adjustedProgress` climbs from −0.75→+0.70, cards reach `cardProgress=0.5` **one after another in DOM order** (i=0 first, i=6 last) — so card-1 swings through center first, then card-2, and so on.
- Position: `x = cos(angle)·radius` (right of center when angle<90°, left when >90°); `y = −sin(angle)·radius + radius = radius·(1 − sin angle)` — **0 at the top of the arc**, increasing (pushing the card down and off-screen) as it moves away from center. The circle's center is effectively `radius` below the card layer's center, so cards ride the top edge of a huge wheel.
- Rotation: `rotation: -(angle − 90°)` in degrees — the card is rotated to stay **tangent to the arc** (perfectly upright at the top, tilting clockwise as it moves right, counter-clockwise to the left).
- Everything is applied with `gsap.set` (instantaneous per frame), never a tween. `will-change: transform` is on `.cards` and `.card`.

### Synced step counter (IntersectionObserver + one tween)

A native `IntersectionObserver` watches each card and slides the number column so the centered card's number shows:

```js
const observer = new IntersectionObserver((entries) => {
  entries.forEach((entry) => {
    if (entry.isIntersecting) {
      const cardIndex = Array.from(cards).indexOf(entry.target);
      const targetY = 150 - cardIndex * 150;      // 150, 0, −150, −300, −450, −600, −750
      gsap.to(countContainer, {
        y: targetY,
        duration: 0.3,
        ease: "power1.out",
        overwrite: true,
      });
    }
  });
}, { root: null, rootMargin: "0% 0%", threshold: 0.5 });

cards.forEach((card) => observer.observe(card));
```

- `threshold: 0.5` → the callback fires when a card is at least 50% visible (i.e. as it reaches the center of the screen). The centered card's DOM index sets `targetY = 150 − index·150`.
- The `.count-container` starts at `translateY(150px)` (blank window). Because each number `<h1>` is exactly 150px tall, `y=0` reveals "01", `y=−150` reveals "02", … `y=−600` reveals "05". The tween (`power1.out`, `0.3s`, `overwrite:true`) slides the correct number into the clipped window as each successive card passes center — the counter reads **01 → 05** in lockstep with the arc. (The two empty spacer cards also trigger the observer at the tail, which is why five numbers plus padding cover seven cards.)

### Resize

```js
window.addEventListener("resize", () => positionCards(0));
```

On resize just re-run `positionCards(0)` (recomputes radius from the new width). No `ScrollTrigger.refresh` call in the original.

## Assets / images

- **1 full-bleed hero background** for the `.intro` panel (set as a CSS `background`, `cover`, centered) — a moody, dark, atmospheric editorial/technical image; any aspect ratio (it's `cover`).
- **5 card images**, one per content card, each filling a rounded (`0.5em`) white-matted frame roughly **500×490px** (portrait-ish, `object-fit:cover`). They are a cohesive set of **abstract 3D / product-design visuals** matching the five step captions: (1) importing 3D models/assets, (2) customization — lighting & geometry controls, (3) animation presets, (4) real-time collaboration, (5) export/production. Clean, rendered, tech-forward look; cohesive dark or neutral palette. Provide 5 files in step order; if fewer are available, repeat in order.

No client logos or real brand marks — the demo brand is the fictional "Voxel".

## Behavior notes

- **Page-level component:** Lenis takes over the whole page; the `.steps` section pins for 7 viewport heights while the cards rotate through, then releases into the outro.
- The two `.empty` cards (`opacity:0`) are **not decoration to drop** — they are real arc slots that keep the spacing and the counter timing correct; keep `totalCards = 7`.
- Responsive: at `max-width:900px` the radius switches to `innerWidth·7.5` (even shallower arc), `.cards` top moves to `27.5%`, cards shrink to `375×500`, and the counter title/number shrink to `30px` (with `.count { top:0; left:-10px; }`).
- No reduced-motion handling and no autoplay — nothing moves except in response to scroll (cards) or a card crossing center (counter), so the motion is entirely user-driven.
