How to Animate Points on a Map With Plotly in Python

On this page

Problem statement

A map of a year of events hides the order in which they happened. An animated map, with a play button and a slider stepping through months or days, shows sequences, seasons and spreading patterns that a static map flattens. Plotly Express builds one from a DataFrame with a single extra argument, animation_frame, but the defaults trip people up: frames sorted alphabetically instead of chronologically, colour ranges and marker sizes that jump from frame to frame, and frames with no data that vanish from the slider.

This page animates every magnitude 4.5 and larger earthquake of 2024 from the USGS catalogue month by month with plotly.express.scatter_map, fixes the frame order and the colour range, adds cumulative frames that keep earlier events visible, and counts what each frame contains.

Quick answer

Give animation_frame a column that sorts in the right order, and fix the colour range so it does not change between frames:

import plotly.express as px
fig = px.scatter_map(Q, lat="latitude", lon="longitude", animation_frame="month", color="depth", size="size", size_max=12,
                     range_color=(0, 300), color_continuous_scale="Plasma_r", zoom=0.6, center={"lat": 10, "lon": 150},
                     map_style="carto-positron", hover_name="place", category_orders={"month": MONTHS})
fig.update_layout(margin=dict(l=0, r=0, t=0, b=0))
fig.write_html("data/quakes_by_month.html", include_plotlyjs="cdn")
print(f"frames: {len(fig.frames)}; first {fig.frames[0].name}, last {fig.frames[-1].name}; HTML {Path('data/quakes_by_month.html').stat().st_size / 1e6:.2f} MB")
frames: 12; first 2024-01, last 2024-12; HTML 0.57 MB

The animation has 12 frames in order, January to December, and the HTML file is 0.57 MB.

Screenshot of the Plotly animated earthquake map at the January 2024 frame, with points coloured by depth, and a play button and slider from 2024-01 to 2024-12 below the map. Screenshot of the Plotly animated earthquake map at the January 2024 frame, with points coloured by depth, and a play button and slider from 2024-01 to 2024-12 below the map.
One frame of the animation.

Frame order, frame contents, cumulative frames

1. Frame order

Frames follow the order in which values first appear, or category_orders if given. A month name column without it can start anywhere; zero-padded YYYY-MM strings sort correctly on their own:

by_name = Q.time.dt.strftime("%B")
print("first appearance order of month names:", list(dict.fromkeys(by_name))[:4], "...")
print("sorted month names:", sorted(by_name.unique())[:4], "... (alphabetical, wrong)")
print("frame names from the figure:", [f.name for f in fig.frames][:4], "...")
first appearance order of month names: ['January', 'February', 'March', 'April'] ...
sorted month names: ['April', 'August', 'December', 'February'] ... (alphabetical, wrong)
frame names from the figure: ['2024-01', '2024-02', '2024-03', '2024-04'] ...

Month names happen to appear in order here because the data is sorted by time, but sorted alphabetically they start with April, August, December. The YYYY-MM frame names avoid both traps.

2. What each frame holds

Count the events and the largest magnitude per month, so the animation can be read against numbers:

per = Q.groupby("month").agg(events=("mag", "size"), largest=("mag", "max"), m6=("mag", lambda m: (m >= 6).sum()))
print(per.to_string())
print(f"busiest month: {per.events.idxmax()} ({per.events.max()}); quietest: {per.events.idxmin()} ({per.events.min()})")
         events  largest  m6
month                       
2024-01     623      7.5  12
2024-02     508      6.3   4
2024-03     509      6.9   8
2024-04     705      7.4  12
2024-05     546      6.6   8
2024-06     507      7.2   7
2024-07     474      7.4   9
2024-08     450      7.1  10
2024-09     481      6.5   8
2024-10     522      6.6   7
2024-11     487      6.8   5
2024-12     585      7.3   9
busiest month: 2024-04 (705); quietest: 2024-08 (450)

April was the busiest month, 705 events, 172 of them in the Hualien sequence in eastern Taiwan; August the quietest, 450. Every month had at least four earthquakes of M6 or more.

M4.5 and larger earthquakes per month in 2024. events per frame, 2024January623April705 (172 Hualien)August450December5850250500750Busiest April, quietest August spatialworkflow.io
What each frame holds.

3. Cumulative frames

To show the year building up, repeat each event in every later frame. The data grows, so check the file size:

cum = pd.concat([Q[Q.month <= m].assign(frame=m) for m in MONTHS], ignore_index=True)
f2 = px.scatter_map(cum, lat="latitude", lon="longitude", animation_frame="frame", color="depth", range_color=(0, 300),
                    color_continuous_scale="Plasma_r", size="size", size_max=12, zoom=0.6, center={"lat": 10, "lon": 150}, map_style="carto-positron")
f2.write_html("data/quakes_cumulative.html", include_plotlyjs="cdn")
print(f"rows: {len(Q):,} events -> {len(cum):,} in cumulative frames; HTML {Path('data/quakes_cumulative.html').stat().st_size / 1e6:.2f} MB")
rows: 6,397 events -> 42,559 in cumulative frames; HTML 2.03 MB

Cumulative frames repeat each event in every later month, multiplying the rows 6.7 times to 42,559 and the file to 2.03 MB.

Frame speed and the slider

The data used on this page

Every earthquake of magnitude 4.5 or more worldwide in 2024 from the USGS FDSN event service, as CSV, with a YYYY-MM month column for the frames.

from pathlib import Path

import numpy as np
import pandas as pd
import requests

Path("data").mkdir(exist_ok=True)
p = Path("data/quakes_m45_2024.csv")
if not p.exists():
    r = requests.get("https://earthquake.usgs.gov/fdsnws/event/1/query", timeout=600,
                     params=dict(format="csv", starttime="2024-01-01", endtime="2025-01-01", minmagnitude=4.5, orderby="time-asc"))
    r.raise_for_status()
    p.write_bytes(r.content)
Q = pd.read_csv(p)
Q = Q[Q["type"] == "earthquake"].reset_index(drop=True)
Q["time"] = pd.to_datetime(Q.time)
Q["month"] = Q.time.dt.strftime("%Y-%m")
Q["size"] = (Q.mag - 4) ** 2
MONTHS = sorted(Q.month.unique())
print(f"{len(Q):,} earthquakes of M4.5+ in 2024 across {len(MONTHS)} months")
6,397 earthquakes of M4.5+ in 2024 across 12 months

The 6,397 earthquakes fall in 12 months, each a frame.

Example 1: frame speed and the slider

The play button's speed is set in the layout's update menus. Slow it down and check the settings:

fig.layout.updatemenus[0].buttons[0].args[1]["frame"]["duration"] = 900
fig.layout.updatemenus[0].buttons[0].args[1]["transition"]["duration"] = 300
fig.write_html("data/quakes_by_month.html", include_plotlyjs="cdn")
print(f"frame duration {fig.layout.updatemenus[0].buttons[0].args[1]['frame']['duration']} ms; slider steps {len(fig.layout.sliders[0].steps)}")
frame duration 900 ms; slider steps 12

Each frame now shows for 900 ms; the slider has a step per month.

Twelve small world maps, one per month of 2024, each showing that month's M4.5 and larger earthquakes with its count, from 450 in August to 705 in April. Twelve small world maps, one per month of 2024, each showing that month's M4.5 and larger earthquakes with its count, from 450 in August to 705 in April.
The frames as small multiples.

An animation is a list of frames

Steps to a readable animated map in Plotly. a readable animated map1frame columnYYYY-MM strings sort correctly2category_ordersset the order explicitly3range_color fixedsame colours in every frame4center and zoom fixedthe map stays still5frame duration900 ms per month6check file sizecumulative: 6.7x the rows12 monthly frames, 6,397 earthquakes spatialworkflow.io
A readable animated map.

Plotly builds an animation as a list of frames, one per unique value of animation_frame, each holding the data for that value, plus a slider and play buttons that step through them. Plotly Express takes the frame order from the order in which values first appear in the data, unless category_orders sets it, which is why month names, weekday names and unsorted data produce scrambled animations. Zero-padded strings such as 2024-03 sort chronologically and avoid the problem; real dates work too but produce long frame labels.

Consistency between frames is what makes an animation readable. A colour range computed per frame changes the meaning of each colour as the animation plays; fixing range_color keeps it constant. The same applies to marker size and to the view: a fixed center and zoom keep the map still while the data changes. All frames are embedded in the HTML, so cumulative animations, which repeat events in every later frame, multiply the data roughly by half the number of frames: 6.7 times for 12 monthly frames here.

Empty frames, animation groups and video export

Animated Plotly maps: problems and fixes. animation trapsTrapInsteadframes in alphabetical orderYYYY-MM or category_orderscolours jump between framesrange_colorempty frames vanishadd empty rowshuge cumulative filesfewer frames or aggregatepx.scatter_map with animation_frame spatialworkflow.io
Animation traps.
  • Empty frames: a value with no rows produces no frame; add explicit empty rows or accept the gap in the slider.
  • animation_group: links the same object across frames for smooth movement, such as a vehicle ID; events need none.
  • Very many frames: daily frames over years make large files and a slider too fine to use; aggregate.
  • Export to video: Plotly has no direct video export; render frames to images or use matplotlib's animation tools.

FAQ

How do I make an animated map in Plotly?

Add animation_frame="column" to px.scatter_map or px.choropleth_map; each unique value becomes a frame with a play button and a slider.

Why is my Plotly animation in the wrong order?

Frames follow the order values first appear; sort the data, use zero-padded YYYY-MM strings or set category_orders.

How do I stop colours changing between frames?

Set range_color=(min, max) so every frame uses the same colour scale.

How do I change the animation speed?

Edit fig.layout.updatemenus[0].buttons[0].args[1]["frame"]["duration"], in milliseconds.