All components

JavaScript Responsive Minimap

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

Open live demo ↗ Raw prompt (.md)

What it does

A vertical thumbnail minimap (horizontal on mobile) whose framed indicator scrubs through 15 images as you scroll the wheel or drag on touch; a requestAnimationFrame loop lerps the strip toward its target offset, dims the thumbnail with the greatest overlap inside the indicator, and swaps that image into the large centered preview. Clicking a thumbnail eases the strip to center it with a slower lerp factor.

How it's built

Categorygallery
Techvanilla JS
Complexitypage
Performance costlight
Mobile-safeyes

minimap gallery scroll lerp requestanimationframe vanilla-js responsive image-preview interactive

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

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; }the transition: opacity 0.2s on 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; — the span ("Responsive Minimap") is color:#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))
  • body and .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)
  1. lerpFactor = isClickMove ? 0.05 : 0.075 — click glides are noticeably slower/softer than wheel scrubs.
  2. currentTranslate = lerp(currentTranslate, targetTranslate, lerpFactor).
  3. If Math.abs(currentTranslate - targetTranslate) > 0.01:
  4. write the transform on .items: translateY(${currentTranslate}px) on desktop, translateX(${currentTranslate}px) on mobile;
  5. call getItemInIndicator() to find the active thumbnail, then updatePreviewImage(activeIndex).
  6. Else (settled): isClickMove = false. The loop itself never stops — requestAnimationFrame(animate) every frame.
getItemInIndicator() — max-overlap picking + dim
  1. Reset ALL thumbnail <img>s to opacity = 1.
  2. The indicator's window in strip-space: indicatorStart = -currentTranslate, indicatorEnd = indicatorStart + indicatorSize.
  3. 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.
  4. Set that item's <img> to opacity = 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: if isHorizontal, record touchStartY = e.touches[0].clientY.
  • touchmove ({ passive: false }): if isHorizontal, deltaY = touchStartY - touchY, then the exact same velocity math as the wheel (clamp(deltaY * 0.5, -20, 20) subtracted from targetTranslate, clamped to [-maxTranslate, 0]); update touchStartY = touchY each move and e.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)
  1. Dim thumbnail 0: itemImages[0].style.opacity = 0.3.
  2. updatePreviewImage(0) (preview starts on image 1).
  3. 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.jpegimg15.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 with preventDefault to 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-motion branch. 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) / 2 evaluates to 0 — keep the formula anyway so it stays correct if sizes diverge.