Skip to content

Add an animated icon to the map

Replicates the MapLibre example Add an animated icon to the map. The point and its symbol layer are YAML, hidden until the icon exists. The icon is a StyleImageInterface: instead of fixed pixels, addImage receives an object whose render() MapLibre calls before every frame that uses it. It redraws a canvas, copies the pixels, asks for another frame, and reports that it changed.

A pulsing dot drawn fresh every frame
add-an-animated-icon-to-the-map.yaml
# yaml-language-server: $schema=https://docs.maplibre-yaml.org/schema/latest/map.schema.json
# Gallery twin of https://maplibre.org/maplibre-gl-js/docs/examples/add-an-animated-icon-to-the-map/
# The YAML half: the point and its symbol layer, hidden until the page has
# registered the animated "pulsing-dot" image (a StyleImageInterface that
# redraws itself every frame) — see add-an-animated-icon-to-the-map.js.
type: map
id: add-an-animated-icon-to-the-map
config:
center: [0, 0]
zoom: 1
mapStyle: "https://demotiles.maplibre.org/style.json"
layers:
- id: points
type: symbol
visible: false
source:
type: geojson
location:
coordinates: [0, 0]
layout:
icon-image: pulsing-dot
add-an-animated-icon-to-the-map.js
// The JavaScript half: a StyleImageInterface. Instead of fixed pixels,
// addImage() gets an object whose render() MapLibre calls before every
// frame that uses the icon; it redraws a canvas, copies the pixels into
// `data`, asks for another frame and returns true ("I changed").
const mapEl = document.querySelector("ml-map");
const size = 200;
mapEl.mapReady().then((map) => {
const pulsingDot = {
width: size,
height: size,
data: new Uint8Array(size * size * 4),
// Called when the image is added: set up a 2D canvas to draw on.
onAdd() {
const canvas = document.createElement("canvas");
canvas.width = this.width;
canvas.height = this.height;
this.context = canvas.getContext("2d", { willReadFrequently: true });
},
// Called once before every frame where the icon is used.
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, this.width, this.height);
ctx.beginPath(); // outer, fading ring
ctx.arc(this.width / 2, this.height / 2, outerRadius, 0, Math.PI * 2);
ctx.fillStyle = `rgba(255, 200, 200, ${1 - t})`;
ctx.fill();
ctx.beginPath(); // inner dot
ctx.arc(this.width / 2, this.height / 2, radius, 0, Math.PI * 2);
ctx.fillStyle = "rgba(255, 100, 100, 1)";
ctx.strokeStyle = "white";
ctx.lineWidth = 2 + 4 * (1 - t);
ctx.fill();
ctx.stroke();
this.data = ctx.getImageData(0, 0, this.width, this.height).data;
map.triggerRepaint(); // keep the animation running
return true;
},
};
map.addImage("pulsing-dot", pulsingDot, { pixelRatio: 2 });
mapEl.setLayerVisibility("points", true);
});
  • map.triggerRepaint() inside render() keeps frames coming; return false from render() on frames where nothing changed.
  • pixelRatio: 2 treats the 200 px canvas as a 100 px icon, crisp on high-density screens.