Responsive Minimap Image Scrubber (vertical strip on desktop, horizontal on mobile)
Goal
Build a full-viewport gallery navigator where a minimap of 15 small thumbnails (a vertical strip on the right on desktop, a horizontal strip near the bottom on mobile) is scrubbed with the mouse wheel / touch drag: a fixed 1px-bordered indicator frame stays put while the thumbnail strip glides underneath it with lerped inertia; whichever thumbnail overlaps the indicator the most is dimmed to 30% opacity and instantly swapped into a large centered preview image. Clicking any thumbnail eases the strip so that thumbnail lands centered inside the indicator, using a slower (softer) lerp. The star effect is the buttery lerp-driven scrubbing of the strip plus the live preview swap.
Tech
Vanilla HTML/CSS/JS with an ES module script (<script type="module" src="./script.js">). No GSAP, no plugins, no Lenis, no libraries at all — the entire effect is a requestAnimationFrame loop with manual linear interpolation (lerp) writing transform: translateX/Y on the strip, plus wheel, touchstart/touchmove, click and resize listeners. There is no page scroll — the wheel is hijacked (preventDefault) to drive the minimap.
Layout / HTML
.container (full-viewport stage)
nav (fixed top bar)
p "Motionprompts"
p "Menu"
.site-info
p "E427"
p > span "Responsive Minimap"
.img-preview
img (large preview, src = image 1 initially)
.minimap
.indicator (empty framed box, the fixed "lens")
.items
.item > img × 15 (thumbnails, images 1…15 in order)
Styling
Font: "Neue Haas Grotesk Display Pro" on body (a neutral grotesque sans; no import needed — system fallback is fine).
* { margin:0; padding:0; box-sizing:border-box; }img { width:100%; height:100%; object-fit:cover; transition: opacity 0.2s; user-select:none; }— thetransition: opacity 0.2son all images is what makes the active-thumbnail dim fade smoothly.p { font-size:14px; font-weight:600; -webkit-font-smoothing:antialiased; user-select:none; }.container:position:relative; width:100vw; height:100vh; overflow:hidden; background-color:#f1efe7;(warm off-white / bone).nav:position:fixed; top:0; left:0; width:100vw; padding:1.5em; display:flex; justify-content:space-between; align-items:center;.site-info:position:absolute; top:50%; left:1.5em; display:flex; gap:4px;— thespan("Responsive Minimap") iscolor:#9a9994(muted grey)..img-preview:position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); width:50%; height:75%; overflow:hidden;.img-preview img:position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); width:100%; height:100%; object-fit:contain;— contain, not cover, so the whole photo is always visible..minimap:position:absolute; top:50%; right:8em; width:80px;(note: NO translateY — the strip's top edge starts at the vertical middle of the screen and the strip extends downward from there)..indicator:position:absolute; top:0; left:0; width:100%; height:60px; border:1px solid #000; z-index:2;— a fixed hairline rectangle sitting over the first visible thumbnail slot..items:position:relative; width:100%; height:100%; display:flex; flex-direction:column; gap:0; will-change:transform;.item:width:100%; height:60px; padding:5px; cursor:pointer;— the 5px padding creates the visual gaps between thumbnails and insets them inside the indicator frame.
Mobile (@media (max-width: 900px))
bodyand.container:overflow:hidden; touch-action:none;.site-info:top:1.5em; left:50%; transform:translateX(-50%);(moves to top-center)..minimap:top:auto; right:auto; bottom:5em; left:50%; width:auto; height:80px; touch-action:none;(note: NO translateX — the strip's left edge starts at the horizontal middle and extends rightward)..indicator:width:60px; height:100%;.items:flex-direction:row; width:max-content; height:100%; touch-action:none;.item:width:60px; height:100%; padding:5px;.img-preview:top:45%; width:75%; height:50%;
The effect (exhaustive — vanilla JS, lerp + rAF)
Orientation & dimensions
isHorizontal = window.innerWidth <= 900(mobile = horizontal strip, desktop = vertical strip).updateDimensions()recomputes and returns:- desktop (vertical):
itemSize = firstItem.getBoundingClientRect().height(60),containerSize = items.getBoundingClientRect().height,indicatorSize = indicator.getBoundingClientRect().height(60). - mobile (horizontal):
itemSize = firstItem.getBoundingClientRect().width(60),containerSize = items.scrollWidth(scrollWidth, because the row overflows),indicatorSize = indicator.getBoundingClientRect().width(60). maxTranslate = containerSize - indicatorSize— the farthest the strip may slide (in the negative direction).
State
currentTranslate = 0(rendered offset),targetTranslate = 0(goal offset), both always clamped to[-maxTranslate, 0].isClickMove = false— true while a click-initiated glide is in flight (selects the slower lerp factor).currentImageIndex = 0,activeItemOpacity = 0.3.lerp(start, end, factor) = start + (end - start) * factor.
rAF loop animate() (runs forever)
lerpFactor = isClickMove ? 0.05 : 0.075— click glides are noticeably slower/softer than wheel scrubs.currentTranslate = lerp(currentTranslate, targetTranslate, lerpFactor).- If
Math.abs(currentTranslate - targetTranslate) > 0.01: - write the transform on
.items:translateY(${currentTranslate}px)on desktop,translateX(${currentTranslate}px)on mobile; - call
getItemInIndicator()to find the active thumbnail, thenupdatePreviewImage(activeIndex). - Else (settled):
isClickMove = false. The loop itself never stops —requestAnimationFrame(animate)every frame.
getItemInIndicator() — max-overlap picking + dim
- Reset ALL thumbnail
<img>s toopacity = 1. - The indicator's window in strip-space:
indicatorStart = -currentTranslate,indicatorEnd = indicatorStart + indicatorSize. - For each item
index:itemStart = index * itemSize,itemEnd = itemStart + itemSize; overlap =max(0, min(indicatorEnd, itemEnd) - max(indicatorStart, itemStart)). Track the index with the greatest overlap. - Set that item's
<img>toopacity = 0.3(fades over the CSS 0.2s transition) and return the index.
updatePreviewImage(index)
Only when index !== currentImageIndex: store it, read the winning thumbnail's src attribute and set it on the big .img-preview img — an instant src swap (no crossfade).
Wheel input (desktop scrub)
container.addEventListener("wheel", handler, { passive: false }):
e.preventDefault();isClickMove = false.scrollVelocity = clamp(e.deltaY * 0.5, -20, 20)— half the wheel delta, capped at ±20px per event.targetTranslate = clamp(targetTranslate - scrollVelocity, -maxTranslate, 0)— scrolling down slides the strip up (negative translate).
Touch input (mobile only)
touchstart: ifisHorizontal, recordtouchStartY = e.touches[0].clientY.touchmove({ passive: false }): ifisHorizontal,deltaY = touchStartY - touchY, then the exact same velocity math as the wheel (clamp(deltaY * 0.5, -20, 20)subtracted fromtargetTranslate, clamped to[-maxTranslate, 0]); updatetouchStartY = touchYeach move ande.preventDefault(). Note: a vertical finger drag drives the horizontal strip.
Click-to-center
On each .item click:
isClickMove = true(slower 0.05 lerp for a gentle glide);targetTranslate = -index * itemSize + (indicatorSize - itemSize) / 2, then clamp to[-maxTranslate, 0]— this parks the clicked thumbnail exactly inside the indicator frame.
Resize
On window.resize: re-run updateDimensions() (also refreshes isHorizontal), recompute maxTranslate, clamp targetTranslate to the new range, snap currentTranslate = targetTranslate (no glide), and immediately write the transform in the correct axis for the new orientation.
Init (bottom of the module)
- Dim thumbnail 0:
itemImages[0].style.opacity = 0.3. updatePreviewImage(0)(preview starts on image 1).- Start
animate().
Assets / images
15 portrait editorial photographs, aspect ratio ~3:4 (e.g. 900×1200) — a cohesive fashion/lifestyle series with varied subjects, colors and settings so consecutive thumbnails are clearly distinguishable at 70×50px. Each image plays two roles: minimap thumbnail (cropped by object-fit: cover inside its 60px cell) and, when active, the large centered preview (letterboxed by object-fit: contain). Name them img1.jpeg … img15.jpeg and reference each exactly twice: once in its .item and (for img1.jpeg only) as the initial .img-preview src. No real brand names anywhere — use the neutral demo strings from the Layout section.
Behavior notes
- The page never scrolls: the container fills the viewport,
overflow: hidden, and the wheel/touch events are captured withpreventDefaultto drive the minimap only. - Fully responsive at the 900px breakpoint: desktop = vertical strip on the right driven by wheel; mobile = horizontal strip bottom-center driven by vertical touch drags. Resizing across the breakpoint keeps working (dimensions and axis are recomputed).
- The strip only moves between
0(first item under the indicator) and-maxTranslate(last item under the indicator) — it can never scrub past either end. - No
prefers-reduced-motionbranch. The only "loop" is the always-running rAF lerp; when settled (delta ≤ 0.01) it simply stops writing to the DOM. - Because indicator and item are the same size (60px), the click-centering term
(indicatorSize - itemSize) / 2evaluates to 0 — keep the formula anyway so it stays correct if sizes diverge.