Refit the Map on Resize in Maptoolkit Maps JS
A route framed on a desktop is cropped on a phone, and a map inside a collapsing sidebar ends up showing the wrong thing entirely. Restoring the saved camera is not the fix, because the same centre and zoom cover a different area in a different shaped box. The fit has to be recomputed from the data.
const API_KEY = 'YOUR_API_KEY';
const PLACES = [
['Hungerburg', 11.3937, 47.2830],
['Seegrube', 11.3790, 47.3010],
['Hafelekar', 11.3810, 47.3120],
['Arzler Alm', 11.4160, 47.2960],
['Hoettinger Alm', 11.3520, 47.2960]
];
const map = new maptoolkit.Map({
container: 'map',
apiKey: API_KEY,
style: `https://styles.maptoolkit.net/maptoolkit/maptoolkit.hiking.json?api_key=${API_KEY}`,
center: [11.385, 47.295],
zoom: 12,
attributionControl: { compact: false }
});
map.addControl(new maptoolkit.NavigationControl(), 'top-right');
// Grow a bounds from the data rather than hard-coding one.
function bounds() {
return PLACES.reduce(
(b, [, lng, lat]) => b.extend([lng, lat]),
new maptoolkit.LngLatBounds([PLACES[0][1], PLACES[0][2]], [PLACES[0][1], PLACES[0][2]])
);
}
const PANEL_WIDTH = 230;
// Padding is how you tell fitBounds which part of the canvas is actually free. The
// panel covers the left edge, so that edge needs the panel's width added to it.
function fit(animate) {
const open = document.getElementById('sidebar').classList.contains('open');
map.fitBounds(bounds(), {
padding: { top: 56, right: 56, bottom: 56, left: 56 + (open ? PANEL_WIDTH : 0) },
maxZoom: 15,
animate,
duration: 400
});
}
map.on('load', () => {
map.addLayer({
id: 'places',
type: 'circle',
source: {
type: 'geojson',
data: {
type: 'FeatureCollection',
features: PLACES.map(([name, lng, lat]) => ({
type: 'Feature',
properties: { name },
geometry: { type: 'Point', coordinates: [lng, lat] }
}))
}
},
paint: {
'circle-radius': 8, 'circle-color': '#2171b5',
'circle-stroke-color': '#fff', 'circle-stroke-width': 2
}
});
document.getElementById('list').innerHTML =
PLACES.map(([name]) => `<li>${name}</li>`).join('');
fit(false);
// ResizeObserver catches every cause of a real size change: the window, a flex
// reflow, a print layout, a container animating. A window resize listener catches
// only the first of those.
let settle = null;
const observer = new ResizeObserver(() => {
map.resize();
// Debounced, because a container that animates fires this on every frame and
// each fitBounds starts a 400 ms camera move. Seventeen of them interrupting
// one another over a 280 ms transition reads as flashing, not as motion.
clearTimeout(settle);
settle = setTimeout(() => fit(true), 160);
});
observer.observe(map.getContainer());
});
const toggle = document.getElementById('toggle');
toggle.addEventListener('click', () => {
const open = document.getElementById('sidebar').classList.toggle('open');
toggle.classList.toggle('shifted', open);
toggle.textContent = open ? 'Hide panel' : 'Show panel';
// The canvas has not changed size, so the observer will not fire. Refit for the
// new free area directly, and let it run alongside the CSS transition.
fit(true);
});<!DOCTYPE html>
<html lang="en">
<head>
<title>Refit on Resize - Maptoolkit Maps JS</title>
<meta property="og:description" content="Recompute the camera fit when the container resizes." />
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="https://unpkg.com/@maptoolkit/maps@11.0.0-beta.3/dist/maptoolkit.js"></script>
<link rel="stylesheet" href="https://unpkg.com/@maptoolkit/maps@11.0.0-beta.3/dist/maptoolkit.css" />
<style>
html, body { width: 100%; height: 100%; margin: 0; padding: 0;
font: 13px/1.5 system-ui, sans-serif; color: #16181d; }
#frame { position: absolute; inset: 0; }
/* The panel slides OVER the map rather than pushing it. That keeps the canvas at a
constant size, which is what stops the map flashing while the panel animates. */
#sidebar {
position: absolute; left: 0; top: 0; bottom: 0; z-index: 998;
width: 230px; box-sizing: border-box; padding: 14px 16px;
background: #f7f8fa; border-right: 1px solid #e3e5ea;
transform: translateX(-100%); transition: transform .28s ease;
}
#sidebar.open { transform: none; }
#sidebar h2 { margin: 0 0 4px; font-size: 13px; }
#sidebar p { margin: 0 0 12px; color: #5b6170; font-size: 12px; }
#sidebar ol { margin: 0; padding-left: 18px; color: #5b6170; font-size: 12px; }
#sidebar li { margin-bottom: 4px; }
#map { position: absolute; inset: 0; }
/* Top left, clear of the navigation control in the top right. */
#toggle {
position: absolute; top: 10px; left: 10px; z-index: 999;
background: #fff; border: 1px solid #d7dae0; border-radius: 6px;
box-shadow: 0 1px 6px #0002; padding: 7px 12px; cursor: pointer;
font: 13px/1 system-ui, sans-serif; transition: left .28s ease;
}
/* Follow the panel so the button never covers its heading. */
#toggle.shifted { left: 240px; }
#toggle:hover { background: #f2f4f7; }
</style>
</head>
<body>
<div id="frame">
<div id="map"></div>
<div id="sidebar">
<h2>Trailheads</h2>
<p>Open and close this panel. The map refits the markers into the space that is left.</p>
<ol id="list"></ol>
</div>
</div>
<button id="toggle">Show panel</button>
<script>
const API_KEY = 'YOUR_API_KEY';
const PLACES = [
['Hungerburg', 11.3937, 47.2830],
['Seegrube', 11.3790, 47.3010],
['Hafelekar', 11.3810, 47.3120],
['Arzler Alm', 11.4160, 47.2960],
['Hoettinger Alm', 11.3520, 47.2960]
];
const map = new maptoolkit.Map({
container: 'map',
apiKey: API_KEY,
style: `https://styles.maptoolkit.net/maptoolkit/maptoolkit.hiking.json?api_key=${API_KEY}`,
center: [11.385, 47.295],
zoom: 12,
attributionControl: { compact: false }
});
map.addControl(new maptoolkit.NavigationControl(), 'top-right');
// Grow a bounds from the data rather than hard-coding one.
function bounds() {
return PLACES.reduce(
(b, [, lng, lat]) => b.extend([lng, lat]),
new maptoolkit.LngLatBounds([PLACES[0][1], PLACES[0][2]], [PLACES[0][1], PLACES[0][2]])
);
}
const PANEL_WIDTH = 230;
// Padding is how you tell fitBounds which part of the canvas is actually free. The
// panel covers the left edge, so that edge needs the panel's width added to it.
function fit(animate) {
const open = document.getElementById('sidebar').classList.contains('open');
map.fitBounds(bounds(), {
padding: { top: 56, right: 56, bottom: 56, left: 56 + (open ? PANEL_WIDTH : 0) },
maxZoom: 15,
animate,
duration: 400
});
}
map.on('load', () => {
map.addLayer({
id: 'places',
type: 'circle',
source: {
type: 'geojson',
data: {
type: 'FeatureCollection',
features: PLACES.map(([name, lng, lat]) => ({
type: 'Feature',
properties: { name },
geometry: { type: 'Point', coordinates: [lng, lat] }
}))
}
},
paint: {
'circle-radius': 8, 'circle-color': '#2171b5',
'circle-stroke-color': '#fff', 'circle-stroke-width': 2
}
});
document.getElementById('list').innerHTML =
PLACES.map(([name]) => `<li>${name}</li>`).join('');
fit(false);
// ResizeObserver catches every cause of a real size change: the window, a flex
// reflow, a print layout, a container animating. A window resize listener catches
// only the first of those.
let settle = null;
const observer = new ResizeObserver(() => {
map.resize();
// Debounced, because a container that animates fires this on every frame and
// each fitBounds starts a 400 ms camera move. Seventeen of them interrupting
// one another over a 280 ms transition reads as flashing, not as motion.
clearTimeout(settle);
settle = setTimeout(() => fit(true), 160);
});
observer.observe(map.getContainer());
});
const toggle = document.getElementById('toggle');
toggle.addEventListener('click', () => {
const open = document.getElementById('sidebar').classList.toggle('open');
toggle.classList.toggle('shifted', open);
toggle.textContent = open ? 'Hide panel' : 'Show panel';
// The canvas has not changed size, so the observer will not fire. Refit for the
// new free area directly, and let it run alongside the CSS transition.
fit(true);
});
</script>
</body>
</html>Use the prompt below with any LLM to get the same result. Make sure the Maptoolkit MCP server is connected first — check out AI Integration & MCP to get started.
How it works
Two separate things have to happen, and doing only one is the usual bug.
map.resize() tells the renderer that its canvas has new dimensions. Without it the map
keeps drawing at the old size, which shows up as a stretched or clipped canvas and as clicks
landing in the wrong place, because the projection still uses the stale pixel size.
fitBounds then reframes the data for the new shape. Restoring the previous centre and
zoom is not equivalent: a tall narrow container and a short wide one at the same zoom cover
different ground, so the route that was framed before is now cut off at the ends.
ResizeObserver rather than window.onresize. A window listener misses every cause that
is not a window resize: a flex reflow, a split pane being dragged, a parent collapsing,
entering print layout. Observing the container covers all of them and the window case too.
fitBounds is debounced to 160 ms after the last callback, because it starts an animation.
A container that animates fires the observer on roughly every frame, and kicking off a new
400 ms fit each time leaves seventeen cameras interrupting one another, which reads as
flashing rather than as motion.
A requestAnimationFrame guard is not enough here, which is a tempting mistake. It collapses
several callbacks that land within one frame, but the observer fires once per frame across
the whole transition, so nearly every call survives it. The fit has to wait for the container
to stop changing, not for the next paint.
Do not animate the map container’s size. It is the obvious way to build a collapsing
sidebar and it makes the map flicker. Every map.resize() reallocates the WebGL drawing
buffer, which clears it, and the map only repaints on the following frame. Do that on each
frame of a CSS transition and the canvas is intermittently blank, which shows as the map
flashing white while the panel moves.
This example avoids the problem rather than working around it: the panel is
position: absolute and slides in over the map on a transform, so the canvas keeps one
size and never needs reallocating. What changes instead is fitBounds padding, which is
the correct tool for “part of the map is covered”. The ResizeObserver stays, because the
container still resizes for real when the window does.
Padding also has to be asymmetric to be useful here. A single padding: 56 centres the
markers in the whole canvas, including the strip hidden behind the panel, so they drift left
as it opens. Adding the panel width to the left padding alone frames them in the part the
reader can actually see.
LngLatBounds is grown from the data with extend rather than hard-coded. That is what
makes the fit survive the data changing, which is the common case once markers come from a
search result rather than a literal.
animate: false on the first fit and true afterwards is a small courtesy: the initial
frame should be instant, later reframes read better as a short move.
maxZoom on fitBounds stops a tight cluster of markers zooming to street level and
leaving the reader with no context. It matters most in the narrow layout, where the fit is
driven by height rather than width.
Transforming the panel rather than animating its width has a second payoff: transform is
composited on the GPU and never triggers layout, so the text inside does not rewrap on every
frame the way it does when a width animates from zero.
Next steps
On a phone the better answer is often a different layout rather than the same one narrower. A map that scrolls past on a small screen frequently works better pinned, or replaced with a static image that needs no renderer at all.
The panel toggle sits top left because the navigation control owns the top right. A control placed over another is the most common way a map interface ends up with an unclickable button.