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
.containerblocks (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 .previewand.imagesstart 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
h1headings — 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 ownbackground:#0f0f0f. (z-index:-1tucks it beneath the.containerblocks so the nav/pill stay above it.)- ⚠️ Gotcha — the single most important detail in this component: that
z-index:-1makes.gallerypaint below any ancestor that has a background. It works only becausehtmlandbodyare transparent (nobackground). If you put a background onhtml/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. Keephtml/bodybackground-free; let.containerand.galleryeach 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.previewstack is centered vertically. Because it'sposition:sticky; top:0inside 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:
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.const imagePath = "/c/sticky-image-minimap/img" + i + ".jpg";(same source image used for both the thumbnail and the full card).- Thumbnail: create
div.img-thumbnail, setstyle.left = randomLeft, append an<img src=imagePath>, append the div into.minimap .preview. - Full image: create
div.img, setstyle.left = randomLeft, append an<img src=imagePath>, append the div into.images. - 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.imgdiv.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 timeself.isActiveflips), not by scroll progress.self.isActiveis 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 offcenter, 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:1↔0.5.scale:1.3↔1(the active tile grows ~30%, overlapping its touching neighbors).zIndex:10000↔1(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:
- Wide golden-hour coastal / landscape, warm tones.
- Wide travel landscape.
- Tall vertical travel portrait.
- Wide scenic landscape.
- Tall vertical scenic portrait.
- Tall vertical travel portrait.
- Tall vertical travel portrait.
- Tall vertical scenic portrait.
- Wide travel landscape, warm tones.
- 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:0on a 100vh element inside the tall.gallery); GSAP only touches the thumbnail highlight state. - The per-image random
leftjitter 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 instantgsap.set.