Camera control, cinematic fly-throughs, and animated data. How-to first, with the footguns that bite flagged inline.
essential / reduced-motion gotcha| Method | Use |
|---|---|
jumpTo(opts) |
Instant, no animation |
easeTo(opts) |
Animated, constant-feel — short hops, pitch/bearing changes |
flyTo(opts) |
Zoom-out-arc "flight" — best for long distances |
panTo / panBy / rotateTo / snapToNorth |
Single-axis moves |
All take {center, zoom, bearing, pitch, padding, duration}. flyTo adds the flight
controls: curve (default 1.42 — higher = bigger zoom-out arc), speed,
screenSpeed, minZoom, maxDuration.
map.flyTo({ center:[146.9,-36.1], zoom:14, pitch:55, bearing:-20, curve:1.6, speed:0.8,
essential:true });
map.fitBounds(bounds, { padding:{top:40,bottom:40,left:320,right:40}, maxZoom:15,
pitch: map.getPitch(), bearing: map.getBearing() });
padding as an object is essential when a sidebar/legend/controls overlay the map
— otherwise the fitted content tucks under your UI. Per-edge padding offsets it clear.pitch/bearing (a bare
fitBounds resets them to 0/north).cameraForBounds(bounds, opts) returns the computed {center, zoom, …} without
moving — compute it, tweak it (nudge zoom, add pitch), then easeTo the result.essential / reduced-motion gotchaAny camera animation is cancelled immediately (jumps to the end) for users with
prefers-reduced-motion: reduce — unless you pass {essential: true}. A "fly to the
selected result" that mysteriously jumps for some users and animates for others is
almost always this. Pass essential:true when the motion conveys meaning; otherwise
honour the preference deliberately. (User scroll/drag also interrupts animations — usually
desired; essential doesn't change that.)
When flyTo/easeTo can't express the shot (orbit a point, drone pass over terrain, a
camera at a specific altitude looking at a target), drive the camera directly:
const cam = map.getFreeCameraOptions();
const target = [146.9, -36.1];
// position the camera at an altitude, then aim it at the target
cam.position = mapboxgl.MercatorCoordinate.fromLngLat([146.85, -36.15], 4000); // 4 km up
cam.lookAtPoint(target);
map.setFreeCameraOptions(cam);
MercatorCoordinate.fromLngLat([lng,lat], altitudeMetres) — the 3rd arg is metres.MercatorCoordinate.fromLngLat(c).meterInMercatorCoordinateUnits().position on a circle around the
target, lookAtPoint(target) each frame.Continuous first-person movement — a flight sim, drone pass, or walkthrough — drives the
camera every frame from a {lng, lat, alt, heading, pitch, speed} state.
Native camera: Mapbox GL JS has no camera roll — only bearing + pitch.
"Banked" turns are choreography: sweep bearing through the turn while easing pitch
up a few degrees; the combined motion reads as a bank even though the horizon stays
level. Per-frame jumpTo is instant (no easing lag):
const s = { lng:146.9, lat:-36.1, alt:1500, heading:0, pitch:75, speed:0 };
function fly() {
// advance along heading by speed (deg/frame ≈ metres → deg)
const rad = s.heading * Math.PI/180, d = s.speed * 1e-5;
s.lat += Math.cos(rad) * d; s.lng += Math.sin(rad) * d;
map.jumpTo({ center:[s.lng,s.lat], bearing:s.heading, pitch:s.pitch });
requestAnimationFrame(fly);
}
fly(); // A/D → s.heading, W/S → s.pitch, R/F → s.speed (key handlers)
Full positional control (camera at an exact altitude, aimed at a target): use
freeCameraOptions — set position = MercatorCoordinate.fromLngLat([lng,lat], alt)
and orientation each frame. This does not unlock roll either: the orientation
quaternion "must be representable using only pitch and bearing" (per the
FreeCameraOptions docs — a non-conforming orientation is discarded). freeCamera buys
position freedom, not a rolled horizon.
Need true roll? Switch libraries — MapLibre GL JS v5 added first-class camera roll
(easeTo/flyTo({roll}), map.setRoll) plus pitch beyond 90°. Mapbox GL JS has no
equivalent on any camera API.
Cap pitch near 85° — the native camera's hard maximum (maxPitch); a nose-down or
looking-up shot past that is likewise MapLibre-v5-only territory.
setConfigProperty("basemap","lightPreset", …)
through dawn→day→dusk→night on a timer — snaps between four looks (cheap, no reload).Smooth (3D lights): drive the v3 lights API and move the sun — animate the directional light's direction by clock for a continuous sweep:
map.setLights([
{ id:"ambient", type:"ambient", properties:{ intensity:0.5 } },
{ id:"sun", type:"directional",
properties:{ direction:[azimuthDeg, polarDeg], intensity:0.8, "cast-shadows":true } }
]);
// in a slow loop, advance azimuth/polar to sweep the sun → moving shadows, warm→cool light
A telemetry overlay (speed, heading, altitude) is absolutely-positioned DOM updated from
the camera. Read state on "move"/"render" (not a free-running RAF) so it idles cheaply:
map.on("render", () => {
hud.heading.textContent = Math.round(map.getBearing());
hud.pitch.textContent = Math.round(map.getPitch());
hud.alt.textContent = Math.round(map.getFreeCameraOptions().position.toAltitude());
});
getFreeCameraOptions().position.toAltitude() is the live camera altitude in metres —
the only way to read it (there's no map.getAltitude()).
requestAnimationFrame loop advancing a distance; get the point with Turf
(@turf/along) or manual segment interpolation; setData a one-point source. Rotate an
icon with @turf/bearing.
let phase = 0, raf;
const line = trail.features[0], total = turf.length(line); // km
function frame() {
phase = (phase + 0.02) % total;
const pt = turf.along(line, phase);
map.getSource("mover").setData(pt);
raf = requestAnimationFrame(frame);
}
frame();
// cancelAnimationFrame(raf) on teardown — see lifecycle.md (leaked RAF loops survive map.remove)
Two options:
line-gradient over ["line-progress"] and animate a single colour
stop from 0→1 (no geometry churn). Needs lineMetrics:true (see
lines-and-trails.md).phase and setData each frame (more work,
but lets you also drop a moving "head" marker).Paint properties transition smoothly on change (layout properties do not). Set the transition then update the value:
map.setPaintProperty("choro", "fill-color-transition", { duration: 600, delay: 0 });
map.setPaintProperty("choro", "fill-color", nextRamp); // animates over 600 ms
For continuous data updates, setData on a RAF/interval — but batch and throttle; every
setData re-tiles the source (see interaction-and-performance.md).
function spin() {
if (userInteracting || map.getZoom() > 5) return;
const c = map.getCenter(); c.lng -= 2;
map.easeTo({ center: c, duration: 1000, easing: t => t }); // linear, seamless loop
}
map.on("moveend", spin); // chain each ease into the next; pause on user interaction