Skip to main content

Map animations in Remotion

Create map animations in Remotion using MapLibre GL JS and Turf.js.

Prerequisites

Install the required packages:

npm i --save-exact 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

See also