Skip to content

Animate a Vehicle Along a Route in Leaflet

Fleet dashboards, delivery tracking and route previews all show a vehicle moving along a road. This example requests a car route from Innsbruck’s main station to the airport from the Maptoolkit Routing API and drives a marker along it at the speed the route implies, sped up. The marker turns to face its direction of travel with Leaflet.RotatedMarker, and the driven part of the route is drawn as a trail.

const API_KEY = "YOUR_API_KEY";

    const map = L.map("map", { zoomControl: false }).setView([47.266, 11.395], 14);
    L.control.zoom({ position: "topright" }).addTo(map);

    L.tileLayer(`https://rtc-cdn.maptoolkit.net/rtc/maptoolkit-maptoolkit.summer/{z}/{x}/{y}{r}.png?api_key=${API_KEY}`, {
      maxZoom: 18,
      attribution:
        "© <a href='https://www.maptoolkit.com' target='_blank'>Maptoolkit</a> " +
        "© <a href='https://www.openstreetmap.org/copyright' target='_blank'>OSM</a>",
    }).addTo(map);

    const panel = L.control({ position: "topleft" });
    panel.onAdd = () => {
      const div = L.DomUtil.create("div", "panel");
      div.innerHTML = `
        <div class="row"><button id="play">Pause</button>
          <select id="speed"><option value="10">10×</option><option value="30" selected>30×</option><option value="60">60×</option></select></div>
        <div class="row"><label><input type="checkbox" id="follow"> Follow the vehicle</label></div>
        <div class="row stat" id="stat">Loading route...</div>`;
      L.DomEvent.disableClickPropagation(div);
      return div;
    };
    panel.addTo(map);

    // An arrow pointing north; Leaflet.RotatedMarker turns it to the heading.
    const vehicleIcon = L.divIcon({
      className: "vehicle",
      html: `<svg width="30" height="30" viewBox="0 0 30 30">
        <circle cx="15" cy="15" r="13" fill="#303f7e" stroke="#fff" stroke-width="2"/>
        <path d="M15 7 L21 21 L15 18 L9 21 Z" fill="#fff"/></svg>`,
      iconSize: [30, 30],
      iconAnchor: [15, 15],
    });

    // Initial compass bearing from a to b, in degrees clockwise from north.
    function bearing([lat1, lng1], [lat2, lng2]) {
      const rad = Math.PI / 180;
      const y = Math.sin((lng2 - lng1) * rad) * Math.cos(lat2 * rad);
      const x = Math.cos(lat1 * rad) * Math.sin(lat2 * rad) -
        Math.sin(lat1 * rad) * Math.cos(lat2 * rad) * Math.cos((lng2 - lng1) * rad);
      return (Math.atan2(y, x) / rad + 360) % 360;
    }

    async function fetchRoute() {
      const url = new URL("https://routing.maptoolkit.net/route");
      url.searchParams.append("point", "47.2632,11.4008"); // Innsbruck main station
      url.searchParams.append("point", "47.2595,11.3520"); // Innsbruck Airport
      url.searchParams.set("routeType", "car");
      url.searchParams.set("points_encoded", "false");
      url.searchParams.set("api_key", API_KEY);
      const response = await fetch(url);
      if (!response.ok) throw new Error(`Routing API returned ${response.status}`);
      return (await response.json()).paths[0];
    }

    fetchRoute().then((path) => {
      // GeoJSON is [lng, lat]; Leaflet wants [lat, lng].
      const points = path.points.coordinates.map(([lng, lat]) => [lat, lng]);
      // Distance from the start to every point, so a distance can be turned into a position.
      const along = [0];
      for (let i = 1; i < points.length; i++) along.push(along[i - 1] + map.distance(points[i - 1], points[i]));
      const total = along[along.length - 1];
      // The route's average speed in m/s: distance in meters, time in milliseconds.
      const speed = path.distance / (path.time / 1000);

      L.polyline(points, { color: "#adb5bd", weight: 6 }).addTo(map);
      const trail = L.polyline([], { color: "#303f7e", weight: 6 }).addTo(map);
      const vehicle = L.marker(points[0], { icon: vehicleIcon, rotationOrigin: "center center", zIndexOffset: 1000 }).addTo(map);
      map.fitBounds(L.latLngBounds(points), { paddingTopLeft: [240, 40], paddingBottomRight: [40, 40] });

      let distance = 0, playing = true, last = null, segment = 1;
      const stat = document.getElementById("stat");

      function frame(now) {
        if (last !== null && playing) {
          const factor = Number(document.getElementById("speed").value);
          distance = Math.min(total, distance + speed * factor * ((now - last) / 1000));
        }
        last = now;

        // Find the segment the vehicle is on and interpolate within it.
        segment = distance < along[segment - 1] ? 1 : segment;
        while (segment < points.length - 1 && along[segment] < distance) segment++;
        const [a, b] = [points[segment - 1], points[segment]];
        const t = (distance - along[segment - 1]) / (along[segment] - along[segment - 1] || 1);
        const position = [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t];

        vehicle.setLatLng(position);
        vehicle.setRotationAngle(bearing(a, b));
        trail.setLatLngs([...points.slice(0, segment), position]);
        if (document.getElementById("follow").checked) map.panTo(position, { animate: false });

        const minutes = (distance / speed / 60).toFixed(1);
        stat.textContent = `${(distance / 1000).toFixed(2)} of ${(total / 1000).toFixed(2)} km, ${minutes} min driven`;

        if (distance >= total) {
          // Wait at the destination, then start again.
          setTimeout(() => { distance = 0; segment = 1; last = null; requestAnimationFrame(frame); }, 1500);
          return;
        }
        requestAnimationFrame(frame);
      }
      requestAnimationFrame(frame);

      document.getElementById("play").addEventListener("click", (event) => {
        playing = !playing;
        event.target.textContent = playing ? "Pause" : "Play";
      });
    }).catch(() => { document.getElementById("stat").textContent = "Could not load the route."; });
<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/leaflet@1.9.4/dist/leaflet.css" />
  <script src="https://cdn.jsdelivr.net/npm/leaflet@1.9.4/dist/leaflet.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/leaflet-rotatedmarker@0.2.0/leaflet.rotatedMarker.js"></script>
  <style>
    html, body { width: 100%; height: 100%; margin: 0; padding: 0; }
    #map { width: 100%; height: 100%; }
    .panel {
      width: 210px; padding: 10px 12px; background: #fff; border-radius: 10px;
      box-shadow: 0 4px 20px rgba(20, 30, 60, 0.18); font: 13px/1.5 system-ui, sans-serif; color: #1f2430;
    }
    .panel .row { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-top: 6px; }
    .panel button {
      padding: 5px 12px; border: none; border-radius: 7px; background: #303f7e; color: #fff;
      font: 600 12px system-ui, sans-serif; cursor: pointer;
    }
    .panel select { padding: 3px; border: 1px solid #d0d4e0; border-radius: 6px; font: inherit; }
    .panel .stat { font-variant-numeric: tabular-nums; color: #4a5068; }
    .vehicle { background: none; border: none; }
  </style>
</head>
<body>
  <div id="map"></div>
  <script>
    const API_KEY = "YOUR_API_KEY";

    const map = L.map("map", { zoomControl: false }).setView([47.266, 11.395], 14);
    L.control.zoom({ position: "topright" }).addTo(map);

    L.tileLayer(`https://rtc-cdn.maptoolkit.net/rtc/maptoolkit-maptoolkit.summer/{z}/{x}/{y}{r}.png?api_key=${API_KEY}`, {
      maxZoom: 18,
      attribution:
        "© <a href='https://www.maptoolkit.com' target='_blank'>Maptoolkit</a> " +
        "© <a href='https://www.openstreetmap.org/copyright' target='_blank'>OSM</a>",
    }).addTo(map);

    const panel = L.control({ position: "topleft" });
    panel.onAdd = () => {
      const div = L.DomUtil.create("div", "panel");
      div.innerHTML = `
        <div class="row"><button id="play">Pause</button>
          <select id="speed"><option value="10">10×</option><option value="30" selected>30×</option><option value="60">60×</option></select></div>
        <div class="row"><label><input type="checkbox" id="follow"> Follow the vehicle</label></div>
        <div class="row stat" id="stat">Loading route...</div>`;
      L.DomEvent.disableClickPropagation(div);
      return div;
    };
    panel.addTo(map);

    // An arrow pointing north; Leaflet.RotatedMarker turns it to the heading.
    const vehicleIcon = L.divIcon({
      className: "vehicle",
      html: `<svg width="30" height="30" viewBox="0 0 30 30">
        <circle cx="15" cy="15" r="13" fill="#303f7e" stroke="#fff" stroke-width="2"/>
        <path d="M15 7 L21 21 L15 18 L9 21 Z" fill="#fff"/></svg>`,
      iconSize: [30, 30],
      iconAnchor: [15, 15],
    });

    // Initial compass bearing from a to b, in degrees clockwise from north.
    function bearing([lat1, lng1], [lat2, lng2]) {
      const rad = Math.PI / 180;
      const y = Math.sin((lng2 - lng1) * rad) * Math.cos(lat2 * rad);
      const x = Math.cos(lat1 * rad) * Math.sin(lat2 * rad) -
        Math.sin(lat1 * rad) * Math.cos(lat2 * rad) * Math.cos((lng2 - lng1) * rad);
      return (Math.atan2(y, x) / rad + 360) % 360;
    }

    async function fetchRoute() {
      const url = new URL("https://routing.maptoolkit.net/route");
      url.searchParams.append("point", "47.2632,11.4008"); // Innsbruck main station
      url.searchParams.append("point", "47.2595,11.3520"); // Innsbruck Airport
      url.searchParams.set("routeType", "car");
      url.searchParams.set("points_encoded", "false");
      url.searchParams.set("api_key", API_KEY);
      const response = await fetch(url);
      if (!response.ok) throw new Error(`Routing API returned ${response.status}`);
      return (await response.json()).paths[0];
    }

    fetchRoute().then((path) => {
      // GeoJSON is [lng, lat]; Leaflet wants [lat, lng].
      const points = path.points.coordinates.map(([lng, lat]) => [lat, lng]);
      // Distance from the start to every point, so a distance can be turned into a position.
      const along = [0];
      for (let i = 1; i < points.length; i++) along.push(along[i - 1] + map.distance(points[i - 1], points[i]));
      const total = along[along.length - 1];
      // The route's average speed in m/s: distance in meters, time in milliseconds.
      const speed = path.distance / (path.time / 1000);

      L.polyline(points, { color: "#adb5bd", weight: 6 }).addTo(map);
      const trail = L.polyline([], { color: "#303f7e", weight: 6 }).addTo(map);
      const vehicle = L.marker(points[0], { icon: vehicleIcon, rotationOrigin: "center center", zIndexOffset: 1000 }).addTo(map);
      map.fitBounds(L.latLngBounds(points), { paddingTopLeft: [240, 40], paddingBottomRight: [40, 40] });

      let distance = 0, playing = true, last = null, segment = 1;
      const stat = document.getElementById("stat");

      function frame(now) {
        if (last !== null && playing) {
          const factor = Number(document.getElementById("speed").value);
          distance = Math.min(total, distance + speed * factor * ((now - last) / 1000));
        }
        last = now;

        // Find the segment the vehicle is on and interpolate within it.
        segment = distance < along[segment - 1] ? 1 : segment;
        while (segment < points.length - 1 && along[segment] < distance) segment++;
        const [a, b] = [points[segment - 1], points[segment]];
        const t = (distance - along[segment - 1]) / (along[segment] - along[segment - 1] || 1);
        const position = [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t];

        vehicle.setLatLng(position);
        vehicle.setRotationAngle(bearing(a, b));
        trail.setLatLngs([...points.slice(0, segment), position]);
        if (document.getElementById("follow").checked) map.panTo(position, { animate: false });

        const minutes = (distance / speed / 60).toFixed(1);
        stat.textContent = `${(distance / 1000).toFixed(2)} of ${(total / 1000).toFixed(2)} km, ${minutes} min driven`;

        if (distance >= total) {
          // Wait at the destination, then start again.
          setTimeout(() => { distance = 0; segment = 1; last = null; requestAnimationFrame(frame); }, 1500);
          return;
        }
        requestAnimationFrame(frame);
      }
      requestAnimationFrame(frame);

      document.getElementById("play").addEventListener("click", (event) => {
        playing = !playing;
        event.target.textContent = playing ? "Pause" : "Play";
      });
    }).catch(() => { document.getElementById("stat").textContent = "Could not load the route."; });
  </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.

Use the Maptoolkit Connector. Create a Leaflet map of Innsbruck with Maptoolkit raster tiles. Request a car route with the Routing API as GeoJSON and animate a vehicle marker along it with requestAnimationFrame, rotated to its heading with Leaflet.RotatedMarker, with a trail of the driven part, play and pause, a playback speed and an option to follow the vehicle.

How it works

The route as GeoJSON. points_encoded=false makes the Routing API return the route as a GeoJSON LineString. Its coordinates are [lng, lat], so they are flipped once into Leaflet’s [lat, lng] order.

Distance, not points, drives the animation. Route points are unevenly spaced: many in a bend, few on a straight road. Moving one point per frame would make the vehicle crawl through bends and jump along straights. Instead, along holds the distance from the start to every point, each frame advances the distance by speed times elapsed time, and the position is interpolated between the two points around that distance. The speed is constant however the points are spread.

Real speed, sped up. path.distance is in meters and path.time in milliseconds, so the route’s average speed follows from the two. The playback factor multiplies it. Using the requestAnimationFrame timestamp to measure the time between frames keeps the speed the same on a 60 Hz and a 120 Hz screen, and after a pause the vehicle continues where it stopped.

Heading with Leaflet.RotatedMarker. The plugin adds rotationAngle and setRotationAngle() to L.marker. bearing() computes the compass direction of the current segment, and the arrow, drawn pointing north, turns by that angle. rotationOrigin: "center center" turns it around its middle, which matches the iconAnchor of a round icon; for a pin, rotate around the tip.

The trail. trail.setLatLngs() gets the points already passed plus the current position, so the driven part grows smoothly instead of point by point.

Follow mode. panTo(position, { animate: false }) recenters the map every frame. With the default animation, each pan would start before the last one finished, and the map would lag behind the vehicle.

For live tracking, the same marker and trail take positions from your GPS feed instead of the interpolation: call setLatLng() and setRotationAngle() with each update, using the heading the device reports or the bearing from the previous position.

Next steps

To place the route by hand before driving it, combine this with the route planner. For a recorded drive, snap the GPS track to the road network first, so the vehicle stays on the road.

In Maptoolkit Maps JS, Animate a Point Along a Route moves a point along a line with Turf.js.