rguides

Build an Animated Chart with gganimate

Animated charts bring your data to life. Whether you are showing stock prices moving over time, survey responses changing across demographics, or population trends evolving across decades, animation helps your audience see the story unfold. This project walks you through building animated visualizations with gganimate, the ggplot2 extension for animations.

What you will build

By the end of this project, you will have created a polished animated visualization showing the growth of tech companies stock performance over a 10-year period. You will start with a static ggplot, add animation layers, customize transitions, and export the final product as a GIF or video.

The techniques you learn apply to any time series or categorical data. You will understand how to control animation timing, add smooth transitions between states, and create animations that are informative without being distracting.

Setting up your environment

First, install and load the required packages:

install.packages(c("gganimate", "tidyverse", "lubridate"))

library(gganimate)
library(tidyverse)
library(lubridate)

You also need ImageMagick installed on your system for GIF export. On macOS, install it with Homebrew:

brew install imagemagick

On Ubuntu or Debian:

sudo apt-get install imagemagick

Preparing your data

Create a sample dataset representing tech company stock prices over time:

set.seed(42)

# Generate sample stock data
companies <- c("Apple", "Google", "Microsoft", "Amazon", "Meta")
n_days <- 2520  # ~10 years of trading days

stock_data <- tibble(
  date = rep(seq(as.Date("2014-01-01"), by = "day", length.out = n_days), each = 5),
  company = rep(companies, times = n_days),
  price = NA
)

# Generate realistic-looking stock paths
for (co in companies) {
  start_price <- switch(co, "Apple" = 50, "Google" = 40, "Microsoft" = 30, 
                       "Amazon" = 25, "Meta" = 60)
  returns <- rnorm(n_days, mean = 0.0003, sd = 0.02)
  prices <- start_price * cumprod(1 + returns)
  stock_data$price[stock_data$company == co] <- prices
}

# Add year column for animation grouping
stock_data <- stock_data %>%
  mutate(year = year(date)) %>%
  filter(company %in% c("Apple", "Google", "Microsoft", "Amazon"))

This creates a realistic-looking dataset with multiple companies tracked over approximately 10 years. The random seed ensures reproducibility.

Building your first animated chart

Start with a basic line chart and add animation:

p <- ggplot(stock_data, aes(x = date, y = price, color = company)) +
  geom_line(size = 1.2) +
  geom_point(size = 2) +
  scale_y_continuous(labels = scales::dollar) +
  labs(
    title = "Tech Stock Prices",
    subtitle = "2014-2024",
    x = "Date",
    y = "Stock Price ($)",
    color = "Company"
  ) +
  theme_minimal()

# Add animation
animate(p, nframes = 100, fps = 10)

This works, but it shows all points appearing at once. The real power of gganimate comes from transition functions that control how data states transition.

Using transitions for smooth animation

The transition_states() function splits your data into states and animates between them:

p_animated <- ggplot(stock_data, aes(x = date, y = price, color = company)) +
  geom_line(size = 1.2) +
  geom_point(size = 2, aes(group = company)) +
  scale_y_continuous(labels = scales::dollar, limits = c(0, 400)) +
  labs(
    title = "Tech Stock Prices: {closest_state}",
    subtitle = "2014-2024",
    x = "Date",
    y = "Stock Price ($)",
    color = "Company"
  ) +
  theme_minimal() +
  transition_states(year, transition_length = 2, state_length = 1) +
  enter_fade() +
  exit_fade()

animate(p_animated, nframes = 150, fps = 15, width = 800, height = 500)

Key additions here:

  • transition_states(year, ...) splits the data by year and animates between states
  • {closest_state} in the title shows the current year
  • enter_fade() and exit_fade() add smooth opacity transitions
  • Fixed y-axis limits ensure the chart does not jump around

Adding annotations and visual polish

Make your animation more informative by adding a running annotation:

p_polished <- ggplot(stock_data, aes(x = date, y = price, color = company)) +
  # Current values as points
  geom_point(size = 3, aes(group = company)) +
  # Connecting line
  geom_line(size = 1.2) +
  # Add current price label
  geom_text(aes(label = paste0("$", round(price, 0)), 
                group = company),
            hjust = -0.3, size = 4, fontface = "bold") +
  scale_y_continuous(labels = scales::dollar, limits = c(0, 450)) +
  scale_color_brewer(palette = "Set1") +
  labs(
    title = "Tech Stock Performance",
    subtitle = "Year: {closest_state}",
    x = "Date",
    y = "Stock Price ($)",
    color = "Company"
  ) +
  theme_minimal(base_size = 14) +
  theme(
    plot.title = element_text(face = "bold", size = 18),
    legend.position = "bottom"
  ) +
  transition_states(year, transition_length = 2, state_length = 1) +
  enter_fade() +
  exit_fade() +
  ease_aes("cubic-in-out")

animate(p_polished, nframes = 200, fps = 20, width = 900, height = 600)

The ease_aes("cubic-in-out") makes the animation start slow, speed up, then slow down again—this feels more natural than linear motion.

Creating a bar chart race

Another popular animation style is the bar chart race, showing how rankings change over time:

# Prepare data for bar race
bar_data <- stock_data %>%
  filter(month(date) == 1) %>%  # Annual snapshots
  select(date, company, price) %>%
  mutate(rank = rank(-price)) %>%
  filter(rank <= 4)

p_race <- ggplot(bar_data, aes(x = rank, y = price, fill = company, group = company)) +
  geom_col(width = 0.8) +
  geom_text(aes(y = price, label = paste0("$", round(price, 0))),
            hjust = -0.2, size = 5, fontface = "bold") +
  scale_x_reverse() +
  scale_y_continuous(limits = c(0, 400)) +
  scale_fill_brewer(palette = "Set1") +
  coord_flip() +
  labs(
    title = "Top Tech Stocks by Price",
    subtitle = "January {closest_state}",
    x = "",
    y = "Stock Price ($)",
    fill = "Company"
  ) +
  theme_minimal(base_size = 14) +
  theme(
    axis.text.x = element_blank(),
    axis.ticks.x = element_blank()
  ) +
  transition_states(date, transition_length = 1, state_length = 2) +
  enter_grow() +
  ease_aes("back-out")

animate(p_race, nframes = 100, fps = 10, width = 700, height = 500)

Saving your animation

Export your animation in multiple formats:

# Save as GIF
anim_save("tech-stocks.gif", animation = p_polished)

# Save as MP4 (requires ffmpeg)
anim_save("tech-stocks.mp4", animation = p_polished, renderer = ffmpeg_renderer())

# Save with specific settings
anim_save(
  "custom-animation.gif",
  animation = p_polished,
  nframes = 150,
  fps = 15,
  width = 900,
  height = 600,
  res = 100
)

The GIF format works everywhere but can be large. For web use, consider reducing frames or using a video embed.

Understanding animation timing

The timing of your animation significantly affects how it is perceived:

Transition LengthEffect
Short (0.5-1)Quick, energetic changes
Medium (2-3)Smooth, readable transitions
Long (4+)Dramatic, emphasizes each state
Frame RateBest For
10-15 fpsSimple transitions, web GIFs
20-30 fpsSmoother, video exports
30+ fpsVery smooth, but larger files

Start with 15 fps and adjust based on your content complexity.

Common issues and solutions

Animation appears jerky

Increase the number of frames:

animate(p, nframes = 200, fps = 20)

File size is too large

Reduce frames or dimensions, or export as video instead of GIF.

Labels overlap

Adjust hjust values or reduce the number of elements shown.

Chart jumps between states

Fix the axis limits explicitly:

scale_y_continuous(limits = c(0, max_value))

Taking it further

Now that you have the basics, try these enhancements:

  1. Interactive animations: Combine gganimate with plotly for hover information
  2. Custom easing: Try different ease_aes() functions for varied feels
  3. Multiple transitions: Use transition_time() for continuous data
  4. Pause states: Add pause() to hold on specific frames

gganimate transitions

transition_time(time_variable) creates an animation where the x position (or any aesthetic) changes smoothly over time. transition_reveal(time_variable) progressively reveals data, useful for line charts that build up over time. transition_states(category_variable) cycles through discrete states (categories), useful for “morph” animations between chart types.

Each transition type has corresponding entry and exit aesthetics. enter_grow() scales new elements in from zero. exit_fade() fades out removed elements. enter_fly(x_loc = 5) slides elements in from a specific position. Mix and match entry/exit animations to control how attention flows.

Rendering options

animate(p, nframes = 100, fps = 20, width = 800, height = 600, renderer = gifski_renderer()) renders a GIF. renderer = av_renderer() produces an MP4 video using the av package. renderer = magick_renderer() produces a magick image object that can be further manipulated. Higher nframes produces smoother animation; higher fps plays faster.

For Shiny integration, renderImage() outputs the rendered animation. renderGif() from the gganimate package renders and serves the GIF directly. For production, pre-render the animation and serve the file rather than rendering on each page load.

Labeling animated charts

labs(title = "{frame_time}") includes the current frame’s time value in the title. {closest_state} shows the current state for transition_states. These dynamic labels are automatically updated for each frame, providing context without cluttering the chart with a static legend.

For smooth text transitions, ease_aes("cubic-in-out") applies easing to all aesthetics including text positions, producing professional-looking smooth transitions.

Performance considerations

gganimate renders each frame as a separate ggplot, so performance scales with the number of frames and the complexity of each plot. Reduce frame count with nframes in animate(), and reduce rendering time by simplifying geoms, geom_point() is faster than geom_line() for large datasets. For publication, render to GIF with gifski renderer or to video with ffmpeg renderer. GIF is universally supported but limited to 256 colors per frame; MP4 via ffmpeg gives better quality at smaller file sizes. Use anim_save() to write the output to disk.

gganimate fundamentals

gganimate extends ggplot2 with animation. Add a transition layer to specify how the plot should change over time. transition_time(year) animates between discrete time values, interpolating between states. transition_states(category) cycles through factor levels.

animate(p, nframes = 100, fps = 20, duration = 5) renders the animation. The result is a gif_image by default. anim_save("chart.gif", animation = last_animation()) saves to disk. animate(p, renderer = av_renderer("video.mp4")) saves as MP4 using the av package.

transition_reveal(time_var) progressively reveals data as time increases, drawing a cumulative line chart frame by frame. This is the standard animation for time series with a “drawing” effect. shadow_trail(distance = 0.05) leaves a fading trail of previous positions.

Easing and timing

ease_aes("cubic-in-out") applies cubic easing, transitions start and end slowly and speed up in the middle, reducing the jarring effect of linear transitions. Other easing functions: "linear", "bounce-out", "elastic-in-out", "back-in".

transition_states(state, transition_length = 2, state_length = 1) controls the proportion of time spent transitioning vs pausing on each state. transition_length = c(1, 2, 1) specifies different transition lengths between each pair of states.

enter_fade() and exit_fade() animate new points appearing and old points disappearing with a fade effect. enter_grow() scales new points from zero. These make it visually clear which points are entering or leaving the animation.

Labels and titles

labs(title = "Year: {frame_time}") uses frame variables in labels. {closest_state} shows the current state value. {frame} shows the frame number. {nframes} shows the total frames. These dynamic labels keep the viewer oriented during the animation.

animate(p, width = 800, height = 600, res = 150) controls output dimensions. For presentations, higher resolution (res = 150 or res = 200) prevents blurry appearance. The width and height are in pixels at the specified resolution.

When animation helps

Animation is most valuable when time is a meaningful dimension and the viewer benefits from seeing how values change, rather than just comparing start and end states. Bar chart races (changing rankings over time) and geographic spread (showing how a phenomenon spreads spatially) are classic use cases.

Static alternatives are often more informative for data communication: small multiples (facet_wrap(~ year)) show all time periods simultaneously for direct comparison. Animation makes comparison between specific frames harder because you cannot see them at the same time. Use animation for presentations and explorations; use small multiples for analysis.

See also