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.
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.
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:
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:
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>:
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:
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:
map.on('movestart zoomstart', hide);
map.on('click', hide);