All components

Sticky Image Minimap

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

Open live demo ↗ Raw prompt (.md)

What it does

A tall scrolling column of full-size images paired with a sticky vertical minimap of thumbnails; a GSAP ScrollTrigger created per image fires onToggle as each image passes the viewport center (start 'top center', end 'bottom center'), and gsap.to animates the matching thumbnail's border, opacity, scale (1.3) and z-index to highlight it. Triggered by scroll.

How it's built

Categoryscroll
Techgsap
GSAP pluginsScrollTrigger
Complexitypage
Performance costlight
Mobile-safeyes

scrolltrigger sticky minimap gallery image-highlight editorial scroll-progress gsap photography

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 — Scroll-Synced Thumbnail Highlight

Goal

Build a tall, dark editorial travel gallery: a wide column of big full-size images that you scroll past, paired with a sticky vertical minimap of small thumbnails pinned to the left that stays centered on screen the whole time. The star effect: as each full-size image passes through the vertical center of the viewport, its matching thumbnail in the minimap lights up — it pops to scale 1.3, goes fully opaque, gains a white border and jumps above its neighbors — then softly reverts once the image leaves the center band. The result reads like a live "you are here" indicator tracking your scroll position through the gallery. Driven by one GSAP ScrollTrigger per image using its onToggle callback (no scrubbing).

Tech

Vanilla HTML/CSS/JS with ES module imports. Use gsap (npm) plus the single GSAP plugin ScrollTrigger. No smooth-scroll library, no canvas/WebGL. Import and register as:

import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
gsap.registerPlugin(ScrollTrigger);

Run everything inside a DOMContentLoaded handler.

Layout / HTML

Semantic structure (class names are load-bearing — the JS/CSS query them):

<nav>
  <a href="#">Meridian</a>          (fictional travel brand / logo)
  <button>Sign Up</button>
</nav>

<div class="container">
  <h1> long editorial travel headline </h1>
</div>

<div class="gallery">
  <div class="minimap">
    <div class="preview"></div>      (JS injects the 10 thumbnails here)
  </div>
  <div class="images"></div>         (JS injects the 10 full-size images here)
</div>

<div class="container">
  <h1> second long editorial travel headline </h1>
</div>
  • Two .container blocks (one above, one below the gallery) each hold a single large <h1> of neutral travel prose (e.g. *"Embark on a journey where every destination is a paradise, offering a symphony of colors, cultures, and experiences that enrich the soul…"* and *"Explore a world where every horizon promises new wonders and each journey is a tapestry of breathtaking landscapes and timeless moments…"*). No brand names in the copy.
  • Nav: a text logo (fictional — e.g. "Meridian") on the left and a "Sign Up" pill button on the right.
  • Important: .minimap .preview and .images start empty. The JS clears both (innerHTML = "") and generates all thumbnails and full-size images programmatically (see GSAP section). Do not hardcode the images in HTML — the effect depends on the JS build loop.

Styling

Global reset: * { margin:0; padding:0; box-sizing:border-box; }.

Fonts (the original uses two proprietary faces — name them with sane fallbacks):

  • "Neue Montreal" (neutral neo-grotesque sans) for html, body — substitute Inter / Neue Haas / any clean grotesque.
  • "PP Editorial Old" (a high-contrast serif) for the nav link, the button, and the big h1 headings — substitute any elegant editorial serif (e.g. PP Editorial New, Times-ish display serif).

The html, body rule carries only font-family — that's the entire rule: html, body { font-family: "Neue Montreal"; }. It has no background, no background-color, and no color. Text color (#fff) is set per element (on nav a, button, h1, and via color:#fff on .gallery), and the dark backdrop is set per section block (see Palette). Leave html/body transparent.

Palette: near-black #0f0f0f and white #fff text — a dark, minimal editorial look.

> Critical (this breaks the whole component if you get it wrong): the #0f0f0f background belongs to the section blocks.container and .gallery each set their own background:#0f0f0f — and NOT to html, body, or any other root/ancestor element. Do not add a global dark background in a reset/boilerplate (html, body { background:#0f0f0f }, background:#0f0f0f on *, etc.). Here's why it matters: .gallery sits at z-index:-1 (see Key CSS), so it paints beneath its ancestors. If html or body has any background color, that background paints on top of the negatively-stacked gallery and hides the entire minimap + image column — you get a black page showing only the fixed nav ("Meridian" / "Sign Up") and nothing else, even though the GSAP logic runs perfectly. The correct result: html/body stay transparent, and the visible #0f0f0f you see everywhere is contributed piece-by-piece by the .container and .gallery blocks, which stack normally.

Key CSS:

  • nav { position:fixed; width:100%; display:flex; justify-content:space-between; align-items:center; padding:2em; } — floats over the top of everything.
  • nav a { color:#fff; text-decoration:none; }.
  • button { padding:1em 2em; border:1px solid #fff; background:rgba(255,255,255,0.15); border-radius:50px; color:#fff; z-index:10000; } — a translucent white-outlined pill.
  • .container { padding:10em 2em 5em 2em; width:100%; height:100%; background:#0f0f0f; }.
  • h1 { color:#fff; font-family:"PP Editorial Old"; font-size:60px; font-weight:lighter; } — big, thin, elegant serif.
  • .gallery { position:relative; width:100%; display:flex; background:#0f0f0f; color:#fff; padding:5em 2em; z-index:-1; } — the sticky minimap and the image column sit side by side; this block carries its own background:#0f0f0f. (z-index:-1 tucks it beneath the .container blocks so the nav/pill stay above it.)
  • ⚠️ Gotcha — the single most important detail in this component: that z-index:-1 makes .gallery paint below any ancestor that has a background. It works only because html and body are transparent (no background). If you put a background on html/body (a common dark-theme reset habit), it will cover the gallery and the entire effect — minimap, images, and highlight — vanishes behind a flat black page while the JS still runs silently. Keep html/body background-free; let .container and .gallery each own their #0f0f0f.
  • .minimap { position:sticky; top:0; width:20%; height:100vh; padding:2em 0; display:flex; justify-content:center; align-items:center; }this is the sticky rail: it occupies the left 20%, is one viewport tall, and its .preview stack is centered vertically. Because it's position:sticky; top:0 inside the tall .gallery, it stays fixed on screen while the image column scrolls.
  • .images { position:relative; top:0; width:80%; } — the right 80% holds the tall stack of big images.
  • .img { position:relative; width:800px; height:1000px; border-radius:40px; overflow:hidden; margin:50px auto; } — each full-size image is a large rounded 4:5 portrait card, 50px vertical gap between cards, horizontally centered in the column.
  • .img-thumbnail { position:relative; width:50px; height:70px; border-radius:10px; overflow:hidden; opacity:0.5; } — the minimap tile: a tiny 5:7 rounded thumbnail, half-opacity by default. No margins, so the 10 thumbnails stack directly touching, forming a ~700px vertical filmstrip inside the centered .preview.
  • img { width:100%; height:100%; object-fit:cover; } — both full images and thumbnails hard-crop to fill their box.
  • Media query max-width:900px: .img { width:400px; height:500px; } (the big cards shrink; thumbnails stay 50×70).

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

Everything runs inside a single DOMContentLoaded handler.

1. Setup
const numberOfImages = 10;
const minimap = document.querySelector(".minimap .preview");
const fullSizeContainer = document.querySelector(".images");
minimap.innerHTML = "";
fullSizeContainer.innerHTML = "";
let activeThumbnail = null;

activeThumbnail is a module-level pointer to the currently highlighted thumbnail so the code can un-highlight the previous one when a new one activates.

2. Random horizontal jitter helper
function getRandomLeft() {
  const values = [-15, -7.5, 0, 7.5, 15];
  return values[Math.floor(Math.random() * values.length)] + "px";
}

Returns a random horizontal offset from the discrete set {−15, −7.5, 0, 7.5, 15}px. This is applied as an inline style.left to give the otherwise perfectly-aligned column an organic, slightly-scattered feel.

3. Build loop — for i = 1 … 10

For each index:

  1. const randomLeft = getRandomLeft();one offset value is computed per index and applied to both the thumbnail and the matching full image, so a thumbnail and its big image share the same horizontal nudge.
  2. const imagePath = "/c/sticky-image-minimap/img" + i + ".jpg"; (same source image used for both the thumbnail and the full card).
  3. Thumbnail: create div.img-thumbnail, set style.left = randomLeft, append an <img src=imagePath>, append the div into .minimap .preview.
  4. Full image: create div.img, set style.left = randomLeft, append an <img src=imagePath>, append the div into .images.
  5. Create the ScrollTrigger bound to that full image div:
ScrollTrigger.create({
  trigger: imgDiv,
  start: "top center",
  end: "bottom center",
  onToggle: (self) => {
    if (self.isActive) {
      if (activeThumbnail && activeThumbnail !== thumbnailDiv) {
        animateThumbnail(activeThumbnail, false); // un-highlight the previous
      }
      animateThumbnail(thumbnailDiv, true);       // highlight this one
      activeThumbnail = thumbnailDiv;
    } else if (activeThumbnail === thumbnailDiv) {
      animateThumbnail(thumbnailDiv, false);      // leaving center → revert
    }
  },
});
ScrollTrigger config (exact)
  • One ScrollTrigger per full-size image (10 total). No shared timeline, no pin, no scrub.
  • trigger: the full-size .img div.
  • start: "top center" — becomes active when the top of the image reaches the vertical center of the viewport.
  • end: "bottom center" — deactivates when the bottom of the image passes the vertical center. So a trigger is active for the entire span the image straddles the screen's vertical midline.
  • The effect is driven purely by onToggle (fires each time self.isActive flips), not by scroll progress. self.isActive is the boolean state.
  • Toggle logic: on activation, first revert any *other* currently-active thumbnail, then highlight this one and store it as activeThumbnail. On deactivation, revert this thumbnail only if it was the active one. (Because big cards are 1000px tall with only 50px gaps and start/end both key off center, the active window of consecutive images can briefly overlap during fast scrolls — the "revert previous, then activate new" guard keeps exactly one highlighted.)
The highlight tween
function animateThumbnail(thumbnail, isActive) {
  gsap.to(thumbnail, {
    border: isActive ? "1px solid rgba(255, 255, 255, 1)" : "none",
    opacity: isActive ? 1 : 0.5,
    scale: isActive ? 1.3 : 1,
    zIndex: isActive ? 10000 : 1,
    duration: 0.3,
  });
}

Each thumbnail state change is a 0.3s gsap.to (default power1.out ease — no ease specified, no delay, no stagger). Active vs inactive targets:

  • border: 1px solid rgba(255,255,255,1) (crisp white outline) ↔ none.
  • opacity: 10.5.
  • scale: 1.31 (the active tile grows ~30%, overlapping its touching neighbors).
  • zIndex: 100001 (brings the enlarged active tile on top of the others).

There is no other animation anywhere — no reveal on load, no scrub, no parallax. The full-size images do not animate; only the thumbnails react.

Assets / images

10 travel photographs forming one cohesive, editorial, wanderlust set — golden-hour coastlines, mountain vistas, misty valleys, desert dunes, forests, architecture and shorelines, warm and moody. Each source is reused twice (as a big card and as its thumbnail). Both display boxes are portrait-cropped with object-fit:cover (full card = 800×1000 ≈ 4:5; thumbnail = 50×70 ≈ 5:7), so portrait-friendly framing works best, though the cover crop forgives any aspect. Suggested rhythm by role:

  1. Wide golden-hour coastal / landscape, warm tones.
  2. Wide travel landscape.
  3. Tall vertical travel portrait.
  4. Wide scenic landscape.
  5. Tall vertical scenic portrait.
  6. Tall vertical travel portrait.
  7. Tall vertical travel portrait.
  8. Tall vertical scenic portrait.
  9. Wide travel landscape, warm tones.
  10. Square-format travel scene.

Behavior notes

  • Scroll-driven and reversible: scrolling back up re-fires the toggles, so highlights turn on/off going either direction. Nothing autoplays on load — on first paint the minimap shows all 10 thumbnails at rest (0.5 opacity, no border).
  • The sticky minimap is pure CSS (position:sticky; top:0 on a 100vh element inside the tall .gallery); GSAP only touches the thumbnail highlight state.
  • The per-image random left jitter is decorative and re-randomizes on every page load (matches the original).
  • Light/mobile-safe: only 10 cheap ScrollTriggers and short 0.3s tweens; no heavy assets or continuous rAF loop.
  • Reduced-motion: the only motion is the user's own scroll plus brief 0.3s state tweens; it's already calm. If honoring prefers-reduced-motion, you can drop the scale/opacity tween to an instant gsap.set.