Add an Animated Icon to the Map in Maptoolkit Maps JS
Maptoolkit Maps JS lets you register a custom animated image by implementing an object with onAdd, render, and width/height/data properties. Each frame, the render function redraws the icon onto a pixel buffer and calls map.triggerRepaint() to keep the animation running. Use this technique to draw attention to a specific point on the map, such as a live vehicle position, an alert location, or a featured place of interest.
const API_KEY = 'YOUR_API_KEY';
const map = new maptoolkit.Map({
container: 'map',
apiKey: API_KEY,
style: `https://styles.maptoolkit.net/maptoolkit/maptoolkit.summer.json?api_key=${API_KEY}`,
center: [11.39085, 47.27574],
zoom: 12,
attributionControl: { compact: false }
});
map.addControl(new maptoolkit.NavigationControl(), 'top-right');
const size = 100;
const pulsingDot = {
width: size,
height: size,
data: new Uint8Array(size * size * 4),
onAdd() {
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
this.context = canvas.getContext('2d');
},
render() {
const duration = 1000;
const t = (performance.now() % duration) / duration;
const radius = (size / 2) * 0.3;
const outerRadius = (size / 2) * 0.7 * t + radius;
const ctx = this.context;
ctx.clearRect(0, 0, size, size);
ctx.beginPath();
ctx.arc(size / 2, size / 2, outerRadius, 0, Math.PI * 2);
ctx.fillStyle = `rgba(56, 135, 190, ${1 - t})`;
ctx.fill();
ctx.beginPath();
ctx.arc(size / 2, size / 2, radius, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(56, 135, 190, 1)';
ctx.strokeStyle = 'white';
ctx.lineWidth = 2 + 4 * (1 - t);
ctx.fill();
ctx.stroke();
this.data = ctx.getImageData(0, 0, size, size).data;
map.triggerRepaint();
return true;
}
};
map.on('load', () => {
map.addImage('pulsing-dot', pulsingDot, { pixelRatio: 2 });
map.addSource('dot', {
type: 'geojson',
data: { type: 'Feature', geometry: { type: 'Point', coordinates: [11.39085, 47.27574] } }
});
map.addLayer({
id: 'dot-layer',
type: 'symbol',
source: 'dot',
layout: { 'icon-image': 'pulsing-dot', 'icon-allow-overlap': true }
});
});<!DOCTYPE html>
<html lang="en">
<head>
<title>Add an Animated Icon to the Map - Maptoolkit Maps JS</title>
<meta property="og:description" content="Create a pulsing dot icon using a custom animated image." />
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="https://unpkg.com/@maptoolkit/maps@11.0.0-beta.3/dist/maptoolkit.js"></script>
<link rel="stylesheet" href="https://unpkg.com/@maptoolkit/maps@11.0.0-beta.3/dist/maptoolkit.css" />
<style>
html, body { width: 100%; height: 100%; margin: 0; padding: 0; }
#map { width: 100%; height: 100%; }
</style>
</head>
<body>
<div id="map"></div>
<script>
const API_KEY = 'YOUR_API_KEY';
const map = new maptoolkit.Map({
container: 'map',
apiKey: API_KEY,
style: `https://styles.maptoolkit.net/maptoolkit/maptoolkit.summer.json?api_key=${API_KEY}`,
center: [11.39085, 47.27574],
zoom: 12,
attributionControl: { compact: false }
});
map.addControl(new maptoolkit.NavigationControl(), 'top-right');
const size = 100;
const pulsingDot = {
width: size,
height: size,
data: new Uint8Array(size * size * 4),
onAdd() {
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
this.context = canvas.getContext('2d');
},
render() {
const duration = 1000;
const t = (performance.now() % duration) / duration;
const radius = (size / 2) * 0.3;
const outerRadius = (size / 2) * 0.7 * t + radius;
const ctx = this.context;
ctx.clearRect(0, 0, size, size);
ctx.beginPath();
ctx.arc(size / 2, size / 2, outerRadius, 0, Math.PI * 2);
ctx.fillStyle = `rgba(56, 135, 190, ${1 - t})`;
ctx.fill();
ctx.beginPath();
ctx.arc(size / 2, size / 2, radius, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(56, 135, 190, 1)';
ctx.strokeStyle = 'white';
ctx.lineWidth = 2 + 4 * (1 - t);
ctx.fill();
ctx.stroke();
this.data = ctx.getImageData(0, 0, size, size).data;
map.triggerRepaint();
return true;
}
};
map.on('load', () => {
map.addImage('pulsing-dot', pulsingDot, { pixelRatio: 2 });
map.addSource('dot', {
type: 'geojson',
data: { type: 'Feature', geometry: { type: 'Point', coordinates: [11.39085, 47.27574] } }
});
map.addLayer({
id: 'dot-layer',
type: 'symbol',
source: 'dot',
layout: { 'icon-image': 'pulsing-dot', 'icon-allow-overlap': true }
});
});
</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
A StyleImageInterface is an image the map asks you to redraw. You provide an object with
width, height, a data buffer and a render method, register it with addImage, and the
renderer calls render each frame; returning true tells it the pixels changed and the
texture needs re-uploading.
The drawing happens into a 2D canvas whose pixels are copied into data. That is the whole
trick: the icon is a texture you repaint rather than a DOM element.
map.triggerRepaint() inside render keeps the frame loop alive. Without it the map stops
redrawing when nothing else is moving and the animation freezes.
Because it is a style image, every symbol using that icon animates, and it costs one texture upload per frame no matter how many features show it. That is what makes an animated pulse affordable across hundreds of points where hundreds of animated DOM markers would not be.
Next steps
An animated icon draws the eye, which means using it sparingly: one pulsing point among static ones reads as important, and everything pulsing reads as noise.
The next step is usually tying the animation to state rather than running it always, so a point animates while something is live and stops when it is not. That is also what keeps the repaint loop from running for the whole session.