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.
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.