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:hiddenclips the strip;padding-top:300pxpushes 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 istranslateX(-50%)and the JS re-appends atranslateY(...)..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:differenceinverts 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; }(atmax-width:900px→width:400px; height:500px)..item-img { width:500px; height:550px; }(the photo; the remaining ~50px of the item is the.item-copycaption 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.offsetHeightis100vh. Since the strip (1254px) is taller than the viewport,minimap.offsetHeight − previewHeightis negative, sopreviewMaxTranslateis a negative number (the strip's maximum *upward* travel). The2.84is 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 (
0when the gallery top hits the viewport top,1when its bottom is one viewport above) maps linearly onto the strip'stranslateY, from0down topreviewMaxTranslate(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
previewMaxTranslateis negative andMath.min(previewMaxTranslate, scrollRange)therefore returns it,previewScrollRangeis that negative travel; multiplying by the0→1fraction slides the strip upward through its ten thumbnails. - Clamps: above the gallery (
scrollPosition < imagesStart) the strip is pinned attranslateY(0)(thumbnail 1 framed); below the gallery it's pinned attranslateY(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:stickymeans it stays fixed on screen for the entire gallery, so the moving strip + stationary.active-img-indicatorread 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 atscrollY = 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-imgslot (500×550, a nearly-square-to-portrait crop) and once small as an.item-previewthumbnail (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
.containersection.
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 noresizere-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.84multiplier 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:900pxthe 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.