Map animations in Remotion
Create map animations in Remotion using MapLibre GL JS and Turf.js.
Prerequisites
Install the required packages:
- npm
- bun
- pnpm
- yarn
npm i --save-exact maplibre-gl @turf/turf
pnpm i maplibre-gl @turf/turf
bun i maplibre-gl @turf/turf
yarn --exact add maplibre-gl @turf/turf
Import the MapLibre stylesheet once, either in the component that renders the map or in an app-level stylesheet:
import 'maplibre-gl/dist/maplibre-gl.css';Adding a map
Use useDelayRender() to wait for the map to load. The container element must have explicit dimensions and position: "absolute".
import {useEffect , useRef , useState } from 'react';
import {AbsoluteFill , useDelayRender , useVideoConfig } from 'remotion';
import maplibregl from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css';
const zurich : [number, number] = [8.5417, 47.3769];
export const MapComposition = () => {
const ref = useRef <HTMLDivElement >(null);
const {delayRender , continueRender } = useDelayRender ();
const {width , height } = useVideoConfig ();
const [handle ] = useState (() => delayRender ('Loading map...'));
useEffect (() => {
if (!ref .current ) {
return;
}
const map = new maplibregl .Map ({
container : ref .current ,
style : 'https://demotiles.maplibre.org/style.json',
center : zurich ,
zoom : 7,
interactive : false,
attributionControl : false,
fadeDuration : 0,
canvasContextAttributes : {
preserveDrawingBuffer : true,
},
});
map .on ('load', () => {
map .jumpTo ({center : zurich , zoom : 7});
map .once ('idle', () => continueRender (handle ));
});
}, [handle , continueRender ]);
return (
<AbsoluteFill >
<div ref ={ref } style ={{width , height , position : 'absolute'}} />
</AbsoluteFill >
);
};Set interactive: false and fadeDuration: 0 so the map does not run its own animations.
For Remotion renders, do not add a map.remove() cleanup function. It can interfere with the render lifecycle.
Styling the map
Use any valid MapLibre style JSON URL. The stock demo style works as a simple default:
const map = new maplibregl .Map ({
container : document .createElement ('div'),
style : 'https://demotiles.maplibre.org/style.json',
center : [0, 0],
zoom : 1,
interactive : false,
fadeDuration : 0,
});If you need a custom look, prefer changing your own GeoJSON layers first. Only edit the base style if the composition requires it.
Drawing lines
Add a GeoJSON line source and layer:
map .addSource ('route', {
type : 'geojson',
data : {
type : 'Feature',
properties : {},
geometry : {
type : 'LineString',
coordinates : lineCoordinates ,
},
},
});
map .addLayer ({
id : 'route-line',
type : 'line',
source : 'route',
paint : {
'line-color': '#000000',
'line-width': 5,
},
layout : {
'line-cap': 'round',
'line-join': 'round',
},
});Animating lines
For curved geodesic paths, such as flight routes, use Turf to create and slice the route:
const greatCircleLine = (from : [number, number], to : [number, number]) => {
const route = turf .greatCircle (from , to , {npoints : 100});
if (route .geometry .type === 'LineString') {
return turf .lineString (route .geometry .coordinates );
}
const longestSegment = route .geometry .coordinates .reduce ((longest , segment ) => {
return segment .length > longest .length ? segment : longest ;
});
return turf .lineString (longestSegment );
};
const route = greatCircleLine (start , end );
const routeDistance = turf .length (route );
// Keep the route non-empty at progress 0; Turf can error on zero-length slices.
const currentDistance = Math .max (0.001, routeDistance * progress );
const slicedLine = turf .lineSliceAlong (route , 0, currentDistance );Update the GeoJSON source for the current frame:
const source = map ?.getSource ('route') as GeoJSONSource | undefined;
source ?.setData (slicedLine );For a visually straight line on the map, use a regular GeoJSON LineString between the two points instead of turf.greatCircle().
Animating the camera
Use calculateCameraOptionsFromTo() to move the camera while looking at a target point. A good pattern is to keep the target route and camera route separate, then use Turf to find the current point on each route.
const frame = useCurrentFrame ();
const {durationInFrames } = useVideoConfig ();
const {delayRender , continueRender } = useDelayRender ();
useEffect (() => {
if (!map ) {
return;
}
const handle = delayRender ('Moving camera...');
const progress = interpolate (frame , [0, durationInFrames - 1], [0, 1], {
extrapolateLeft : 'clamp',
extrapolateRight : 'clamp',
easing : Easing .inOut (Easing .cubic ),
});
const target = turf .along (targetRoute , targetRouteDistance * progress ).geometry .coordinates ;
const camera = turf .along (cameraRoute , cameraRouteDistance * progress ).geometry .coordinates ;
const cameraAltitudeMeters = 180000;
map .jumpTo (
map .calculateCameraOptionsFromTo (
new maplibregl .LngLat (camera [0], camera [1]),
cameraAltitudeMeters ,
new maplibregl .LngLat (target [0], target [1]),
),
);
map .once ('idle', () => continueRender (handle ));
// Force an idle event even if the camera parameters are unchanged from the previous frame.
map .triggerRepaint ();
}, [frame , durationInFrames , map , delayRender , continueRender ]);To make a zoom-out / travel / zoom-in animation, animate travel progress separately from camera altitude. Camera altitude is measured in meters.
Adding markers
Add circle markers with labels as map-native GeoJSON layers:
map .addSource ('cities', {
type : 'geojson',
data : turf .featureCollection ([
turf .point (LA_COORDS , {name : 'Los Angeles'}),
]),
});
map .addLayer ({
id : 'city-markers',
type : 'circle',
source : 'cities',
paint : {
'circle-radius': 40,
'circle-color': '#FF4444',
'circle-stroke-width': 4,
'circle-stroke-color': '#FFFFFF',
},
});
map .addLayer ({
id : 'labels',
type : 'symbol',
source : 'cities',
layout : {
'text-field': ['get', 'name'],
'text-size': 50,
'text-offset': [0, 0.5],
'text-anchor': 'top',
},
paint : {
'text-color': '#FFFFFF',
'text-halo-color': '#000000',
'text-halo-width': 2,
},
});Rendering
Render map animations with --gl=angle to enable the GPU. Use single concurrency for WebGL-heavy map renders:
npx remotion render <composition-id> out/video.mp4 --gl=angle --concurrency=1