Creating Animated Charts with gganimate and ggplot2 in R
Creating animated charts with gganimate adds a temporal dimension to your ggplot2 charts. Instead of a static image, you get a sequence of frames that reveal change over time, categories that morph into one another, or data that accumulates step by step. If you already know ggplot2, the learning curve is gentle — you keep everything you know and layer on animation.
How gganimate extends ggplot2
gganimate works by inserting animation-specific layers between your data and the rendered output. The package adds two pseudo-aesthetics that ggplot2 does not normally know about:
framemaps data to individual animation frames. Points sharing the same frame value render together.groupdefines which data points belong to the same entity across frames. Without it, gganimate cannot track a line or bar across time steps.
The critical insight is that you always start with a valid ggplot2 plot. gganimate only takes effect once you add a transition_*() function and call animate().
library(ggplot2)
library(gganimate)
# Build a static plot first—this must be valid before animation
p <- ggplot(economics, aes(x = date, y = psavert)) +
geom_line(color = "#3366cc", linewidth = 0.8) +
labs(title = "Personal Savings Rate", x = "Date", y = "PSAVERT (%)")
# Now add animation
p + transition_reveal(date)
The transition_reveal(date) call tells gganimate to render the line progressively as the date variable increases. Each frame adds the next portion of data.
Choosing a transition function
transition_*() functions are the core of gganimate. They determine how the plot data is segmented into frames.
transition_states()
Use this for discrete categories or time steps. gganimate interpolates smoothly between each state.
p <- ggplot(mtcars, aes(x = wt, y = mpg, color = factor(cyl))) +
geom_point(size = 3) +
labs(title = "Cylinders: {closest_state}") +
transition_states(cyl, transition_length = 2, state_length = 1) +
enter_fade() + exit_shrink()
animate(p, nframes = 60, fps = 10)
{closest_state} is a gganimate label expression that inserts the current state value into the title. Other useful placeholders include {frame} for the frame number and {transitioning} for a boolean.
transition_time()
For continuous time variables, transition_time() generates a number of frames proportional to the number of unique time points in your data. It handles the interpolation automatically.
p <- ggplot(economics_long, aes(x = date, y = value01, color = variable)) +
geom_line() +
transition_time(date) +
shadow_wake(wake_length = 0.3)
animate(p, nframes = 80, fps = 15)
shadow_wake() leaves a fading trail of previous frames behind the current data, which helps viewers see the direction of change.
transition_reveal()
This reveals data progressively along a single variable—typically time on the x-axis. Unlike transition_time(), it does not interpolate between missing time points. It simply shows more of the line as time advances.
p <- ggplot(gss_laboured, aes(x = year, y = hours, color = degree)) +
geom_line(size = 1) +
geom_point(size = 2) +
transition_reveal(year)
animate(p, renderer = gifski_renderer("reveal.gif"))
transition_layers()
Animates the plot layer by layer, building up from a blank canvas. Each frame adds one geom layer.
p <- ggplot(mtcars, aes(x = factor(cyl), y = mpg)) +
geom_jitter(width = 0.2, color = "#d35400") +
geom_boxplot(fill = NA, color = "#2c3e50", outlier.shape = NA) +
geom_violin(fill = NA, color = "#8e44ad", trim = FALSE) +
transition_layers(layer_order = c("jitter", "boxplot", "violin"))
animate(p, nframes = 30)
transition_filter()
Keeps only data matching a condition, then transitions to the next matching condition. Useful for filtering animations where data “zooms in” based on a threshold.
p <- ggplot(iris, aes(x = Petal.Length, y = Petal.Width, color = Species)) +
geom_point(size = 3) +
transition_filter(Sepal.Length > 4, Sepal.Length > 5, Sepal.Length > 6) +
enter_fade() + exit_fade()
Enter and exit transitions
How data points appear and disappear between states is controlled by enter_*() and exit_*() functions. By default, gganimate removes departing points instantly; adding enter/exit transitions makes the motion feel natural.
p <- ggplot(mtcars, aes(x = reorder(rownames(mtcars), mpg), y = mpg, fill = factor(cyl))) +
geom_bar(stat = "identity") +
coord_flip() +
transition_states(cyl, transition_length = 1, state_length = 0.5) +
enter_fade() + # new bars fade in
exit_fade() + # departing bars fade out
enter_shrink() # bars shrink from full height
You can stack multiple enter/exit effects—they are additive:
p + enter_fade() + enter_drift(y_mod = +5) + exit_shrink()
The enter_drift() function shifts data points in a given direction as they enter, which is especially useful for scatter plots where you want new points to float in from above or below.
Controlling animation pacing with ease_aes()
Easing controls the speed curve of transitions—linear means constant speed, while cubic-in-out starts and ends slowly with faster movement in the middle.
p <- ggplot(airquality, aes(x = Day, y = Temp, group = Month, color = factor(Month))) +
geom_line(linewidth = 1) +
transition_time(Month) +
ease_aes("cubic-in-out")
animate(p, nframes = 80)
You can apply different easing to each aesthetic:
ease_aes(x = "sine-in-out", y = "elastic-out")
Some easing options create bouncy or spring-like effects. back-in-out overshoots the target slightly before settling. elastic-out creates a spring effect. These are useful for playful or highlight animations but can feel distracting in serious analytical work.
Rendering and saving animations
The animate() function renders the animation. It accepts several arguments that control output quality and file size.
animate(
plot,
nframes = 100, # total frames—more = smoother but slower
fps = 15, # frames per second
device = "png", # image device: "png", "jpeg", or "gif"
renderer = gifski_renderer("output.gif") # output format
)
The gifski_renderer() produces high-quality GIFs. For video formats, av_renderer() outputs MP4 or WebM but requires ffmpeg to be installed on your system.
To save an animation after rendering:
anim <- ggplot(...) + transition_*() + ...
anim_save("my_animation.gif", anim)
anim_save() captures last_animation(), so you do not need to store the animation object explicitly. For reproducible scripts, storing the animation object first and then saving it is cleaner:
anim <- p + transition_time(year) + enter_fade() + exit_fade()
anim_save("timeseries.gif", animation = anim)
If you need individual frames for external assembly, use file_renderer():
animate(p, renderer = file_renderer("frames/", overwrite = TRUE))
This writes numbered PNG files to the frames/ directory.
A practical example: bar chart race
Bar chart races are a popular animation format. Bars reorder by rank at each time step. The key trick is combining transition_states() with forcats::fct_reorder():
library(gganimate)
library(dplyr)
library(forcats)
# Suppose `data` has columns: country, value, year
p <- data %>%
mutate(country = fct_reorder(country, value, .fun = last)) %>%
ggplot(aes(x = value, y = country)) +
geom_col(fill = "#3498db", width = 0.8) +
geom_text(aes(label = round(value, 1)), hjust = -0.2, size = 3) +
coord_cartesian(clip = "off") +
labs(title = "Year: {closest_state}", x = NULL, y = NULL) +
theme_minimal() +
theme(plot.margin = margin(5, 40, 5, 5)) +
transition_states(year, wrap = FALSE) +
enter_fade() + exit_fade() +
ease_aes("cubic-in-out")
animate(p, nframes = 120, fps = 20, width = 800, height = 500,
renderer = gifski_renderer("barchart-race.gif"))
The coord_cartesian(clip = "off") setting prevents labels from being clipped when they extend past the plot area, which matters when you add text annotations to bars.
Animations in RMarkdown
RMarkdown documents can include animated figures with chunk options:
# ```{r, animation.hook="gifski", fig.show="animate", dev="gif", interval=0.1}
library(gganimate)
p <- ggplot(economics, aes(x = date, y = psavert)) +
geom_line(color = "#3366cc") +
transition_reveal(date)
anim_save("savings.gif", p)
# ```
Set fig.show='animate' in the chunk header so knitr knows to render the animation rather than show a static preview. The interval option controls seconds per frame (0.1s = 10fps).
Performance considerations
Animated plots generate many image frames in memory before assembling them. Large datasets combined with high nframes values can exhaust RAM quickly.
Keep nframes as low as practical for your desired smoothness. For previewing during development, use 20–30 frames. Bump to 60–120 only for final output.
If your animation renders too slowly, consider pre-summarizing your data before plotting rather than relying on ggplot2 to handle millions of rows per frame.
Transitions
gganimate transitions animate between states of the data. transition_time(year) animates through unique values of year, interpolating between frames. transition_states(group) transitions between factor levels. transition_reveal(x) progressively reveals data along the x-axis, useful for drawing a line from left to right over time. Each transition type pairs with appropriate easing functions via ease_aes('linear'), ease_aes('cubic-in-out'), etc.
Rendering
animate(anim, nframes = 100, fps = 10, width = 800, height = 600) renders the animation. renderer = gifski_renderer() produces GIF; renderer = av_renderer() produces MP4 (requires the av package and FFmpeg). anim_save("animation.gif") saves the last animated plot. For Shiny, renderImage() with a temporary file path displays the rendered animation.
Labels and titles
labs(title = "Year: {frame_time}") uses glue syntax to display the current frame’s value in the title. {closest_state} for transition_states, {frame_along} for transition_reveal. These dynamic labels make it clear what each frame represents. shadow_mark() retains previous frames as faded ghost marks, useful for showing trajectories without full transition.
Performance tips
Each frame is a complete ggplot render, so complex plots with many geoms are slow to animate. Simplify: reduce the number of data points per frame, use geom_point() instead of geom_path() for point animations, and lower nframes during development. Use renderer = gifski_renderer(loop = FALSE) for non-looping animations. Profile render time with system.time(animate(anim)) before generating final output.
When to animate and when not to
Animation in data visualization is a tool for showing change, not for making charts look impressive. Before animating a chart, ask: what story does the animation tell that a static chart cannot? Is the viewer expected to watch the full animation or pause and examine specific frames?
The strongest case for animation is when temporal progression is the central message. Epidemiological data showing disease spread over a map, population growth by country over decades, or market share changes over years, these have a natural temporal structure that animation conveys directly. The viewer experiences time along with the data.
The weakest case for animation is when the comparison between two states (before and after, A versus B) is the point. Static small multiples with the two states shown side by side enable direct comparison. Animation forces sequential viewing, making comparison harder. For the presentation context where the audience watches once, animation can work; for analysis where the viewer needs to compare, small multiples are better.
Technical considerations for export
Animated GIFs are the most portable format, they display in any web browser, in Slack, in email, and in most presentation software. The tradeoff is file size and limited color depth (256 colors per frame). For charts with many colors or gradients, GIF artifacts may be visible.
MP4 video export with the av package produces much higher quality with smaller file sizes. It supports full color and smooth gradients. The downside is that embedding video in presentations requires more setup than inserting an image, and not all contexts support video playback (email, for instance).
SVG animation (via gganimate’s svg renderer) produces vector graphics with smooth rendering at any zoom level. Browser support is good, but SVG animations often cannot be embedded in PowerPoint or other non-web contexts.
For loop count in GIF: animate(p, loop = 0) loops infinitely (standard for dashboards and websites). loop = 1 plays once and stops (better for presentations where you want to make a point and move on). Most presentation software does not respect loop count, so test in your target environment.
Performance optimization
Rendering animations is computationally expensive. Each frame requires re-rendering the full ggplot2 chart. For a 200-frame animation at 20 FPS, that is 200 ggplot2 renders. Complex charts with many data points or expensive geoms can take minutes per render.
Strategies for faster rendering: reduce the number of frames (nframes = 50 instead of 200), simplify the data in the animation (aggregate before animating), use geom_point() instead of geom_sf() for large spatial datasets, and use renderer = gifski_renderer(optimize = FALSE) to skip GIF optimization (faster but larger files).
Profile animation render time: system.time(animate(p, nframes = 10)) measures the render time for 10 frames. Multiply by total frames for an estimate. If render time is unacceptable, identify the slowest geom by removing layers and testing each.
Next steps
Now that you understand animations with gganimate, explore these related topics to deepen your knowledge and apply these techniques in more complex scenarios.
See also
- Introduction to ggplot2, The foundation you need before animating
- Customizing ggplot2 Charts, Colors, labels, and visual polish that carry over to animations
- ggplot2 Facets and Themes — Animations respect facet panels; learn how panels drive independent transitions