← all posts · javascript

Adding a hover-preview tooltip to Leaflet markers

How to build a floating thumbnail tooltip for Leaflet photo markers — shared DOM element, edge-flip positioning, hover delays, and keyboard accessibility.

01 May 2026 · 4 min read · Stephen Masters javascriptleaflet

Leaflet’s bindTooltip is fine for text labels but limited for richer previews. This is how to build a floating thumbnail tooltip that appears when hovering a photo marker, stays within the map bounds, and works with the keyboard.

One element, not many

The natural instinct is to create a tooltip element per marker. Don’t. With many markers on the map, that’s many hidden elements in the DOM, each needing positioning logic run on every hover.

A better approach: one shared element, repositioned and repopulated on demand.

javascript
123456789101112
var tip = document.createElement('div');
tip.className = 'velo-preview';
tip.setAttribute('aria-hidden', 'true');
tip.innerHTML =
  '<img class="velo-preview__img" alt="" />' +
  '<div class="velo-preview__body">' +
  '<div class="velo-preview__caption"></div>' +
  '</div>';
map.getContainer().appendChild(tip);

var tipImg     = tip.querySelector('.velo-preview__img');
var tipCaption = tip.querySelector('.velo-preview__caption');

Append it to the map container, not the document body, so position coordinates are relative to the map.


Hover delays

Firing immediately on mouseenter feels jittery — graze across a cluster of markers and tooltips flash in and out. A short delay smooths this out:

javascript
12345678910111213141516171819202122
var HOVER_IN_DELAY  = 80;   // ms before showing
var HOVER_OUT_DELAY = 200;  // ms before hiding

var hoverInTimer  = null;
var hoverOutTimer = null;
var activeUrl     = null;

function scheduleShow(m, btnEl) {
  clearTimeout(hoverOutTimer);
  clearTimeout(hoverInTimer);
  if (activeUrl !== null) {
    showFor(m, btnEl); // already showing something — swap immediately
  } else {
    hoverInTimer = setTimeout(function () { showFor(m, btnEl); }, HOVER_IN_DELAY);
  }
}

function scheduleHide() {
  clearTimeout(hoverInTimer);
  clearTimeout(hoverOutTimer);
  hoverOutTimer = setTimeout(hide, HOVER_OUT_DELAY);
}

When moving between adjacent markers, activeUrl !== null causes an immediate swap rather than waiting for the in-delay again. The out-delay gives the user a moment to move from the marker to the tooltip without it disappearing.


Edge-flip positioning

Anchoring the tooltip at a fixed offset from the marker breaks near the edges of the map. Measure the tooltip dimensions and flip when it would overflow:

javascript
123456789101112131415161718192021222324252627282930313233343536373839
function showFor(m, btnEl) {
  // Populate content
  tipImg.src = m.thumb;
  tipImg.alt = m.caption;
  tipCaption.textContent = m.caption;

  // Measure marker position relative to map container
  var containerRect = map.getContainer().getBoundingClientRect();
  var btnRect       = btnEl.getBoundingClientRect();
  var mx = btnRect.left - containerRect.left + btnRect.width  / 2;
  var my = btnRect.top  - containerRect.top  + btnRect.height / 2;

  // Measure tooltip height while invisible
  tip.style.visibility = 'hidden';
  tip.classList.add('is-visible');
  var th = tip.offsetHeight || 168;
  tip.classList.remove('is-visible');
  tip.style.visibility = '';

  var W  = containerRect.width;
  var H  = containerRect.height;
  var TW = 200; // fixed tooltip width from CSS

  var tx = mx + 14;
  var ty = my - th - 12;

  if (tx + TW > W - 8) { tx = mx - TW - 14; } // flip left
  if (ty < 8)          { ty = my + 14; }        // flip below

  // Clamp within container
  tx = Math.max(8, Math.min(W - TW - 8, tx));
  ty = Math.max(8, Math.min(H - th  - 8, ty));

  tip.style.left = tx + 'px';
  tip.style.top  = ty + 'px';
  tip.classList.add('is-visible');
  tip.setAttribute('aria-hidden', 'false');
  activeUrl = m.url;
}

The key step is measuring the tooltip’s height while it’s invisible. Apply the is-visible class (which gives it display: block or equivalent), read offsetHeight, then remove it before setting the final position and showing it for real. Without this, the height measurement returns 0 and vertical positioning is wrong.


Button markers for keyboard access

Change the marker inner element from a <div> to a <button>:

javascript
12345678
icon: L.divIcon({
  className: 'photo-marker',
  html: '<button class="photo-marker-label" type="button" ' +
        'aria-label="Photo ' + (i + 1) + ': ' + escapeHtml(m.caption) + '">' +
        (i + 1) + '</button>',
  iconSize:   [22, 22],
  iconAnchor: [11, 11]
})

A <button> is focusable by default, responds to Enter and Space, and exposes a role of button to screen readers. Wire focus/blur to the same show/hide functions as mouseenter/mouseleave and the tooltip works with keyboard navigation for free.


Prefetch thumbnails

Hover-in delay is 80ms, but image loading might take longer on a slow connection, producing a blank flash in the tooltip. Prefetch all thumbnail URLs on map load:

javascript
123
markers.forEach(function (m) {
  if (m.thumb) { var img = new Image(); img.src = m.thumb; }
});

The browser caches the images. By the time the hover fires and tipImg.src is set, the image is already available — the tooltip appears populated.


Dismiss on pan and zoom

The tooltip’s position is calculated relative to a static marker position. When the map moves, the marker moves but the tooltip doesn’t — it hangs in the wrong place. Dismiss it:

javascript
12
map.on('movestart zoomstart', hide);
map.on('click', hide);
SM
Stephen Masters

Software developer and architect. I build systems for places that move energy, commodities, and money around. I keep a bike-packing journal at velostevie.com.