# Sticky Image Minimap — Thumbnail Strip That Glides Through a Fixed Indicator as You Scroll a Tall Gallery

## Goal
Build a **tall, black, editorial vertical image gallery** with a **sticky sidebar minimap** on the left. The gallery column (75% wide) holds ten large stacked photos; the minimap (25% wide) is pinned to the viewport and contains a vertical strip of the same ten photos as small thumbnails. The star effect: as the page scrolls through the gallery, the whole thumbnail strip **slides vertically (translateY)** behind a **fixed bordered indicator box**, so the minimap "plays through" its thumbnails in lockstep with the scroll — the thumbnail currently framed by the indicator matches the large photo you're looking at. The indicator uses `mix-blend-mode: difference` so its outline stays legible over any thumbnail. A second scroll trigger: once you've scrolled past **four viewport heights**, the entire page **inverts from a black theme to a white theme** with a smooth 0.5s color transition.

## Tech
Vanilla HTML/CSS/JS. **No GSAP, no animation library, no smooth-scroll library.** The entire effect is two plain `window` scroll listeners that write `element.style.transform` and toggle a CSS class; all easing/transitions are pure CSS `transition`. Ships as an ES module (`<script type="module" src="./script.js">`) but imports nothing. Everything runs inside a single `DOMContentLoaded` handler.

## Layout / HTML
```
.wrapper                                  (full-page shell; carries the theme class)
  nav                                     (fixed top strip)
    a  "Motionprompts"                    (fictional brand / logo — left)
    a  "Subscribe"                        (right)

  .gallery                                (flex row: minimap | images)
    .minimap                              (sticky left column, 25%)
      .preview                            (the vertical thumbnail STRIP — this is what translates)
        .item-preview > img   × 10        (100×125 thumbnails, one per gallery photo, same order)
      .active-img-indicator               (fixed bordered box that frames the "current" thumbnail)

    .images                               (the tall gallery column, 75%)
      .item            × 10
        .item-img > img                   (large 500×550 photo)
        .item-copy
          p  "img_01.jpg" … "img_010.jpg" (filename label — left)
          p  "01" … "10"                  (index — right)

  .container                              (lower editorial text section)
    h1   <long travel headline paragraph>
    .hero-img > img                       (full-width wide hero photo)
    h1   <second travel headline paragraph>
    h1   <third travel headline paragraph>
```
Key classes the JS depends on: `.images`, `.preview`, `.minimap`, `.wrapper`. The `.preview` strip and its ten `.item-preview` thumbnails hold the **same ten images in the same order** as the ten `.item` photos in `.images`. Nav brand is the fictional **"Motionprompts"** / **"Subscribe"** — no real brand names. Headline copy is generic travel-editorial prose (three paragraphs about horizons, hidden gems, and curated expeditions).

## Styling
Global reset: `* { margin:0; padding:0; box-sizing:border-box; }`. `html, body { font-family:"PP Neue Montreal"; }` — a proprietary neo-grotesque; substitute Inter / Neue Haas Grotesk / any clean grotesque. `img { width:100%; height:100%; object-fit:cover; }` (every image hard-crops to its slot).

**Palette (default = dark theme):** page/background pure black `#000`, all text white `#fff`. There is no other color — the photographs supply all the color.

**Type:**
- `a, p { text-decoration:none; color:#fff; font-size:14px; font-weight:500; text-transform:uppercase; transition:0.5s color; }`
- `h1 { font-size:50px; font-weight:500; margin-bottom:1em; transition:0.5s color; }`

**Load-bearing structural CSS** (the JS reads offsets off this geometry and the sticky/fixed positioning is the whole trick — keep the pixel values):
- `.wrapper { width:100%; height:100%; background-color:#000; transition:0.5s background-color; }`
- `nav { position:fixed; width:100%; display:flex; justify-content:space-between; align-items:center; padding:2.5em; z-index:2; }`
- `.gallery { position:relative; width:100%; display:flex; z-index:0; }`
- `.minimap { position:sticky; top:0; width:25%; height:100vh; padding-top:300px; overflow:hidden; background-color:#000; transition:0.5s background-color; }` — **sticky**, so it pins for the full length of the gallery; `overflow:hidden` clips the strip; `padding-top:300px` pushes the strip's static top down to 300px so the first thumbnail starts aligned with the indicator.
- `.preview { position:absolute; left:50%; transform:translateX(-50%); width:100%; height:1254px; display:flex; flex-direction:column; align-items:center; }` — a fixed-height (**1254px**) vertical column of the ten thumbnails (10 × 125px ≈ 1250). **This is the element the JS translates on Y**; its base transform is `translateX(-50%)` and the JS re-appends a `translateY(...)`.
- `.item-preview { position:relative; width:100px; height:125px; padding:10px; overflow:hidden; }` — one thumbnail slot (inner image ≈ 80×105 after padding).
- `.active-img-indicator { position:absolute; top:300px; left:50%; transform:translateX(-50%); width:100px; height:125px; border:1.5px solid #fff; border-radius:4px; mix-blend-mode:difference; z-index:2; }` — a **fixed-in-place** (relative to the sticky minimap) bordered box, exactly one thumbnail-slot in size (100×125), sitting at the 300px offset. It never moves; the strip slides under it. `mix-blend-mode:difference` inverts the white border against whatever thumbnail is behind, so the outline stays visible on both dark and light photos and in both themes.
- `.images { position:relative; top:0; width:75%; }` — the tall gallery column.
- `.item { position:relative; width:500px; height:600px; overflow:hidden; margin:50px auto; }` (at `max-width:900px` → `width:400px; height:500px`).
- `.item-img { width:500px; height:550px; }` (the photo; the remaining ~50px of the item is the `.item-copy` caption row).
- `.item-copy { width:100%; display:flex; justify-content:space-between; padding:5px 0; text-transform:uppercase; }` — filename pinned left, index pinned right.
- `.container { width:100%; height:100%; padding:5em 2.5em; }`, `.hero-img { margin-bottom:2em; }`.

**Theme-invert CSS** (toggled by adding `.dark-theme` to `.wrapper`; note the class name says "dark" but it produces the WHITE/light look):
```css
.wrapper.dark-theme,
.wrapper.wrapper.dark-theme .minimap { background-color:#fff; }
.wrapper.dark-theme a,
.wrapper.dark-theme p,
.wrapper.dark-theme h1 { color:#000; }
```
Because `.wrapper`, `.minimap`, `a`, `p`, `h1` all carry `transition:0.5s` on their `background-color`/`color`, adding/removing the class cross-fades the whole page between black-on-white and white-on-black over 0.5s.

## The effect (exhaustive — this IS the component)

All of it runs once inside `document.addEventListener("DOMContentLoaded", …)`.

### 1. Measure the geometry (once, on load)
```js
const imagesContainer = document.querySelector(".images");
const preview = document.querySelector(".preview");
const minimap = document.querySelector(".minimap");

function getElementTop(element) {          // absolute document-top of an element
  let top = 0;
  while (element) { top += element.offsetTop; element = element.offsetParent; }
  return top;
}

const imagesStart   = getElementTop(imagesContainer);       // ≈ 0 (gallery sits at page top under the fixed nav)
const imagesEnd     = imagesStart + imagesContainer.offsetHeight;   // bottom of the tall gallery column
const viewportHeight = window.innerHeight;
const previewHeight  = preview.offsetHeight;                 // = 1254 (fixed in CSS)
const previewMaxTranslate = (minimap.offsetHeight - previewHeight) * 2.84;
```
- `minimap.offsetHeight` is `100vh`. Since the strip (1254px) is taller than the viewport, `minimap.offsetHeight − previewHeight` is **negative**, so `previewMaxTranslate` is a **negative** number (the strip's maximum *upward* travel). The **`2.84`** is an empirical tuning multiplier: at the reference viewport it makes the strip's total travel ≈ one full preview height, so scrolling the gallery from top to bottom walks the strip from thumbnail 1 to thumbnail 10 under the indicator.

### 2. The scroll-linked strip translate (`handleScroll`, bound to `window` `scroll`)
```js
function handleScroll() {
  const scrollPosition = window.scrollY;
  const scrollRange = imagesEnd - imagesStart - viewportHeight;                 // scroll distance the gallery spans
  const previewScrollRange = Math.min(previewMaxTranslate, scrollRange);        // = previewMaxTranslate (negative)

  if (scrollPosition >= imagesStart && scrollPosition <= imagesEnd - viewportHeight) {
    let scrollFraction   = (scrollPosition - imagesStart) / scrollRange;        // 0 → 1 through the gallery
    let previewTranslateY = scrollFraction * previewScrollRange;                // 0 → previewMaxTranslate (0 → negative)
    preview.style.transform = `translateX(-50%) translateY(${previewTranslateY}px)`;
  } else if (scrollPosition < imagesStart) {
    preview.style.transform = "translateX(-50%) translateY(0px)";               // before gallery: strip parked at top
  } else {
    preview.style.transform = `translateX(-50%) translateY(${previewMaxTranslate}px)`;  // after gallery: strip parked at bottom
  }
}
window.addEventListener("scroll", handleScroll);
```
Precise behavior:
- **Mapping:** the page-scroll fraction through the gallery (`0` when the gallery top hits the viewport top, `1` when its bottom is one viewport above) maps **linearly** onto the strip's `translateY`, from `0` down to `previewMaxTranslate` (a negative value → the strip moves **up**). No easing, no lerp — it's a direct 1:1 scroll-scrub, so the strip tracks the scrollbar exactly (as smooth as the browser's own scroll).
- Because `previewMaxTranslate` is negative and `Math.min(previewMaxTranslate, scrollRange)` therefore returns it, `previewScrollRange` is that negative travel; multiplying by the `0→1` fraction slides the strip upward through its ten thumbnails.
- **Clamps:** above the gallery (`scrollPosition < imagesStart`) the strip is pinned at `translateY(0)` (thumbnail 1 framed); below the gallery it's pinned at `translateY(previewMaxTranslate)` (thumbnail 10 framed). No overshoot.
- The base `translateX(-50%)` is **always re-written** together with the Y so the strip stays horizontally centered in the minimap.
- The minimap being `position:sticky` means it stays fixed on screen for the entire gallery, so the moving strip + stationary `.active-img-indicator` read as a little live navigator: whatever thumbnail is inside the 100×125 bordered box corresponds to the large photo currently centered in the viewport.

### 3. The theme inversion (`checkScroll`, a second `window` `scroll` listener)
```js
const togglePoint = window.innerHeight * 4;
const wrapper = document.querySelector(".wrapper");

function checkScroll() {
  if (window.scrollY >= togglePoint) wrapper.classList.add("dark-theme");
  else                               wrapper.classList.remove("dark-theme");
}
window.addEventListener("scroll", checkScroll);
```
- Threshold is **4 × viewport height** of scroll. Cross it going down → add `.dark-theme` → page cross-fades to **white background / black text / white minimap** over 0.5s (via the CSS transitions). Scroll back up above the threshold → class removed → cross-fades back to the black theme. It's a hard boolean at `scrollY = 4·innerHeight`, not a gradient.

## Assets / images
**11 photographs**, one cohesive **warm travel / nature landscape series** — golden-hour desert dunes, cloudscapes seen from above, dramatic sunrise/sunset skies with sunbeams, and rugged coastal cliffs meeting the sea. Warm ambers and oranges balanced with sky blues; airy, editorial, wanderlust mood. All hard-cropped with `object-fit:cover`, so source aspect ratio is forgiving.
- **10 gallery images** (`img_01`…`img_010`): each appears **twice** — once large in a `.item-img` slot (500×550, a nearly-square-to-portrait crop) and once small as an `.item-preview` thumbnail (100×125, portrait) in the minimap strip, **in the same order**. Roughly portrait-to-square source images read best.
- **1 hero image**: a **wide landscape** photo (full-container width, e.g. warm-lit coastal cliffs or a close-up of curled autumn leaves) placed between the second and third closing headline paragraphs in the lower `.container` section.

No logos or brand marks. Filenames shown in the captions (`img_01.jpg`, `01` … `img_010.jpg`, `10`) are decorative labels only.

## Behavior notes
- **No autoplay / no loop** — nothing moves until the user scrolls; both listeners are pure scroll-driven. On load the strip is parked at `translateY(0)` (thumbnail 1) and the page is in the black theme.
- **All geometry is measured once on load** (`imagesStart`, `imagesEnd`, `previewMaxTranslate`, `viewportHeight`, `togglePoint`) with no `resize` re-measure — the mapping is correct at the initial viewport size; a mid-session resize would leave the strip travel and 4-viewport threshold slightly off (this matches the original).
- **The `2.84` multiplier is viewport-tuned**, so the exact thumbnail-to-photo registration is calibrated for a typical desktop height; it stays visually coherent across sizes but is precise at the reference height.
- **Responsive:** at `max-width:900px` the gallery items shrink to 400×500; the minimap/strip mechanics are unchanged.
- **Reduced motion:** the only motion is (a) the direct scroll-scrub of the strip, which is as calm as scrolling itself, and (b) the 0.5s theme cross-fade; there's no reduced-motion guard in the original.
- Mobile-safe and lightweight — no canvas, no WebGL, no heavy libraries; just two scroll handlers and CSS transitions.
