> "Animation is powerful when the motion is the story, and distracting when it is not."
Learning Objectives
- Create animations using FuncAnimation with init and update functions
- Export animations to GIF (with Pillow) and MP4 (with ffmpeg) with appropriate frame rates and resolution
- Create interactive figures with mpl_connect for mouse events
- Use %matplotlib widget (ipympl) for interactive matplotlib in Jupyter notebooks
- Implement basic interactive features: hover tooltips, click-to-highlight
- Evaluate when matplotlib animation is appropriate vs. when to use a dedicated tool
- Apply change-blindness principles from Chapter 2 to design helpful animations
In This Chapter
- 15.1 When to Animate (and When Not To)
- 15.2 FuncAnimation: The Core API
- 15.3 Exporting Animations
- 15.4 Writing Good Update Functions
- 15.5 Change Blindness and Animation Design
- 15.5 Keyboard Events and Zoom/Pan Interactivity
- 15.5 ArtistAnimation: A Simpler Alternative
- 15.5 Animation Pitfalls and Performance
- 15.5 Animating Scatter Plots, Bar Charts, and Heatmaps
- 15.5 A Complete Example: The Climate Animation
- 15.5 Event Handling: Making Charts Interactive
- 15.6 ipympl: Interactive matplotlib in Jupyter
- 15.7 matplotlib vs. Dedicated Interactive Tools
- Chapter Summary
- Spaced Review: Concepts from Chapters 1-14
Chapter 15: Animation and Interactivity in matplotlib
"Animation is powerful when the motion is the story, and distracting when it is not." — The working consensus of modern data visualization design.
Chapter 10 taught you matplotlib's architecture. Chapters 11, 12, 13, and 14 built up a full toolkit for producing static charts: essential chart types, customization, multi-panel layouts, and specialized chart types. This final chapter of Part III extends that toolkit in two specific directions: animation (charts that change over time) and interactivity (charts that respond to user input).
Both animation and interactivity are less central to matplotlib than static chart production. matplotlib was designed as a publication library, and its animation and interactivity features are, frankly, less polished than its static rendering. For serious interactive work, Plotly, Bokeh, and D3.js are usually better tools. For serious animation work, matplotlib can produce GIFs and MP4s but is slower and less flexible than dedicated animation libraries.
Nonetheless, matplotlib's animation and interactivity features are useful, accessible, and sometimes the right tool for a specific job. A research scientist who needs an animated simulation result does not need Plotly; matplotlib's FuncAnimation will produce a GIF or MP4 that embeds in a paper or presentation. A data analyst who wants to hover over points in a scatter plot to identify outliers does not need Bokeh; matplotlib's mpl_connect event handling will work. Knowing when matplotlib's built-in animation and interactivity are sufficient — and when they are not — is the practical skill this chapter builds.
A warning: this chapter is less hands-on than the earlier Part III chapters because animation and interactivity depend on specific runtime environments (Jupyter with ipympl, command-line Python, or a specific GUI backend) and specific external dependencies (Pillow for GIFs, ffmpeg for MP4). Some of the examples in this chapter will require setup that is beyond the scope of a standard Python installation. The exercises include setup notes where needed, but be prepared to troubleshoot more than in earlier chapters.
The chapter does not have a single threshold concept the way earlier chapters do. The core insight is simpler: animation adds a time dimension, and you should use it only when time (or some analog of time) is part of the story. For static snapshots of data, static charts are better. For processes that unfold over time or converge through iterations or respond to user input, matplotlib's animation and interactivity tools are the right choice.
15.1 When to Animate (and When Not To)
Before diving into the matplotlib API, it is worth spending a section on the question of when animation is appropriate at all. This is the most important decision in any animation project.
When Animation Helps
Animation is the right choice when:
1. The story is about a process unfolding over time. A time-series chart that builds year by year shows both the final state and the path. A static chart shows only the final state. For stories about how something got to where it is now, animation adds information that static charts cannot.
2. The story is about convergence or iteration. Scientific simulations, optimization algorithms, machine learning training curves — these produce sequences of states that converge toward some result. An animation shows the convergence; a static snapshot shows only the endpoint.
3. The story is about flow or movement. Vector fields, particle trajectories, waves, and similar dynamic systems are naturally animated. Static contour plots can approximate them but lose the motion.
4. The audience needs to see the steps. For educational purposes, stepping through an algorithm or a proof or a concept one piece at a time helps the viewer follow. An animation where each frame adds one piece is a teaching tool.
5. The chart is interactive. An interactive matplotlib chart (with hover or click) is technically an animation because the chart updates in response to events. The same tools that handle frame updates also handle event-driven updates.
When Animation Hurts
Animation is the wrong choice when:
1. The data is fundamentally static. A scatter plot of survey responses, a bar chart of product sales, a histogram of distributions — these have no time dimension, and animating them adds motion that does not encode anything. The viewer sees the motion and expects it to mean something, and when it does not, they are confused.
2. The audience will not see the full animation. A 10-second animation on a web page might be viewed for 2 seconds before the reader scrolls past. If your key finding is revealed at second 8, most readers will miss it. For short-attention contexts, a static chart is more reliable.
3. The motion distracts from the data. Change blindness (from Chapter 2) means that the visual system struggles to track multiple simultaneously-changing elements. An animation with five moving lines and a moving scatter plot and a color cycle is visual noise that the viewer cannot parse.
4. The output medium cannot handle animation. Print publications, PDF reports, and many slide deck templates do not support animated content. If your output is print or PDF, any animation effort is wasted — the final output is a frame, and a well-chosen static chart is better than a frozen frame of an animation.
5. The animation adds production complexity without adding clarity. If a static chart communicates the finding clearly, do not animate it just to be fancy. Animation is production overhead; it must pay for itself in clarity to be worth the investment.
The Change-Blindness Principle
Chapter 2 introduced change blindness — the perceptual phenomenon where the visual system fails to notice changes between one state and the next when the transition is abrupt or when the change involves many elements simultaneously. Animation is subject to change blindness: if too much changes between frames, the viewer cannot track the individual changes.
The practical implication: animations should change one thing at a time (or a few related things), not everything at once. A line chart that builds year by year works because only the new year's point is added in each frame. A scatter plot where every point moves every frame does not work, because the viewer cannot track which point went where.
For effective animation:
- Change one thing per frame. Add one data point, advance one step, move one element.
- Keep the rest stable. The axes, the title, the color scheme, and the background should not change.
- Use smooth transitions. Abrupt jumps are hard to follow; smooth interpolation between states helps the visual system track.
- Limit the frame rate. Very fast animations are too quick to read; very slow animations are boring. 10-30 frames per second is typical; slower for educational animations, faster for smooth motion.
These are design principles, not matplotlib specifics. They apply to any animation tool. But they are worth stating here because matplotlib makes it easy to produce animations that change too much and confuse readers. The tool does not enforce good design; the designer does.
15.2 FuncAnimation: The Core API
matplotlib's main animation tool is matplotlib.animation.FuncAnimation. It takes a figure, an update function, and some configuration, and produces an animation by repeatedly calling the update function to generate each frame.
The Basic Pattern
import numpy as np
import matplotlib.pyplot as plt
from matplotlib.animation import FuncAnimation
# Create the figure and axes
fig, ax = plt.subplots(figsize=(10, 5))
# Initial data
x = np.linspace(0, 10, 100)
line, = ax.plot([], [], color="#d62728", linewidth=2) # empty line to start
ax.set_xlim(0, 10)
ax.set_ylim(-1.5, 1.5)
ax.set_title("Sine Wave Building")
# Init function (called once at the start)
def init():
line.set_data([], [])
return (line,)
# Update function (called for each frame)
def update(frame):
line.set_data(x[:frame], np.sin(x[:frame]))
return (line,)
# Create the animation
ani = FuncAnimation(
fig,
update,
frames=range(1, len(x) + 1),
init_func=init,
interval=50,
blit=True,
)
# Display or save
ani.save("sine_wave.gif", writer="pillow", fps=20)
The key elements:
1. Create the figure once. The animation updates a single figure repeatedly, so you create the figure with plt.subplots() as usual. Set the initial limits, title, and any static elements.
2. Create stub artists. Use ax.plot([], []) to create an empty line that will be updated each frame. The comma in line, = ax.plot(...) unpacks the list returned by ax.plot into a single Line2D variable.
3. Define init and update functions. The init function is called once at the start and clears the plot. The update(frame) function is called for each frame with the current frame index, and it modifies the line's data to reflect the current state.
4. Call FuncAnimation. Pass the figure, the update function, the frames (either an integer count or an iterable), the init function, the interval in milliseconds, and blit=True for efficient rendering.
5. Save or display. Use ani.save(...) to export the animation to a file, or call plt.show() in an interactive session to play it live.
Update Functions in Detail
The update function is the heart of any animation. It should:
- Take a frame argument (an integer index or a value from the
framesiterable). - Modify the artists' properties (set_data, set_color, set_alpha, etc.) to reflect the current state.
- Return the modified artists as a tuple (required for
blit=True).
Common patterns:
Growing a line chart:
def update(frame):
line.set_data(x[:frame], y[:frame])
return (line,)
Moving a single point:
def update(frame):
point.set_data([x[frame]], [y[frame]])
return (point,)
Updating a title with the current frame:
def update(frame):
ax.set_title(f"Year {1880 + frame}")
line.set_data(years[:frame], values[:frame])
return (line,)
Multiple moving artists:
def update(frame):
line1.set_data(x[:frame], y1[:frame])
line2.set_data(x[:frame], y2[:frame])
return (line1, line2)
Notice that in every case, the update function modifies existing Artist objects rather than creating new ones. This is essential for efficient animation: creating new Artists each frame would be slow, and blit=True only works when the set of Artists is stable.
Blit and Performance
The blit=True parameter enables blitting — a rendering optimization where only the changed parts of the figure are redrawn each frame, rather than the entire figure. Blitting makes animations much faster, especially for complex figures.
For blit to work:
- The update function must return the changed Artists.
- Static elements (axes, title, labels, spines) cannot be changed during the animation.
- If you need to change a static element, blit must be set to False (slower but more flexible).
For most animations, blit=True is the right choice. Only disable it if you need to change something that blitting does not allow (like updating the axis limits mid-animation).
Interval and Frame Rate
The interval parameter specifies the delay between frames in milliseconds. interval=50 means 50 ms between frames, which is 20 frames per second. Common choices:
interval=100(10 fps): slow, educational pace. Good for step-by-step explanations.interval=50(20 fps): moderate pace. Good for most data animations.interval=33(~30 fps): smooth motion. Good for fluid animations.interval=16(~60 fps): very smooth. Rarely needed for data animation.
Slower intervals make animations easier to follow but feel sluggish. Faster intervals look smoother but may be hard to read. 20-30 fps is a reasonable default.
15.3 Exporting Animations
Animations live as Python objects until you save them. matplotlib supports several output formats through different writers.
GIF Export with PillowWriter
from matplotlib.animation import PillowWriter
ani.save(
"animation.gif",
writer="pillow", # or PillowWriter(fps=20)
fps=20,
dpi=100,
)
GIF is the most portable format for animated web display. It has no sound, limited color depth (256 colors), and relatively large file sizes, but it works in every browser, email client, and slide deck tool. Use GIF for web embedding, social media sharing, and any context where playback reliability matters.
The pillow writer uses Python's Pillow library (installed automatically with matplotlib in most distributions). fps=20 sets the frame rate. dpi=100 sets the resolution — for GIFs, higher DPI means larger files without much visual improvement, so 100 is usually fine.
MP4 Export with FFMpegWriter
from matplotlib.animation import FFMpegWriter
writer = FFMpegWriter(fps=30, bitrate=1800)
ani.save("animation.mp4", writer=writer, dpi=200)
MP4 is the standard video format for modern web and presentation use. It supports higher quality, smaller file sizes, and richer features than GIF. The catch is that MP4 export requires ffmpeg — a separate command-line tool that must be installed on your system (brew install ffmpeg on Mac, apt-get install ffmpeg on Linux, or downloaded from ffmpeg.org on Windows).
If ffmpeg is not installed, ani.save("animation.mp4", writer="ffmpeg") will fail with an error. Fall back to GIF export if you cannot install ffmpeg.
HTML Export for Notebooks
from matplotlib.animation import HTMLWriter
ani.save("animation.html", writer="html")
Or, in a Jupyter notebook:
from IPython.display import HTML
HTML(ani.to_html5_video())
to_html5_video() returns HTML with an embedded video that plays in Jupyter. This is the cleanest way to display animations inline in notebooks without saving to a file. Requires ffmpeg for encoding.
Choosing a Format
- GIF: portable, works everywhere, but large files and limited colors. Use for social media, blog embeds, and slide decks that do not support video.
- MP4: best quality, smaller files, supports audio. Requires ffmpeg. Use for serious presentation and publication.
- HTML5 video: inline display in notebooks. Requires ffmpeg.
- Separate PNG frames: for maximum flexibility, save each frame as a PNG and use a video editor to combine them. Overkill for most uses.
For quick demos and notebooks, HTML5 video is easiest. For polished presentations, MP4 is best. For web sharing where compatibility matters, GIF is safest.
15.4 Writing Good Update Functions
The update function is the core of any FuncAnimation. Writing a good one — efficient, correct, debuggable — is the main craft skill of animation. This section gives you patterns.
The Pure Update Pattern
A "pure" update function modifies only the artists that need to change in the current frame, returns them, and does nothing else. Example:
def update(frame):
line.set_data(x[:frame], y[:frame])
return (line,)
No prints, no global state modification, no unnecessary computation. Pure update functions are efficient and debuggable.
Avoid Recomputing Static Data
A common mistake is to recompute static data inside the update function:
# BAD: recomputes x and y every frame
def update(frame):
x_data = np.linspace(0, 10, 100) # wasteful: same every frame
y_data = np.sin(x_data) # wasteful
line.set_data(x_data[:frame], y_data[:frame])
return (line,)
Fix: compute static data once, outside the update function:
x_data = np.linspace(0, 10, 100)
y_data = np.sin(x_data)
def update(frame):
line.set_data(x_data[:frame], y_data[:frame])
return (line,)
The update function runs many times per second. Any computation inside it repeats unnecessarily. Move as much as possible to the setup code outside the update.
Use Mutable Containers for State
Python closures can read variables from the enclosing scope, but assigning to them requires nonlocal or (in older Python) a mutable container workaround:
current_frame = [0] # mutable list
def update(frame):
current_frame[0] = frame
# ... use current_frame[0] ...
return (line,)
The [0] indexing is a list-based workaround for closure-rebinding. In Python 3 you can also use nonlocal:
current_frame = 0
def update(frame):
nonlocal current_frame
current_frame = frame
return (line,)
Use whichever is cleaner for your code.
Handle Edge Cases
Update functions are called with every frame in the sequence, including frame 0 (which often should show no data). Handle this:
def update(frame):
if frame == 0:
line.set_data([], [])
else:
line.set_data(x[:frame], y[:frame])
return (line,)
This prevents edge-case errors on the first frame. Similarly, handle the last frame if your animation needs to end in a specific state.
Test Before Animating
Before running the full animation, test the update function by calling it directly with a specific frame:
update(5) # should produce frame 5 of the animation
plt.show()
If the test call produces the wrong output, the update function has a bug. Debug the update function directly before wrapping it in FuncAnimation, because animation errors are hard to debug at runtime.
15.5 Change Blindness and Animation Design
Chapter 2 introduced change blindness — the perceptual phenomenon where the visual system fails to notice changes that occur during an abrupt transition or across many simultaneously-changing elements. Animation is subject to change blindness, and good animation design explicitly accounts for it.
The Phenomenon
If a chart has many moving elements and the viewer is asked to track a specific one, they often fail. The visual system can track perhaps one or two moving things at a time; beyond that, the individual motions blur into a general sense of "things are moving." Experimental demonstrations of change blindness in everyday scenes (like switching faces during a "brief cut" in a video) show how dramatic the effect can be.
For data animation, change blindness means:
- Animating too many elements simultaneously makes individual changes unnoticeable.
- Abrupt jumps between frames (without interpolation) exceed the perceptual system's ability to track.
- Background changes (titles, axes, colors) during the animation are often unnoticed by the viewer.
- Long animations lose viewer attention, and changes late in the sequence are not absorbed.
Design Principles
Given change blindness, effective animations follow specific rules:
1. Change one thing per frame. Add one data point, advance one step, highlight one new element. If multiple things must change, consider whether they can be combined into a single conceptual change (e.g., moving a line forward in time is one change, even though many pixels move).
2. Keep the rest stable. The axes, title, background, and static elements should not change during the animation. If you need to update the title (for example, to show the current year), accept that the change may not be noticed and supplement it with a visual cue on the chart itself.
3. Use smooth transitions. Interpolate between frames so the motion is continuous rather than jumpy. matplotlib's FuncAnimation does not interpolate automatically — if you need smooth motion, you have to generate the intermediate frames yourself.
4. Limit the duration. A 5-10 second animation is absorbed by most viewers. A 30-second animation loses them. If your story takes longer than 10 seconds to tell, either speed it up (higher frame rate, less per-frame information) or split it into multiple animations.
5. End with a static final frame. Animations fade from viewer memory quickly, but the final state persists. Design so that the final frame tells the whole story — the reader who glances at the final image alone should still get the main point.
6. Provide contextual anchors. As the animation builds, keep static elements (like a reference line, a baseline, or a target value) visible so the viewer has anchors against which to measure the motion.
Animation vs. Small Multiples
Chapter 8 argued that small multiples are often better than single charts with many series. The same argument applies to animation: a small-multiple grid of "before" and "after" frames is often clearer than a single animated chart that morphs from before to after, because the viewer can study each state at their own pace.
A good rule of thumb: if your animation is trying to show "how these five states relate to each other," a small multiple of the five states is usually better. If your animation is trying to show "this process unfolding continuously," animation is the right choice. Know which story you are telling.
When Animation Is the Right Choice
Despite these cautions, animation is the right choice for specific stories:
- Growing accumulations — a cumulative line chart building over time, a running total, a scoreboard.
- Converging simulations — an optimization algorithm finding its minimum, a random walk exploring a space.
- Flow visualizations — fluid dynamics, traffic patterns, particle movement.
- Step-by-step educational content — showing how an algorithm works, building a mathematical proof, explaining a mechanism.
- Before-after transitions with a single clear transform.
For these stories, animation reveals something static charts cannot. For other stories, static charts or small multiples are more effective. Choose deliberately.
15.5 Keyboard Events and Zoom/Pan Interactivity
Beyond mouse events, matplotlib supports keyboard input and built-in zoom/pan controls. These let you build simple keyboard-driven interfaces for exploring data.
Keyboard Events
fig, ax = plt.subplots()
ax.plot(np.random.randn(100))
current_index = [0] # mutable container for the callback to modify
def on_key(event):
if event.key == "right":
current_index[0] += 1
elif event.key == "left":
current_index[0] -= 1
elif event.key == "q":
plt.close(fig)
return
ax.set_title(f"Index: {current_index[0]}")
fig.canvas.draw_idle()
fig.canvas.mpl_connect("key_press_event", on_key)
The key_press_event fires whenever a key is pressed while the figure window has focus. The event.key attribute is a string — "right", "left", "up", "down", "q", "enter", "escape", or the literal key character for letters and numbers.
Common patterns:
- Arrow keys for navigation: advance through data points, frames, or subplots.
- Letter keys for shortcuts:
qto quit,rto reset,sto save. - Space or enter for pause/resume: in combination with animations.
Keyboard events work in any interactive backend and are part of matplotlib's built-in toolkit for quick exploratory tools.
Built-in Zoom and Pan
matplotlib's interactive backends include a built-in toolbar with zoom and pan buttons. In a Qt or Tk backend, the toolbar appears at the bottom of the figure window. In Jupyter with %matplotlib widget, the toolbar appears above the figure.
For most use cases, these built-in controls are enough for basic interactivity:
- Zoom: click and drag to select a rectangle, which becomes the new axis limits.
- Pan: click and drag to translate the view.
- Home: reset to the original view.
- Back/Forward: step through zoom/pan history.
- Save: export the current view as a PNG.
You do not need to write any code for these features; they are part of the interactive backend. Just use an interactive backend (%matplotlib widget in Jupyter, or any GUI backend outside it) and the toolbar appears automatically.
Combining Events with Animations
You can combine event handling with animations — for example, pausing the animation when the user clicks:
paused = [False]
def on_click(event):
if paused[0]:
ani.resume()
else:
ani.pause()
paused[0] = not paused[0]
fig.canvas.mpl_connect("button_press_event", on_click)
ani.pause() and ani.resume() are methods on the FuncAnimation object. Combined with event handlers, they let you build pause-resume controls for animations.
15.5 ArtistAnimation: A Simpler Alternative
For animations where every frame is pre-computed, matplotlib provides ArtistAnimation as a simpler alternative to FuncAnimation. Instead of an update function, you pre-compute a list of "frame artists" and pass them to ArtistAnimation:
from matplotlib.animation import ArtistAnimation
fig, ax = plt.subplots(figsize=(10, 5))
ax.set_xlim(0, 10)
ax.set_ylim(-1.5, 1.5)
x = np.linspace(0, 10, 100)
frames = []
# Pre-compute each frame as a list of artists
for i in range(1, len(x) + 1):
# Each frame draws the line up to index i
line, = ax.plot(x[:i], np.sin(x[:i]), color="#d62728", linewidth=2)
frames.append([line])
ani = ArtistAnimation(fig, frames, interval=50, blit=True)
ani.save("sine_artist.gif", writer="pillow", fps=20)
The main difference from FuncAnimation: you build the list of frames explicitly as a Python list of artist lists, where each inner list contains the artists visible in that frame. ArtistAnimation then cycles through the frames.
When to use ArtistAnimation:
- The frames are cheap to compute in advance (small data, simple calculations).
- You want explicit control over every frame.
- You are creating an animation from a collection of pre-existing artists (e.g., from a simulation already computed).
When to use FuncAnimation:
- Frames depend on the current state and need to be computed on demand.
- You want efficient memory use (pre-computing all frames can be expensive for long animations).
- You want to use the
framesparameter to control frame count or iterate over specific values.
For most cases, FuncAnimation is more flexible. ArtistAnimation is a useful fallback when you already have a list of artists and want to animate them directly.
15.5 Animation Pitfalls and Performance
Animations are harder to debug than static charts because the issues only appear when the animation is running. This section catalogs the common problems.
Pitfall 1: The Animation Does Not Render
Symptom: You call FuncAnimation but nothing moves. In a notebook, you see only the initial frame.
Cause: The most common cause is that the animation object is garbage-collected before it has a chance to run. If you do not save a reference to the ani object, Python may delete it, and the animation never plays.
Fix: Always assign the result of FuncAnimation(...) to a variable (ani = FuncAnimation(...)). Hold the reference for the lifetime of the figure. Do not replace ani with a new animation in the same variable unless you intend to stop the previous one.
Pitfall 2: blit=True Does Not Update Some Elements
Symptom: You set blit=True for performance, but some elements (titles, axis labels, colors) do not update during the animation.
Cause: Blit only re-renders the artists returned from the update function. Anything not in the return tuple — including titles, axis labels, and Axes properties — is not re-rendered.
Fix: Either include the problematic artists in the return tuple (if they are Artist objects that can be modified in place), or set blit=False. The latter is slower but more flexible.
Pitfall 3: Memory Leaks in Long Animations
Symptom: A long animation runs fine for a few seconds and then becomes increasingly slow.
Cause: Creating new artists each frame (instead of modifying existing ones) leaks memory. Each call to ax.plot() inside the update function adds a new Line2D to the Axes, and old lines are never removed.
Fix: Modify existing artists with line.set_data(...) instead of creating new ones with ax.plot(...). If you must create new artists, explicitly remove old ones with line.remove().
Pitfall 4: The Saved Animation File Is Huge
Symptom: A simple line-chart animation saves as a multi-megabyte GIF.
Cause: GIF files are large by nature, especially at high DPI or high frame counts. A 10-second 30fps animation has 300 frames; at 100 DPI each frame is a small PNG, and the total can easily be several megabytes.
Fix: Reduce the frame count (lower fps or shorter duration), reduce the DPI (100 is usually fine for GIFs), or switch to MP4 (much smaller files at the same quality). For long animations with many frames, MP4 is almost always the right choice.
Pitfall 5: ffmpeg Is Not Installed
Symptom: ani.save("animation.mp4", writer="ffmpeg") raises an error about ffmpeg not being found.
Cause: MP4 export requires ffmpeg, which is a separate command-line tool that is not installed with matplotlib.
Fix: Install ffmpeg (brew install ffmpeg on Mac, apt-get install ffmpeg on Linux, download from ffmpeg.org on Windows). If you cannot install it, fall back to GIF export with writer="pillow".
Pitfall 6: The Animation Looks Jittery or Slow
Symptom: The animation plays but feels unsmooth — frames flicker, motion is choppy, or the playback is noticeably slower than expected.
Cause: Several possibilities: the interval is set too high (interval=500 means 2 fps, which looks choppy), the computer cannot render frames fast enough, or blit is disabled and every frame is re-rendering the entire figure.
Fix: Reduce interval (30-50 ms is good for smooth motion), enable blit=True if possible, and simplify the figure (fewer subplots, fewer elements per frame) to speed up rendering.
Pitfall 7: The Animation Is Interactive but Does Not Respond
Symptom: You connected event handlers with mpl_connect, but clicking or hovering does nothing.
Cause: Event handling requires an interactive backend. In a Jupyter notebook with the default %matplotlib inline, event handlers are silently ignored.
Fix: Switch to %matplotlib widget (ipympl) in Jupyter, or run the script with a GUI backend outside the notebook. In the notebook, verify that the backend is interactive with import matplotlib; print(matplotlib.get_backend()).
15.5 Animating Scatter Plots, Bar Charts, and Heatmaps
Beyond the basic line-building pattern, matplotlib supports animating any chart type by modifying the relevant Artists each frame. This section covers the most common variations.
Animated Scatter Plots
from matplotlib.animation import FuncAnimation
fig, ax = plt.subplots(figsize=(8, 8))
ax.set_xlim(-5, 5)
ax.set_ylim(-5, 5)
# Initial empty scatter
sc = ax.scatter([], [], s=50, alpha=0.7)
def update(frame):
# Generate a new random scatter each frame
np.random.seed(frame)
x = np.random.randn(100)
y = np.random.randn(100)
sc.set_offsets(np.c_[x, y])
return (sc,)
ani = FuncAnimation(fig, update, frames=60, interval=100, blit=True)
ani.save("scatter_animation.gif", writer="pillow", fps=10)
The key method is sc.set_offsets(new_positions), where new_positions is a 2D array of [x, y] coordinates. Each frame, you generate new positions and pass them to set_offsets. For color and size updates, use sc.set_array(new_colors) and sc.set_sizes(new_sizes).
Animated scatter plots are useful for showing particle motion, random walks, bootstrap sampling, or any process where the individual points move independently. They are also used in simulations of physical systems.
Animated Bar Charts
fig, ax = plt.subplots(figsize=(10, 5))
categories = ["A", "B", "C", "D", "E"]
bars = ax.bar(categories, [0]*5, color="steelblue")
ax.set_ylim(0, 100)
ax.set_title("Animated Bar Chart")
def update(frame):
new_heights = np.random.rand(5) * 100
for bar, h in zip(bars, new_heights):
bar.set_height(h)
return bars
ani = FuncAnimation(fig, update, frames=50, interval=200, blit=False)
For bar charts, iterate over the bars and call bar.set_height(new_height) for each. Because Rectangle artists are updated individually, blit can be finicky — using blit=False is safer.
The classic "bar chart race" visualization (a bar chart that reorders as values change over time) is a more elaborate version of this pattern, typically requiring custom sorting and animation logic.
Animated Heatmaps
fig, ax = plt.subplots(figsize=(8, 6))
im = ax.imshow(np.zeros((20, 20)), cmap="viridis", vmin=0, vmax=1, animated=True)
fig.colorbar(im, ax=ax)
def update(frame):
data = np.random.rand(20, 20)
im.set_array(data)
return (im,)
ani = FuncAnimation(fig, update, frames=50, interval=100, blit=True)
For heatmaps, call im.set_array(new_data) to update the displayed values. The animated=True parameter on the initial imshow call enables efficient rendering. This is useful for visualizing fields that evolve over time — weather simulations, population density changes, heat diffusion, and similar 2D dynamic systems.
Multi-Artist Animations
When you need to animate multiple artists simultaneously, return them all from the update function:
def update(frame):
line1.set_data(x[:frame], y1[:frame])
line2.set_data(x[:frame], y2[:frame])
dot.set_data([x[frame]], [y1[frame]])
return (line1, line2, dot)
Every artist mentioned in the return tuple is recognized as "changed" and rendered efficiently by blit. Artists not in the return tuple may not update correctly with blit=True, so always include every artist you modify.
15.5 A Complete Example: The Climate Animation
The progressive climate project for this chapter is an animated line chart that draws the temperature series year by year. Here is the complete code:
import numpy as np
import matplotlib.pyplot as plt
from matplotlib.animation import FuncAnimation
# Assume climate data is loaded
years = np.arange(1880, 2025)
temperature = -0.3 + (years - 1880) * 0.01 + np.random.randn(len(years)) * 0.15
# Set up the figure
fig, ax = plt.subplots(figsize=(12, 5))
ax.set_xlim(1880, 2024)
ax.set_ylim(-0.8, 1.6)
ax.set_title("Global Temperature, 1880-1880", fontsize=14, loc="left", fontweight="semibold")
ax.set_xlabel("Year")
ax.set_ylabel("Temperature Anomaly (°C)")
# Declutter
ax.spines["top"].set_visible(False)
ax.spines["right"].set_visible(False)
# Reference line at zero
ax.axhline(0, color="gray", linewidth=0.5, linestyle="--")
# Empty line to be populated
line, = ax.plot([], [], color="#d62728", linewidth=2)
current_dot, = ax.plot([], [], "o", color="#d62728", markersize=8)
# Init function
def init():
line.set_data([], [])
current_dot.set_data([], [])
return (line, current_dot)
# Update function
def update(frame):
current_year = years[frame]
current_temp = temperature[frame]
line.set_data(years[:frame + 1], temperature[:frame + 1])
current_dot.set_data([current_year], [current_temp])
ax.set_title(f"Global Temperature, 1880-{current_year}", fontsize=14, loc="left", fontweight="semibold")
return (line, current_dot)
# Create the animation
ani = FuncAnimation(
fig,
update,
frames=len(years),
init_func=init,
interval=40, # 25 fps
blit=False, # blit must be False because we update the title
)
# Save as GIF
ani.save("climate_animation.gif", writer="pillow", fps=25, dpi=100)
# Or save as MP4 (requires ffmpeg)
# ani.save("climate_animation.mp4", writer="ffmpeg", fps=25, dpi=200)
Walk through the key decisions:
1. Figure setup is done once. The figure, axes, limits, title template, and static elements (reference line, spines) are all set before the animation begins. The animation only updates the dynamic parts.
2. Two artists are updated each frame. The line (representing the full history up to the current year) and the current dot (highlighting the most recent point). Using a dot in addition to the line draws the reader's eye to the "where we are now" position.
3. The title updates dynamically. Because the title includes the current year, it changes each frame. This means blit must be False — blit does not support title changes. The animation is slightly slower as a result, but that is acceptable for a data animation.
4. The interval is 40 ms (25 fps). This is a moderate pace that looks smooth without being too fast for the reader to follow.
5. The fps in the save call matches the interval. interval=40 means 25 fps; fps=25 in the save call ensures the output playback matches.
6. GIF is the default output. For web display and slide decks, a GIF is the safest format. For higher-quality presentations, swap the commented-out MP4 save line for the GIF line.
The result is an animation where the temperature line builds from left to right, the current year is highlighted with a moving dot, and the title updates to show the current year. At 25 fps, the 144 years of data take about 6 seconds to play — long enough to show the acceleration of warming, short enough to hold viewer attention.
15.5 Event Handling: Making Charts Interactive
matplotlib's interactive features let you respond to user input — mouse clicks, mouse movements, keyboard events, scroll wheel events. The mechanism is fig.canvas.mpl_connect, which binds a callback function to an event.
The Basic Pattern
def on_click(event):
if event.inaxes is not None:
print(f"Clicked at ({event.xdata:.2f}, {event.ydata:.2f})")
fig, ax = plt.subplots()
ax.plot([1, 2, 3, 4, 5], [1, 4, 9, 16, 25])
cid = fig.canvas.mpl_connect("button_press_event", on_click)
The mpl_connect method takes an event name and a callback function. The callback receives an event object with attributes like xdata (x-coordinate in data units), ydata (y-coordinate in data units), button (which mouse button), and inaxes (which Axes the event occurred on, if any).
Common event types:
button_press_event: mouse button pressed.button_release_event: mouse button released.motion_notify_event: mouse moved.scroll_event: mouse wheel scrolled.key_press_event: keyboard key pressed.key_release_event: keyboard key released.pick_event: an artist was "picked" (requires artists to be made pickable).figure_enter_event: mouse entered the figure.
A Hover Tooltip Example
import numpy as np
import matplotlib.pyplot as plt
fig, ax = plt.subplots(figsize=(10, 6))
x = np.random.randn(50)
y = np.random.randn(50)
sc = ax.scatter(x, y, alpha=0.7)
# Create an annotation that will be updated
annot = ax.annotate(
"",
xy=(0, 0),
xytext=(10, 10),
textcoords="offset points",
bbox=dict(boxstyle="round", facecolor="white", edgecolor="gray"),
arrowprops=dict(arrowstyle="->"),
)
annot.set_visible(False)
def on_hover(event):
if event.inaxes != ax:
return
cont, ind = sc.contains(event)
if cont:
# Found a point under the cursor
idx = ind["ind"][0]
pos = sc.get_offsets()[idx]
annot.xy = pos
annot.set_text(f"Point {idx}\n({pos[0]:.2f}, {pos[1]:.2f})")
annot.set_visible(True)
fig.canvas.draw_idle()
else:
if annot.get_visible():
annot.set_visible(False)
fig.canvas.draw_idle()
fig.canvas.mpl_connect("motion_notify_event", on_hover)
plt.show()
This creates a scatter plot where hovering over a point shows its coordinates in a floating annotation. The pattern:
- Create a scatter plot and an initially-invisible annotation.
- Define an event handler that checks whether the mouse is over a scatter point.
- If yes, update the annotation with the point's data and make it visible.
- If no, hide the annotation.
- Connect the handler to
motion_notify_event.
The sc.contains(event) method checks whether the scatter plot contains the event position, returning a tuple (contained, info) where info["ind"] is an array of point indices under the cursor.
Interactive Backend Requirements
Event handling only works in an interactive backend. If you run the code in a Jupyter notebook with %matplotlib inline, event handling will not work because the inline backend displays static images.
For interactive matplotlib in Jupyter, use:
%matplotlib widget # modern, requires ipympl package
or:
%matplotlib notebook # legacy, works in classic notebooks
Outside Jupyter, event handling works automatically when you call plt.show() with a GUI backend (Qt, Tk, WxAgg, etc.). The specific backend depends on your Python installation.
15.6 ipympl: Interactive matplotlib in Jupyter
%matplotlib widget enables interactive matplotlib inside Jupyter notebooks, replacing the default static inline backend with an interactive one that supports pan, zoom, hover, and event handling.
Setup
# Install ipympl if not already installed
# pip install ipympl
# In a notebook cell:
%matplotlib widget
import matplotlib.pyplot as plt
fig, ax = plt.subplots()
ax.plot([1, 2, 3], [4, 5, 6])
The chart appears inline in the notebook, but with zoom, pan, and save buttons added. Event handlers connected with mpl_connect work as expected.
When to Use ipympl
Use ipympl when:
- You are working in a Jupyter notebook and want interactive exploration of your data.
- You need basic interactivity (zoom, pan, hover) without leaving the notebook.
- You want to use event handling for custom interactions.
Do not use ipympl when:
- You need pixel-perfect reproducibility — the interactive backend can render slightly differently from the static inline backend.
- You are producing charts for export to static formats (PDF, PNG). Interactive features do not translate.
- You need more advanced interactivity than matplotlib provides. For rich web-style interactivity, use Plotly or Bokeh.
15.7 matplotlib vs. Dedicated Interactive Tools
This section is a pragmatic comparison: when is matplotlib animation and interactivity the right choice, and when should you reach for a dedicated tool?
matplotlib Is Right When:
- You are already using matplotlib and do not want to learn a new library.
- The animation or interactivity is simple (hover, click-to-highlight, frame-by-frame build).
- You need to produce a static output file (GIF, MP4) for embedding or sharing.
- You are working in a scientific or publication context where matplotlib is the standard.
- The audience will view the animation as an embedded file, not as a live interactive app.
Plotly / Bokeh / D3 Are Right When:
- You need rich web-based interactivity (dropdowns, sliders, linked views).
- The output is a web app or dashboard, not a file.
- Users will explore the data interactively, not just watch a pre-made animation.
- You need features matplotlib does not support (crosshair tooltips across multiple charts, brush selection, cross-filtering).
- Performance matters for large datasets (Plotly and Bokeh have better optimization for thousands of interactive points).
The right tool depends on the use case. For most matplotlib users, the rule is: use matplotlib for static publication, use it for simple animations exported to GIF or MP4, and switch to dedicated tools for complex web-based interactivity. Chapter 20 and beyond cover Plotly for the cases where matplotlib is not enough.
Chapter Summary
This chapter extended matplotlib with animation (FuncAnimation) and interactivity (mpl_connect, ipympl). Animation adds time as a dimension, useful when the story is about a process unfolding. Interactivity lets the user respond to the chart, useful for exploration. Both are less polished in matplotlib than in dedicated interactive tools, but both are accessible, built-in, and sometimes the right choice.
The core of animation is FuncAnimation(fig, update, frames, interval, blit). The update function modifies Artist properties each frame; blit=True enables efficient rendering. Export to GIF with Pillow, to MP4 with FFMpeg, or to HTML5 video for notebook display.
Event handling uses fig.canvas.mpl_connect(event_name, callback) to bind a function to a user event. Common events include button_press_event, motion_notify_event, and key_press_event. The callback receives an event object with xdata, ydata, button, and other attributes. For interactivity in Jupyter, use %matplotlib widget (ipympl).
The chapter does not have a single threshold concept. The practical advice is: use animation only when time is part of the story, follow Chapter 2's change-blindness principle to avoid overwhelming animations, and know when to reach for a dedicated interactive tool instead of matplotlib.
This is the last chapter of Part III. You have now covered matplotlib from architecture (Chapter 10) through essential chart types (Chapter 11), customization (Chapter 12), multi-panel layouts (Chapter 13), specialized chart types (Chapter 14), and animation/interactivity (Chapter 15). You can produce any chart matplotlib supports, customize it to publication quality, arrange it in any multi-panel layout, and animate or make it interactive when the story requires it.
Next in Part IV: seaborn. seaborn is a high-level statistical visualization library built on top of matplotlib. It provides simpler APIs for many of the chart types you have been producing in raw matplotlib, plus specialized statistical chart types that matplotlib does not include natively. Part IV walks through seaborn's philosophy, its relational and distributional and categorical function families, and the techniques that make statistical visualization more concise than matplotlib alone.
Spaced Review: Concepts from Chapters 1-14
-
Chapter 2: Change blindness is the perceptual phenomenon where too much simultaneous change is hard to track. How does it apply to matplotlib animations specifically?
-
Chapter 4: The chapter argues that motion can distort readings the same way that static chart choices can. How does Chapter 4's anti-distortion discipline apply to animated charts?
-
Chapter 9: Scrollytelling (Chapter 9 case study) is a form of animation driven by user scroll. How does it compare to matplotlib FuncAnimation in terms of interactivity and output format?
-
Chapter 10: Animation is built on matplotlib's Artist tree — the update function modifies existing Artists. How does this connect to the threshold concept of Chapter 10 (everything is an object)?
-
Chapter 12: Animated charts still need all the styling from Chapter 12. Which customization applies differently to animations than to static charts?
-
Chapter 13: Multi-panel animations are possible but more complex. What are the challenges of animating a multi-panel figure, and how does
blitbehave? -
Chapter 14: Specialized chart types can be animated too. Could you animate a heatmap? A polar plot? A contour plot? What would each animation reveal that the static version hides?