From static to motion: Animated charts

Notes
Modified

May 22, 2026

NoteLearning objectives
  • Identify the rationale for animated data visualizations
  • Define the grammar of animation
  • Review the major components of the grammar of animation
  • Implement animated charts using {gganimate}

Animation adds a temporal or sequential dimension to a chart. The core question is whether that dimension carries meaningful information that a static chart cannot show.

Animation works well when:

Animation works poorly when:

The Gapminder dataset — GDP per capita vs. life expectancy from 1952 to 2007 — is a canonical example where animation pays off: the global story of development is most powerful when you can watch it unfold year by year.

Animated scatter plot showing GDP per capita on the x-axis (log scale) and life expectancy on the y-axis, with point size encoding population and color encoding country. Points move and grow as the year advances from 1952 to 2007, showing the overall upward trend in both income and longevity across all continents. Source: Animated bubble chart with R and gganimate

Animated scatter plot showing GDP per capita on the x-axis (log scale) and life expectancy on the y-axis, with point size encoding population and color encoding country. Points move and grow as the year advances from 1952 to 2007, showing the overall upward trend in both income and longevity across all continents. Source: Animated bubble chart with R and gganimate

Compare this to an animated line chart showing the popularity of the top 5 baby names in the US over time. The story here is not the overall trend in baby name popularity, but the individual trajectories of specific names. It’s not clear that animation adds value here — the same information could be conveyed with a static line chart.

Animated line chart showing the popularity of the top 5 baby names in the US over time, with separate facets for male and female babies. Source: Extension from R Graph Gallery

Animated line chart showing the popularity of the top 5 baby names in the US over time, with separate facets for male and female babies. Source: Extension from R Graph Gallery

Getting started with {gganimate}

{gganimate} extends the grammar of graphics as implemented by {ggplot2} to include the description of animation. It provides a range of new grammar classes that can be added to the plot object in order to customize how it should change with time.

The basic workflow is:

  1. Build a static {ggplot2} plot
  2. Add one or more {gganimate} grammar classes
  3. Render by printing the plot object (or explicitly with animate())

Building up to animation

The cleanest way to understand the {gganimate} API is to start with a static plot and add animation in a single step. The {babynames} package provides Social Security Administration records of US baby name popularity by year and sex.

p <- ggplot(
  data = babynames |> filter(name == "Benjamin", sex == "M"),
  mapping = aes(x = year, y = n)
) +
  geom_line() +
  labs(
    x = "Year",
    y = "Number of births",
    title = "Popularity of 'Benjamin' over time"
  )

# static plot
p

# animated plot
p +
  transition_reveal(year)
1
Filter the babynames dataset to male babies named Benjamin.
2
Map year to x and birth count to y.
3
Draw a line connecting yearly counts.
4
transition_reveal(year) incrementally uncovers the line along the year axis — each animation frame shows data up to a given year, so the line appears to be drawn from left to right.

Without transition_reveal(), this is a standard line chart. Adding the transition converts it into an animation where time is made explicit through motion.

Grammar of animation

Just as {ggplot2} has a grammar built from layers, scales, and coordinates, {gganimate} has a grammar of animation built from five component types:

  • Transitions: How the data changes across frames
  • Views: How the plot window (axes) changes across frames
  • Shadows: Whether — and how — data from previous frames persists
  • Entrances/Exits: How elements appear and disappear
  • Easing: How aesthetic values interpolate between states

Transitions

Transitions are the core of any animation. They define how data is spread across frames.

Function Description
transition_manual() Build an animation frame by frame (no tweening applied).
transition_states() Transition between frames of a plot (like moving between facets).
transition_time() Like `transition_states()`, except animation pacing respects time.
transition_components() Independent animation of plot elements (by group).
transition_reveal() Gradually extends the data used to reveal more information.
transition_layers() Animate the addition of layers to the plot. Can also remove layers.
transition_filter() Transition between a collection of subsets from the data.
transition_events() Define entrance and exit times of each visual element (row of data).

transition_layers() adds (and optionally removes) geom layers one at a time:

penguins |>
  drop_na() |>
  ggplot(aes(x = flipper_len, y = body_mass)) +
  geom_point() +
  geom_smooth(color = "grey", se = FALSE, method = "loess", formula = y ~ x) +
  geom_smooth(aes(color = species)) +
  transition_layers(
    layer_length = 1,
    transition_length = 2,
    from_blank = FALSE,
    keep_layers = c(Inf, 0, 0)
  )
1
keep_layers controls how long each layer persists. Inf keeps the base point layer indefinitely; 0 means each smooth is removed before the next one appears.

transition_filter() cycles through different subsets of the data:

penguins |>
  drop_na() |>
  ggplot(aes(x = flipper_len, y = body_mass, color = species)) +
  geom_point() +
  transition_filter(
    transition_length = 2,
    filter_length = 1,
    Adelie = species == "Adelie",
    Heavy = body_mass > 5000,
    `Long flippers` = flipper_len > 200,
    keep = TRUE
  ) +
  ggtitle(
    "Filter: {closest_filter}",
    subtitle = "{closest_expression}"
  ) +
  enter_fade() +
  exit_fly(y_loc = 0)
1
Each named argument defines a filter condition; the animation cycles through them.
2
{closest_filter} and {closest_expression} are {gganimate} label variables — they display the name and expression of the current filter condition.

Views

Views control how the plot axes change during the animation. Without a view function, the axes are fixed at their initial range for the full animation.

Function Description
view_follow() Change the view to follow the range of current data.
view_step() Similar to view_follow(), except the view is static between transitions.
view_step_manual() Same as view_step(), except view ranges are manually defined.
view_zoom() Similar to view_step(), but appears smoother by zooming out then in.
view_zoom_manual() Same as view_zoom(), except view ranges are manually defined.

view_follow() rescales the axes to track the current data range:

penguins |>
  drop_na() |>
  ggplot(aes(x = flipper_len, y = body_mass)) +
  geom_point() +
  labs(title = "{closest_state}") +
  transition_states(
    species,
    transition_length = 4,
    state_length = 2
  ) +
  view_follow()
1
view_follow() zooms the axes to the range of the currently displayed data — useful when different states have meaningfully different scales.

Shadows

Shadows determine whether data from previous frames persists as a ghost or trail behind the current frame.

Function Description
shadow_mark() Previous (and/or future) frames leave permanent background marks.
shadow_trail() Similar to shadow_mark(), except marks are from tweened data.
shadow_wake() Shows a shadow which diminishes in size and/or opacity over time.

shadow_wake() leaves a fading trail behind moving points:

penguins |>
  drop_na() |>
  ggplot(aes(x = flipper_len, y = body_mass)) +
  geom_point(size = 2) +
  labs(title = "{closest_state}") +
  transition_states(
    species,
    transition_length = 4,
    state_length = 1
  ) +
  shadow_wake(wake_length = 0.1)
1
wake_length = 0.1 means the trail spans 10% of the animation duration. Points near the head are full size; they shrink toward zero at the tail.

shadow_mark() leaves permanent marks from each previous frame — useful for showing cumulative change:

ggplot(airquality, aes(Day, Temp)) +
  geom_line(color = "red", linewidth = 1) +
  transition_time(Month) +
  shadow_mark(color = "black", linewidth = 0.75)
1
The current month’s temperature profile is drawn in red.
2
shadow_mark() renders each previous month’s line in black, accumulating across the animation.

Entrances and exits

Entrance and exit functions control how new data elements appear and how old elements disappear during transitions.

Function Description
enter_appear()/exit_disappear() Instantly appears or disappears.
enter_fade()/exit_fade() Opacity fades in or out.
enter_grow()/exit_shrink() Element size grows from or shrinks to zero.
enter_recolor()/exit_recolor() Element color blends in from or out to the background.
enter_fly()/exit_fly() Elements move from/to a specific x,y position.
enter_drift()/exit_drift() Elements shift relative from/to their x,y position.
enter_reset()/exit_reset() Clear all previously added entrances/exits.

Easing

Easing controls how aesthetic values interpolate between keyframes. Without easing, all transitions are linear. With easing, transitions can accelerate, decelerate, or overshoot.

p + ease_aes({aesthetic} = {ease})
p + ease_aes(x = "cubic")

A grid of easing function curves showing how ease-in, ease-out, and ease-in-out variants shape the rate of change for animation transitions across different mathematical functions (sine, quad, cubic, etc.).

Easing functions and their effect on animation timing.1

The default is "linear". Common alternatives: "cubic-in-out" for smooth deceleration, "bounce-out" for bouncy arrivals.

Deeper dive: the datasaurus dozen

The datasaurus dozen is a collection of 13 datasets with nearly identical summary statistics (mean, standard deviation, correlation) but very different shapes when plotted. It was designed to demonstrate why summary statistics alone are insufficient — you must always visualize your data.

Animation is particularly effective here: rather than 13 facet panels, a single animated plot cycles through all 13 shapes, letting the viewer appreciate both the visual diversity and the statistical sameness.

Start by plotting all datasets as a plain scatter:

ggplot(datasaurus_dozen, aes(x, y, color = dataset)) +
  geom_point() +
  guides(color = "none")
1
All 13 datasets overlap in a single panel. The individual shapes are invisible.

Use facet_wrap() to reveal each shape:

ggplot(datasaurus_dozen, aes(x, y, color = dataset)) +
  geom_point() +
  facet_wrap(facets = vars(dataset)) +
  coord_cartesian(ratio = 1) +
  guides(color = "none")
1
Each dataset gets its own panel. The dinosaur, star, and circle shapes are now visible — all with x mean ≈ 54.3, y mean ≈ 47.8, and correlation ≈ −0.06.

Replace the facets with animation to experience each shape sequentially:

ggplot(datasaurus_dozen, aes(x, y)) +
  geom_point() +
  coord_cartesian(ratio = 1) +
  transition_states(dataset, 3, 1) +
  labs(title = "Dataset: {closest_state}")
1
transition_states(dataset, 3, 1) cycles through each unique value of dataset. 3 is the transition length (frames spent morphing between states); 1 is the state length (frames spent paused on each state).
2
{closest_state} is a {gganimate} label variable that displays the name of the current state.

The animation makes a point the facets cannot: you watch the same point cloud morph between radically different shapes while the underlying statistics remain fixed.

Animation controls

By default, animate() renders 100 frames at 10 frames per second. Adjust via the gganimate chunk option in Quarto:

```{r}
#| gganimate: !expr list(nframes = 50, fps = 20)
# animation code here
```

Key parameters:

  • nframes: total frames to render (default 100)
  • fps: playback speed in frames per second (default 10)
  • duration: length of the animation in seconds (overrides nframes/fps if set)
  • end_pause: extra frames to hold on the final state before looping

To save an animation to disk:

anim <- my_plot + transition_reveal(year)
animate(anim, nframes = 200, fps = 25, end_pause = 30)
anim_save("output.gif")

See the full reference at https://gganimate.com/reference/animate.html.

Considerations for effective animation

Pace — Animation speed determines whether the viewer can absorb each frame. Fast animations lose detail; slow animations become tedious. A good starting point is 10 fps with enough frames that each state is held for at least 0.5–1 second.

Perplexity — How much is changing at once? If multiple dimensions change simultaneously, it is difficult to know where to look. When complexity is unavoidable, consider breaking one complex animation into several simpler ones shown side by side.

Purpose — Does animation add value? If the insight is visible in a static chart, animation adds cognitive load without payoff. Animation earns its complexity when the temporal or sequential dimension is itself the story — not when it is used as decoration.

Summary

  • Animation adds a temporal dimension to {ggplot2} plots without changing the underlying grammar — add one {gganimate} class to an existing static plot
  • The grammar of animation has five components: transitions, views, shadows, entrances/exits, and easing
  • transition_reveal() progressively uncovers data along an axis; transition_states() cycles through categorical states; transition_time() animates along a continuous time variable
  • Evaluate animations on pace, perplexity, and purpose — use motion when it earns its complexity, not as decoration

Acknowledgements

Material derived in part from Advanced Data Visualization.

Footnotes

  1. Source: easings.net↩︎