Copied!
Navigation and lists

Hover preview with minimap

A list where hovering or focusing an item shows a preview panel and slides a springy highlight along a small minimap.

← Library

Element · Vanilla JS + CSSUpdated November 6, 2026

CursorElementVanilla JS + CSS

Live preview needs JavaScript. The code below works without any dependencies.

Prompt for your AI assistant

Paste this into Claude, Cursor or ChatGPT. The settings update as you move the controls above.

Build "Hover preview with minimap" for my site using the Enrichr Library element at https://enrichr.dev/elements/hover-preview-minimap.html.

Read the machine-readable spec and source at https://enrichr.dev/elements/hover-preview-minimap.json (markup, CSS, JavaScript, parameters).
Requirements: vanilla JavaScript and CSS, no new dependencies; keep the behaviour of the source unchanged; respect prefers-reduced-motion; work on touch screens; keep it accessible.

Settings:
- accent: #e6ff55
- background: #0c1e18
- stiffness: 0.18
- damping: 0.72
- count: 5

Verify: reduced motion shows no movement, it works at 375px wide, there are no console errors, and scrolling stays smooth.

The same spec as JSON, for tools that read it directly: /elements/hover-preview-minimap.json.

Code

Mount it on an element that contains the markup. It returns an object with update(options) and destroy().

HTML

<div class="ef-hpm">
  <div class="ef-hpm__wrap">
    <ul class="ef-hpm__list" role="list"></ul>
    <div class="ef-hpm__side">
      <div class="ef-hpm__prev" aria-live="polite"><div class="ef-hpm__art"></div><div class="ef-hpm__cap"><span class="ef-hpm__ct"></span><span class="ef-hpm__cs"></span></div></div>
      <div class="ef-hpm__map" aria-hidden="true"><span class="ef-hpm__hl"></span></div>
    </div>
  </div>
</div>

CSS

/* Enrichr Library · https://enrichr.dev */
.ef-hpm { width: 100%; }
.ef-hpm__wrap { --acc: #e6ff55; box-sizing: border-box; position: relative; overflow: hidden; width: 100%; min-height: 20rem; padding: 1.2rem; border-radius: 1rem; display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.1fr); gap: 1.2rem; align-items: stretch; background: #0c1e18; color: #fbf8f1; font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
.ef-hpm__list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; justify-content: center; }
.ef-hpm__item { display: flex; align-items: baseline; gap: 0.7rem; width: 100%; box-sizing: border-box; padding: 0.55rem 0.4rem; border: 0; border-bottom: 1px solid rgba(251, 248, 241, 0.12); background: none; color: inherit; font: inherit; text-align: left; cursor: pointer; opacity: 0.55; transition: opacity 250ms, transform 350ms cubic-bezier(.2,.8,.2,1); outline: none; border-radius: 0.3rem; }
.ef-hpm__item.is-active { opacity: 1; transform: translateX(0.4rem); }
.ef-hpm__item:focus-visible { box-shadow: 0 0 0 2px var(--acc); }
.ef-hpm__n { font-size: 0.66rem; font-weight: 700; letter-spacing: 0.14em; color: var(--acc); }
.ef-hpm__t { font-size: clamp(1.05rem, 3.4vw, 1.5rem); font-weight: 800; letter-spacing: -0.02em; line-height: 1.1; }
.ef-hpm__side { position: relative; display: flex; gap: 0.7rem; min-height: 0; }
.ef-hpm__prev { position: relative; flex: 1; min-height: 14rem; border-radius: 0.9rem; overflow: hidden; }
.ef-hpm__art { position: absolute; inset: 0; transition: opacity 250ms; }
.ef-hpm__art.is-swap { opacity: 0.25; }
.ef-hpm__prev::after { content: ""; position: absolute; inset: 0; background: linear-gradient(180deg, transparent 50%, rgba(0, 0, 0, 0.65)); }
.ef-hpm__cap { position: absolute; z-index: 1; left: 0.9rem; right: 0.9rem; bottom: 0.8rem; display: flex; flex-direction: column; gap: 0.15rem; }
.ef-hpm__ct { font-size: 1.1rem; font-weight: 800; letter-spacing: -0.01em; }
.ef-hpm__cs { font-size: 0.72rem; letter-spacing: 0.06em; opacity: 0.8; }
.ef-hpm__map { position: relative; flex: none; width: 1.1rem; display: flex; flex-direction: column; gap: 3px; padding: 3px; box-sizing: border-box; border-radius: 0.5rem; background: rgba(251, 248, 241, 0.08); }
.ef-hpm__tick { flex: 1; border-radius: 3px; background: rgba(251, 248, 241, 0.25); }
.ef-hpm__hl { position: absolute; left: 2px; right: 2px; top: 3px; height: 1rem; border-radius: 4px; background: var(--acc); box-shadow: 0 0 12px -2px var(--acc); will-change: transform; transform: translateY(0); }
@media (max-width: 30rem) { .ef-hpm__wrap { grid-template-columns: 1fr; } .ef-hpm__list { flex-direction: row; flex-wrap: wrap; } .ef-hpm__item { width: auto; } }
@media (prefers-reduced-motion: reduce) { .ef-hpm__item, .ef-hpm__art { transition: none; } .ef-hpm__item.is-active { transform: none; } }

JavaScript

// Enrichr Library · https://enrichr.dev
// Hover preview with minimap: a list of items where hovering or focusing one swaps a preview panel and slides a springy highlight along a
// small minimap. The spring is a tiny damped-spring step on an rAF loop that stops itself when settled. Reduced motion snaps instead.
// mount(root, { background, accent, stiffness (0.05-0.5), damping (0.4-0.95), count (3-7) })
function mount(root, o) {
  var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
  var wrap = root.querySelector(".ef-hpm__wrap");
  var list = root.querySelector(".ef-hpm__list");
  var art = root.querySelector(".ef-hpm__art");
  var ct = root.querySelector(".ef-hpm__ct");
  var cs = root.querySelector(".ef-hpm__cs");
  var map = root.querySelector(".ef-hpm__map");
  var hl = root.querySelector(".ef-hpm__hl");
  var opts = {}, items = [], offs = [], active = 0, raf = 0, pos = 0, vel = 0, target = 0, swapT = 0;
  var DATA = [["Harbour", "Photo series, 2024"], ["Kiln", "Ceramics studio"], ["Orbit", "Motion identity"], ["Fern", "Botanical print"], ["Signal", "Sound design"], ["Quarry", "Architecture study"], ["Lantern", "Night editorial"]];

  function bg(i, a) {
    var d = "#0c1e18", c = "#fbf8f1", p = i % 7;
    if (p === 0) return "radial-gradient(circle at 70% 30%, " + a + " 0 16%, transparent 17%), linear-gradient(160deg, #0a6b46, " + d + ")";
    if (p === 1) return "conic-gradient(from 200deg at 40% 60%, #005236, " + a + ", " + c + ", #005236)";
    if (p === 2) return "repeating-linear-gradient(125deg, #005236 0 14px, " + d + " 14px 28px)";
    if (p === 3) return "radial-gradient(circle at 30% 35%, " + a + " 0 18%, transparent 19%), radial-gradient(circle at 64% 64%, " + c + " 0 16%, transparent 17%), linear-gradient(200deg, #0a6b46, " + d + ")";
    if (p === 4) return "radial-gradient(circle, " + a + " 0 2.5px, transparent 3px) 0 0 / 16px 16px, linear-gradient(145deg, #005236, " + d + ")";
    if (p === 5) return "linear-gradient(90deg, transparent 49%, " + a + " 49% 51%, transparent 51%), linear-gradient(190deg, #005236, " + d + ")";
    return "radial-gradient(ellipse at 50% 100%, " + a + " 0 25%, transparent 26%), linear-gradient(180deg, " + d + ", #0a6b46)";
  }
  function clear() {
    offs.forEach(function (f) { f(); }); offs = [];
    if (raf) cancelAnimationFrame(raf);
    raf = 0; clearTimeout(swapT);
  }
  function slot() { return (map.clientHeight - 6 - 3 * (items.length - 1)) / items.length; }
  function place() {
    var s = slot();
    hl.style.height = Math.max(6, s) + "px";
    target = active * (s + 3);
    if (reduce) { pos = target; vel = 0; hl.style.transform = "translateY(" + pos + "px)"; return; }
    if (!raf) raf = requestAnimationFrame(step);
  }
  function step() {
    raf = 0;
    vel = (vel + (target - pos) * opts.stiffness) * opts.damping;
    pos += vel;
    hl.style.transform = "translateY(" + pos.toFixed(2) + "px)";
    if (Math.abs(target - pos) > 0.05 || Math.abs(vel) > 0.05) raf = requestAnimationFrame(step);
    else { pos = target; hl.style.transform = "translateY(" + pos + "px)"; }
  }
  function show(i) {
    active = i;
    items.forEach(function (b, k) { b.classList.toggle("is-active", k === i); b.setAttribute("aria-current", k === i ? "true" : "false"); });
    var d = DATA[i % DATA.length];
    ct.textContent = d[0]; cs.textContent = d[1];
    if (!reduce) { art.classList.add("is-swap"); clearTimeout(swapT); swapT = setTimeout(function () { art.classList.remove("is-swap"); }, 60); }
    art.style.background = bg(i, opts.accent);
    place();
  }
  function build() {
    clear();
    list.textContent = ""; map.textContent = ""; map.appendChild(hl); items = [];
    for (var i = 0; i < opts.count; i++) (function (i) {
      var li = document.createElement("li");
      var b = document.createElement("button");
      b.type = "button"; b.className = "ef-hpm__item";
      var n = document.createElement("span"); n.className = "ef-hpm__n"; n.textContent = String(i + 1).padStart(2, "0");
      var t = document.createElement("span"); t.className = "ef-hpm__t"; t.textContent = DATA[i % DATA.length][0];
      b.appendChild(n); b.appendChild(t);
      var on = function () { show(i); };
      b.addEventListener("pointerenter", on); b.addEventListener("focus", on); b.addEventListener("click", on);
      offs.push(function () { b.removeEventListener("pointerenter", on); b.removeEventListener("focus", on); b.removeEventListener("click", on); });
      li.appendChild(b); list.appendChild(li); items.push(b);
      var tk = document.createElement("span"); tk.className = "ef-hpm__tick"; map.insertBefore(tk, hl);
    })(i);
    active = Math.min(active, items.length - 1);
    var onRs = function () { place(); };
    window.addEventListener("resize", onRs);
    offs.push(function () { window.removeEventListener("resize", onRs); });
    pos = active * (slot() + 3);
    show(active);
  }
  function update(next) {
    var prev = opts;
    opts = Object.assign({ background: "#0c1e18", accent: "#e6ff55", stiffness: 0.18, damping: 0.72, count: 5 }, opts, next || {});
    opts.count = Math.max(3, Math.min(7, Math.round(opts.count)));
    wrap.style.background = opts.background;
    wrap.style.setProperty("--acc", opts.accent);
    if (prev.count !== opts.count) build(); else show(active);
  }
  update(o);
  return { update: update, destroy: clear };
}

// Usage
var fx = mount(document.querySelector("#my-root"), {"accent":"#e6ff55","background":"#0c1e18","stiffness":0.18,"damping":0.72,"count":5});

How it works

Each list item is a button. Pointer enter, focus and click call one function that marks the item active, swaps the preview art and caption, and sets a target position for the minimap highlight. The highlight follows that target with a small damped spring stepped on requestAnimationFrame: velocity gains stiffness times the distance, then is multiplied by damping. The loop stops itself once it settles, so nothing runs while idle.

Accessibility and performance

Items are real buttons, so Tab moves through them and focus shows the same preview as hover. The active item is marked with aria-current and the preview caption is in a polite live region. The minimap is decorative and hidden from assistive tech. Reduced motion snaps the highlight and removes fades.

One transform write per frame while the spring moves and nothing at rest. The rAF handle and listeners are cleared in destroy(). Preview art is CSS gradients, so there is nothing to load.

SectionNavigation and lists
TechniquesPointer interaction, Reveal and intro
StackVanilla JS, CSS
Works withReact, Next.js, Vanilla
DependenciesNone
Statustested
Inspired byHover Preview Effect with Mini Map by Codrops on Codrops. Original code, written from scratch.

Find this technique on a real site

Want to see this technique in the wild? Check any site with the free tools, then build your own version from this page.

Check which animation library a site uses · Extract a site's design tokens · Browse site breakdowns

Licence and provenance

Free to use in personal and client projects during the preview. Final licence terms will be published here before any paid tier starts; ask before reselling the code as a collection.

This is original code. The technique is a common one that we studied by measuring award-winning sites with Enrichr; no third-party code, shaders, fonts or images are used.

Clip reveal menuText rows that open a generated pattern behind the word with a clip-path transition on hover, while the word inverts along the edge. Image compare on scrollA before and after slider whose divider is scrubbed by scroll position and can also be dragged or keyboard-operated. Unroll image on scrollA poster that unrolls like a sheet of paper from a spiral roll as you scroll or hover.

FAQ

Can the preview use real images?

Yes. Replace the art background with an image url in the show function, and keep the gradient scrim on top for caption contrast.

How do I make the spring bouncier?

Raise stiffness or lower damping. Values near 0.3 and 0.6 overshoot noticeably.

Does it work on touch?

Tapping an item fires click, which selects it and updates the preview and minimap.