Part of the Responsive Dashboard Layouts guide, which sits inside the broader Python-to-Web Generation Workflows section.
Operative rule: Always override Folium’s inline width and height with !important CSS rules and call invalidateSize() after every layout change — CSS alone cannot notify the Leaflet engine that its container has resized.
How Mobile Breaks Python-Generated Maps
Python visualization libraries such as Folium, Plotly, and Bokeh generate self-contained HTML files designed for Jupyter notebooks and desktop browsers. They hard-code pixel dimensions — typically width: 960px or height: 500px — to guarantee consistent rendering during development. On a phone, three failures appear immediately:
- Horizontal overflow. The fixed width exceeds the viewport, triggering unwanted scroll and breaking touch-pan.
- Broken touch targets. Zoom controls and popups remain desktop-sized, so they are too small to tap reliably.
- Stale tile bounds. When the browser address bar collapses, the device rotates, or a soft keyboard opens, Leaflet retains the original container dimensions, leaving blank gray areas or misaligned overlays.
The solution involves three coordinated layers: stripping the hard-coded dimensions with CSS, switching to dvh/dvw viewport units, and calling the engine’s native invalidateSize() API whenever the layout settles.
The diagram below shows how a resize event travels from the browser through the CSS layer to the Leaflet engine:
Production-Ready Implementation: Folium + Leaflet
The script below generates a Folium map, strips default fixed dimensions, and wraps it in a responsive template. The CSS/JS pattern applies to any Python-to-HTML mapping workflow, not just Folium.
import folium
from jinja2 import Template
# 1. Initialize map with mobile-optimized defaults
m = folium.Map(
location=[40.7128, -74.0060],
zoom_start=12,
control_scale=True,
prefer_canvas=True # Replaces SVG markers with canvas — reduces memory on low-end devices
)
folium.Marker([40.7128, -74.0060], popup="Interactive Marker").add_to(m)
# 2. Extract the HTML/JS bundle Folium generates
raw_html = m._repr_html_()
# 3. Responsive wrapper template
responsive_template = Template("""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<!-- user-scalable=no prevents accidental page zoom conflicting with map pinch-zoom -->
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>Responsive Python Map</title>
<style>
/* dvh recalculates as browser chrome appears/disappears — 100vh does not */
:root { --map-height: 100dvh; }
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; width: 100%; overflow: hidden; }
#map-wrapper {
width: 100%;
height: var(--map-height);
position: relative;
background: #f0f0f0; /* Prevents flash while tiles load */
}
/*
* !important is required because Leaflet writes inline width/height
* directly on .leaflet-container during initialization.
* CSS specificity alone cannot win without it.
*/
.leaflet-container {
width: 100% !important;
height: 100% !important;
}
/* Touch-optimized controls: larger tap area, no accidental text selection */
.leaflet-control-zoom {
margin: 12px !important;
touch-action: manipulation;
}
@media (max-width: 480px) {
/* Scale bar occupies space that is more valuable as map viewport on small screens */
.leaflet-control-scale { display: none; }
}
</style>
</head>
<body>
<div id="map-wrapper">
{{ map_html }}
</div>
<script>
function refreshMap() {
const container = document.querySelector('.leaflet-container');
// _leaflet_map is Leaflet's internal property set on the container element
if (container && container._leaflet_map) {
container._leaflet_map.invalidateSize();
}
}
// Debounce prevents excessive tile requests during continuous resize events
// (pinch-zoom, address-bar animation, split-screen drag)
let resizeTimer;
window.addEventListener('resize', () => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(refreshMap, 150);
});
// iOS Safari repaints the viewport 200-300 ms after orientationchange fires;
// calling invalidateSize() immediately returns stale dimensions
window.addEventListener('orientationchange', () => setTimeout(refreshMap, 300));
// Force recalculation after full page load in case the map initialised
// before its container had settled to its final dimensions
window.addEventListener('load', refreshMap);
</script>
</body>
</html>
""")
# 4. Render and save
final_html = responsive_template.render(map_html=raw_html)
with open("responsive_map.html", "w", encoding="utf-8") as f:
f.write(final_html)
Why each piece is necessary
100dvh vs 100vh: Standard 100vh is calculated against the full browser height before the address bar appears. On mobile Chrome and Safari the address bar collapses when the user scrolls down, expanding the visible area. 100dvh (dynamic viewport height) tracks this change in real time, keeping the map flush with the visible screen rather than hiding 60–80 px behind chrome.
!important on .leaflet-container: Leaflet’s initialization routine writes width and height as inline styles, which carry the highest specificity in CSS. External stylesheets cannot override inline styles without !important. This is safe here because the override is scoped to .leaflet-container and the intent is deliberate.
_leaflet_map property access: Leaflet attaches the map instance to its container DOM element during L.map() initialization. Querying .leaflet-container and reading ._leaflet_map avoids the need to store a reference to the map object in a closure, which matters when Folium generates the initialization script autonomously.
150 ms debounce: Mobile browsers fire resize on every frame during pinch-zoom, address-bar animation, and split-screen drag. Without debouncing, invalidateSize() triggers a tile re-request loop that causes network thrashing and visible jank. 150 ms is the minimum window that allows the layout to stabilize while still feeling immediate to the user.
Alternative: ResizeObserver for Embedded Map Components
When the map is embedded inside a larger Responsive Dashboard Layouts interface rather than filling the full viewport, window.resize events do not fire when only the map’s parent container resizes (for example, when a sidebar collapses). Use ResizeObserver instead:
// Drop this into the <script> block in place of the window resize listener
// when the map is one panel inside a larger dashboard grid
const mapWrapper = document.getElementById('map-wrapper');
const observer = new ResizeObserver(() => {
const container = document.querySelector('.leaflet-container');
if (container && container._leaflet_map) {
// debounceMoveend suppresses the moveend event during resize,
// preventing spurious data-refresh triggers in event-driven dashboards
container._leaflet_map.invalidateSize({ debounceMoveend: true });
}
});
observer.observe(mapWrapper);
ResizeObserver fires when the observed element’s content box changes, regardless of whether the viewport changed. This is the correct approach for dashboards where the map is one panel among several, and it pairs cleanly with the CSS Grid breakpoint system described in Responsive Dashboard Layouts.
Verification Steps
- Open
responsive_map.htmlon a real device (not just browser DevTools). Chrome DevTools mobile emulation does not reproduce iOS Safari’s address bar behaviour or Android’s soft keyboard viewport shift. - Scroll down to collapse the address bar. Confirm the map extends to fill the newly visible space.
- Rotate the device. Tiles should reload within 400 ms with no gray bands at the edges.
- Open a soft keyboard (tap the URL bar or any input on the page). Confirm the map does not overflow or scroll unexpectedly.
- Tap the zoom-in and zoom-out controls. Each button tap should register on the first attempt — if it takes two taps, increase the control margin or the touch target size.
Common Errors and Fixes
Map renders correctly on desktop but shows blank gray tiles on mobile after page load
The map engine initialised before the CSS transitions or DOM animations that set the final container height had completed. The window.load listener calls refreshMap() too early. Fix: replace the load listener with a requestAnimationFrame loop that polls container.offsetHeight and only calls invalidateSize() once it stabilises:
function waitForHeight(container, map, attempts = 0) {
const h = container.offsetHeight;
if (h > 0) {
map.invalidateSize();
} else if (attempts < 20) {
requestAnimationFrame(() => waitForHeight(container, map, attempts + 1));
}
}
window.addEventListener('load', () => {
const container = document.querySelector('.leaflet-container');
if (container && container._leaflet_map) {
waitForHeight(container, container._leaflet_map);
}
});
100dvh has no effect — map is still cut off
dvh requires Chrome 108+, Safari 15.4+, and Firefox 116+. On older mobile WebViews (common in embedded dashboards served inside native apps), fall back to a JavaScript-computed height:
function setMapHeight() {
document.documentElement.style.setProperty(
'--map-height', window.innerHeight + 'px'
);
}
setMapHeight();
window.addEventListener('resize', setMapHeight);
The CSS custom property --map-height already wires into the #map-wrapper height rule, so this fallback requires no other changes.
Pinch-to-zoom on the map triggers page zoom on iOS
The user-scalable=no viewport meta tag is the primary guard, but iOS 10+ ignores it for accessibility reasons. Add touch-action: none directly on the .leaflet-container element to tell the browser to hand all touch gestures to JavaScript rather than native scroll/zoom:
.leaflet-container {
width: 100% !important;
height: 100% !important;
touch-action: none;
}
Note: touch-action: none disables native scrolling inside the container, which is acceptable for a full-screen map but unsuitable if the map is embedded inside a scrollable page. In the latter case use touch-action: pan-x pan-y and rely on Leaflet’s two-finger pan gesture instead.
Popup content is clipped at the bottom of the map on small screens
Leaflet positions popups relative to the map container. On viewports narrower than 375 px, popups anchored to markers near the bottom edge overflow off-screen. Override the max-height of .leaflet-popup-content-wrapper and add overflow-y: auto:
@media (max-width: 480px) {
.leaflet-popup-content-wrapper {
max-height: 45dvh;
overflow-y: auto;
}
}
Related
- Responsive Dashboard Layouts — the parent guide covering CSS Grid breakpoints,
ResizeObserverintegration, and full dashboard architecture - Safely Embedding Folium Maps in React Dashboards — when the map lives inside a React component, use a sandboxed iframe and
postMessagerather than direct DOM injection - Iframe Embedding & Isolation — security headers, CSP configuration, and cross-origin messaging patterns for Python-generated map bundles
- Static vs Dynamic Export Methods — choosing between a fully embedded HTML asset and a server-rendered tile endpoint affects how you handle cache busting and mobile performance budgets