2025, Dec 28 21:00

Plotly Express animation pitfalls: how to handle late-appearing categories with dummy seeds or None

Learn how to fix missing legend categories in Plotly Express animations by seeding the first frame with dummy points or None, ensuring stable color mapping.

Animating classification changes over time in Plotly Express can be surprisingly tricky. A common case is a growing scatter where each new frame adds a point, and color encodes a state. The pain points surface as soon as categories appear only after the first frame: the legend ignores them, and those points sometimes fail to render until much later.

Problem setup

Consider a simple y=x sequence that grows one point per frame. Points with x values below 3 are blue, and points from 3 and higher are red. The following code builds that animation. The behavior to watch for: the legend contains only colors present in the first frame, and the red category is missing until it appears later. To make it reproducible, the data is precomputed, one row per point per frame.

import numpy as np
import pandas as pd
import plotly.express as px

# Create a y=x sequence that expands one point per frame
x_vals = [val for stop in range(10) for val in np.arange(0, stop, 1)]
frame_no = [stop for stop in range(10) for _ in np.arange(0, stop, 1)]
y_vals = x_vals
shade_vals = ['blue' if v < 3 else 'red' for v in x_vals]

basic_df = pd.DataFrame().assign(
    x_pos=x_vals,
    y_pos=y_vals,
    label=shade_vals,
    tick=frame_no,
).sort_values(by='tick')

px.scatter(
    basic_df, x='x_pos', y='y_pos', color='label', animation_frame='tick'
).update_layout(
    title='Animation of y=x line growing from (0, 0) to (9, 9)'
).update_yaxes(range=[-1, 10]).update_xaxes(range=[-1, 10]).show()

This naive version triggers two issues. First, the legend includes only the categories present in the initial frame, which means red is missing early on. Second, because the color mapping follows what’s visible in the first frame, points of a late-appearing category may not draw until that category first shows up.

One workaround is to inject an initial frame containing all records so every category is known up front. The next example forces a starting frame at zero. It does fix the legend, but the per-frame updates still misbehave: only the category introduced in a given frame updates correctly, while the other category appears to “stick” until its first real point is added.

seeded_df = pd.concat([basic_df.assign(tick=0), basic_df])

px.scatter(
    seeded_df, x='x_pos', y='y_pos', color='label', animation_frame='tick'
).update_layout(
    title='Animation of y=x line growing from (0, 0) to (9, 9)'
).update_yaxes(range=[-1, 10]).update_xaxes(range=[-1, 10]).show()

What’s going on

The color legend and rendering behavior are tied to what exists in the first frame. If a category doesn’t appear there, it’s omitted from the legend and may not render until the category enters the scene. Pushing a “zero” frame with all points forces the legend to include all categories, but the per-frame redraw still favors the category that just got introduced, leaving other categories lagging visually.

Practical fix

A pragmatic approach is to seed the first frame with a single dummy point for each category that might appear later. That point can live outside the plotted range so it doesn’t pollute the visible data while still registering the category with the legend and the animation engine. Below is a compact build that creates frames as arrays, explodes them into rows, and adds one off-range red point to frame 0.

import numpy as np
import pandas as pd
import plotly.express as px

# Define frame indices
steps = np.arange(10)

# Build x and y per frame
x_series = [np.arange(s + 1) for s in steps]
y_series = x_series

# Color mapping per point
hues = [['blue' if n < 3 else 'red' for n in row] for row in x_series]

# Assemble and explode
anim_df = pd.DataFrame().assign(stage=steps, xi=x_series, yi=y_series, hue=hues)
anim_df = anim_df.explode(['xi', 'yi', 'hue'])
anim_df = anim_df[['stage', 'xi', 'yi', 'hue']]

# Seed frame 0 with a red point outside the y-range
anim_df.loc[-1] = [0, 3, -2, 'red']

px.scatter(anim_df, x='xi', y='yi', color='hue', animation_frame='stage') \
  .update_yaxes(range=[-1, 10]) \
  .update_xaxes(range=[-1, 10])

This ensures the red category exists from the start, so the legend is complete and the animation draws both colors consistently. You may notice a visual quirk on the first red point sliding in from below, since the seeded point sits off-screen.

If you prefer to avoid the sliding effect and keep autoscale behavior clean when pausing on the first frame, an alternative is to insert a dummy record with None values for x and y. That way nothing appears off-axis and there’s no “growing” or “sliding” of the seeded dot.

# Alternative seeding to avoid sliding and autoscale surprises
# Insert an invisible red entry at frame 0
anim_df_alt = anim_df.copy()
anim_df_alt = anim_df_alt[anim_df_alt.index != -1]
anim_df_alt.loc[-1] = [0, None, None, 'red']

px.scatter(anim_df_alt, x='xi', y='yi', color='hue', animation_frame='stage') \
  .update_yaxes(range=[-1, 10]) \
  .update_xaxes(range=[-1, 10])

Why this matters

When you’re animating streaming or out-of-order updates and rely on color to convey state, stability of the legend and consistent rendering across frames is critical. If a category fails to appear until later in the sequence, you risk confusing readers with missing styles, half-updated frames, and jumpy behavior when scrubbing through the slider. Seeding categories up front makes the animation predictable and the story legible.

Takeaways

For animations in Plotly Express that change category membership over time, make sure all categories exist in the initial frame. If you want zero visual footprint, seed with None coordinates; if you want maximal simplicity, place a dummy point just outside the visible range. Both approaches keep the legend complete and the per-frame color updates consistent. With that in place, the growing y=x line behaves as intended: blue points accumulate at first, then as x reaches 3, red points are introduced one per frame without rendering glitches.