All components

Scroll-Synced Image Minimap

JavaScript animation component · Published 2026-07-27 · by vanguardia.dev

Open live demo ↗ Raw prompt (.md)

What it does

A tall vertical image gallery paired with a sticky sidebar minimap of thumbnails. As the user scrolls, a scroll listener maps the page scroll fraction onto a translateY of the thumbnail strip so the minimap glides through its images in sync, while a fixed bordered indicator marks the active thumbnail via mix-blend-mode; past a threshold the whole page inverts to a light theme.

How it's built

Categorygallery
Techvanilla JS
Complexitypage
Performance costlight
Mobile-safeyes

minimap scroll sticky gallery thumbnails mix-blend-mode vanilla-js editorial navigator

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

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:900pxwidth: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):

.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)
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)
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)
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_01img_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, 01img_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.