# Infinite Horizontal Scroll

## Goal

Build a full-page, seamlessly looping horizontal scroll experience: a row of 8 editorial panels that scrolls sideways forever in either direction. Vertical mouse-wheel deltas (and horizontal touch drags) are converted into smooth, lerp-eased horizontal movement of a giant flex strip. Cloned copies of the panel sequence on both sides make the loop invisible, and a fixed progress bar plus a percentage counter track the position within one loop (0–100).

## Tech

Vanilla HTML/CSS/JS with an ES module script (`<script type="module" src="./script.js">`). **No GSAP and no external libraries are needed** — the entire effect is a hand-rolled `lerp` + `requestAnimationFrame` loop with native `wheel` / `touch` event listeners. Do not import gsap or lenis.

## Layout / HTML

Everything lives inside a fixed, viewport-sized container that hides overflow:

```
.container                     (fixed wrapper)
├── .progress-bar              (empty div, fixed top bar)
├── .progress-counter          (fixed bottom-right) > h1 with initial text "0"
└── .scroller                  (the horizontal flex strip)
    ├── section.intro          h1 "Once You Start Scrolling, There’s No Way Out!"
    │                          (note the curly typographer’s apostrophe ’ in "There’s", not a straight ')
    │                          h2 "What If Your Website Could Scroll Forever?"
    ├── section.hero-img       > img (full-bleed photo)
    ├── section.header         h1 "Traversing the frontier of digital evolution, crafting the future."
    ├── section.about          > .row > .copy (two <p> of futuristic studio copy)
    │                                > .img > img
    │                          then h1 "Future Architectonics"
    ├── section.banner-img     > img (full-bleed photo)
    ├── section.story          four h1s: "Digital Alchemy", "Neoteric Identities",
    │                          "Cinematic Realities", "Symphonics"
    ├── section.concept-img    > img (full-bleed photo)
    └── section.outro          h1 "horizons.com"
```

The two `.about` paragraphs are short marketing copy about engineering next-generation digital experiences (2–3 sentences each; any similar futuristic-studio prose works).

## Styling

- Global reset: `* { margin: 0; padding: 0; box-sizing: border-box; user-select: none; }`.
- **Typography — read carefully; the fonts are deliberately NOT loaded**
  - None of the named webfonts below are bundled or linked: there is **no `@font-face`, no `<link>`, and no import** for any font. This is intentional and must be preserved — do NOT add a Google Fonts `<link>`, an `@font-face`, or a self-hosted font. The named families simply never resolve, so the browser substitutes its **default serif** (a Times-like face) for every heading and paragraph. That default serif — rendered at **normal letter width, NOT condensed** — is exactly what the original demo shows and what the reproduction must match.
  - `h1`: `font-family: "Druk Condensed", serif`. "Druk Condensed" is the *intended* ultra-condensed display face, but because it is never loaded the giant headings actually render in the browser's **default serif at normal width**. Keep the `, serif` fallback (or leave the family bare) and **never** substitute a sans fallback such as `"Arial Narrow"`, `"Impact"`, or `sans-serif` — a sans headline is the single biggest divergence from the original. Other h1 properties: `font-size: 15vw`, `font-weight: lighter`, `text-transform: uppercase`, `line-height: 0.8`, `padding-top: 0.2em`.
  - `h2`: `font-family: "Saans TRIAL"` (likewise unbundled → falls back to the same **default serif**; do not add a sans/Helvetica fallback), `font-size: 30px`, `font-weight: 700`.
  - `p`: `font-family: "Saans TRIAL"` (same unbundled family → default serif), `font-size: 15px`, `font-weight: 500`.
- `img { width: 100%; height: 100%; object-fit: cover; }`.
- `.container`: `position: fixed; top: 0; left: 0; width: 100vw; height: 100svh; overflow: hidden;`.
- `.progress-bar`: `position: fixed; top: 0; left: 0; width: 100vw; height: 10px; transform: scaleX(0); transform-origin: center left; background-color: #fff; will-change: transform; z-index: 2;`.
- `.progress-counter`: `position: fixed; bottom: 1em; right: 2.5em; color: #fff; z-index: 2;` (its `h1` inherits the giant 15vw display style).
- `.scroller`: `position: relative; width: 700vw; height: 100svh; display: flex; will-change: transform; transform: translateX(0);` (the width is overridden in px by JS after cloning).
- Every `section`: `position: relative; height: 100svh; display: flex; justify-content: center; align-items: center;`.
- Section widths: `.intro, .hero-img, .about, .banner-img, .story, .outro { width: 75vw; }` and `.header, .concept-img { width: 100vw; }`.
- Panel colors:
  - `.intro, .header`: `padding: 2em; background-color: #000; color: #fff;`.
  - `.about`: `padding: 4em 3em 1em 2em; background-color: #eb001b;` (vivid red), `color: #fff`.
  - `.story`: `padding: 4em 2em 2em 2em; background-color: #f69e1c;` (amber).
  - `.outro`: `background-color: #fe5e00;` (bright orange).
- `.about, .intro, .header, .story` use `display: flex; justify-content: space-between; align-items: flex-start;`, and `.intro, .about, .story` are `flex-direction: column` (so the big heading sits at the bottom of `.about`, and the intro's h1/h2 spread top/bottom).
- `.header h1 { font-size: 15.75vw; }` and `.story h1 { padding-top: 0; }`.
- `.about .row`: `display: flex; justify-content: space-between;`; each `p` inside is `width: 50%; margin-bottom: 1em;`; `.copy { flex: 3; }`; `.img { flex: 2; aspect-ratio: 7/5; }`.

## Scroll engine (the core effect — implement exactly)

All logic runs inside `DOMContentLoaded`. Constants:

- `smoothFactor = 0.05` — the lerp interpolation factor used for both the strip position and the progress bar.
- `touchSensitivity = 2.5` — multiplier applied to touch drag deltas.
- `bufferSize = 2` — number of cloned sequence copies on EACH side of the originals.
- `lerp(start, end, factor) => start + (end - start) * factor`.

State: `targetScrollX`, `currentScrollX`, `isAnimating` flag, `currentProgressScale`, `targetProgressScale`, `lastPercentage`, plus touch state (`isDown`, `lastTouchX`, `touchVelocity`, `lastTouchTime`).

### 1. Setup (`setupScroll`)

1. Remove any existing `.clone-section` elements, then collect the original `section`s.
2. Compute `sequenceWidth` = the sum of each original section's computed pixel width (`parseFloat(getComputedStyle(section).width)`).
3. Append full cloned copies of the whole sequence: `bufferSize` copies before-equivalents and `bufferSize` copies after (loop `i` from `-bufferSize` to `-1`, then `1` to `bufferSize`; for each, clone every section with `cloneNode(true)`, add class `clone-section` and attribute `data-clone-index="{i}-{index}"`, and append to `.scroller`). The DOM order ends up: originals first, then all clones appended after — this still works because the strip is translated to the middle copy.
4. Set `scroller.style.width = sequenceWidth * (1 + bufferSize * 2) + "px"` (5 sequence copies total).
5. Initialize `targetScrollX = currentScrollX = sequenceWidth * bufferSize` and apply `scroller.style.transform = translateX(-currentScrollX px)`. Return `sequenceWidth`.

### 2. Boundary wrap (`checkBoundaryAndReset`)

Keeps the strip centered on the middle copy so the loop never ends:

- If `currentScrollX > sequenceWidth * (bufferSize + 0.5)` → subtract `sequenceWidth` from BOTH `targetScrollX` and `currentScrollX`, re-apply the transform immediately, return `true`.
- If `currentScrollX < sequenceWidth * (bufferSize - 0.5)` → add `sequenceWidth` to both, re-apply, return `true`.
- Otherwise return `false`. The returned flag is used to force-snap the progress bar (no lerp glitch across the wrap).

### 3. Progress (`updateProgress(sequenceWidth, forceReset)`)

- `currentPosition = (currentScrollX - sequenceWidth * bufferSize) % sequenceWidth`; `percentage = currentPosition / sequenceWidth * 100`; if negative, `percentage = 100 + percentage`.
- Set the counter text to `Math.round(percentage)` and `targetProgressScale = percentage / 100`.
- Wrap detection: if (`lastPercentage > 80 && percentage < 20`) or (`lastPercentage < 20 && percentage > 80`) or `forceReset` → snap instantly: `currentProgressScale = targetProgressScale` and apply `scaleX(currentProgressScale)` directly (skipping the lerp for that jump).
- Store `lastPercentage = percentage`.

### 4. Animation loop (`animate(sequenceWidth, forceProgressReset = false)`)

Each frame:

1. `currentScrollX = lerp(currentScrollX, targetScrollX, 0.05)` and apply `translateX(-currentScrollX px)` to the scroller.
2. Call `updateProgress`.
3. Unless `forceProgressReset` is true for this frame, `currentProgressScale = lerp(currentProgressScale, targetProgressScale, 0.05)` and apply `scaleX(currentProgressScale)` to the progress bar.
4. If `Math.abs(targetScrollX - currentScrollX) < 0.01` set `isAnimating = false` and stop; otherwise `requestAnimationFrame(() => animate(sequenceWidth))` (subsequent frames run without the force flag).

On load: run `setupScroll()`, then `updateProgress(sequenceWidth, true)` and apply the initial `scaleX`.

### 5. Input handlers (all on `.container`)

- **Wheel** (`{ passive: false }`): `e.preventDefault()`, then `targetScrollX += e.deltaY` (vertical wheel → horizontal movement, 1:1). Call `checkBoundaryAndReset`; if not already animating, set `isAnimating = true` and start `requestAnimationFrame(() => animate(sequenceWidth, needsReset))`.
- **touchstart**: set `isDown = true`, record `lastTouchX = e.touches[0].clientX` and `lastTouchTime = Date.now()`, and kill inertia with `targetScrollX = currentScrollX`.
- **touchmove**: if not down return; `e.preventDefault()`; `touchDelta = lastTouchX - currentTouchX`; `targetScrollX += touchDelta * touchSensitivity` (2.5×). Track velocity: with `timeDelta = now - lastTouchTime`, if positive, `touchVelocity = (touchDelta / timeDelta) * 15`. Update `lastTouchX` / `lastTouchTime`, run the boundary check, and kick the animate loop like the wheel handler.
- **touchend**: `isDown = false`. If `|touchVelocity| > 0.1`, apply a momentum fling: `targetScrollX += touchVelocity * 20`, then start a rAF decay loop — each frame `touchVelocity *= 0.95`, and while `|touchVelocity| > 0.1` keep adding `touchVelocity` to `targetScrollX`, run `checkBoundaryAndReset`, and if it wrapped call `updateProgress(sequenceWidth, true)`; continue via `requestAnimationFrame`.

## Assets / images

4 photographs, referenced from the HTML:

1. **Hero panel image** — a striking full-bleed editorial/architectural photo filling a 75vw × full-viewport-height panel (roughly 3:4 to 4:5 of viewport, cropped with `object-fit: cover`).
2. **About inline image** — a photo displayed at a 7:5 landscape aspect ratio next to the two text columns inside the red about panel.
3. **Banner panel image** — a second full-bleed photo filling a 75vw × full-height panel.
4. **Concept panel image** — a third full-bleed photo filling a 100vw × full-height panel.

Any cohesive set of moody, futuristic/editorial photographs works; they are cropped by the panels.

## Behavior notes

- There is NO native page scroll: the document never scrolls; the fixed container intercepts wheel/touch and drives `translateX` on the strip. Body needs no extra height.
- The loop is bidirectional and endless — scrolling backward from the start lands on the end of the sequence with no visible jump.
- The counter and bar wrap from 100 back to 0 (and vice versa); the snap logic prevents the bar from animating backward across the seam.
- Works on touch devices via the drag + momentum handlers; desktop uses the wheel.
- `100svh` (not `100vh`) is used for all heights to behave correctly in mobile browsers.
- **Fonts (do not "improve" this):** no webfonts are loaded, so all text — including the giant 15vw headings — renders in the browser's default **serif** at normal width. Reproduce this exactly. Declare the family names as given (`"Druk Condensed", serif` / `"Saans TRIAL"`) but do not link, import, or `@font-face` any font, and never fall back to a sans/condensed face; adding a font or a sans fallback makes the reproduction diverge from the original.
