Skip to main content
AI in Production 2026 is now open for talk proposals.
Share insights that help teams build, scale, and maintain stronger AI systems.
items
Menu
  • About
    • Overview 
    • Join Us  
    • Community 
    • Contact 
  • Training
    • Overview 
    • Course Catalogue 
    • Public Courses 
  • Posit
    • Overview 
    • License Resale 
    • Managed Services 
    • Health Check 
  • Data Science
    • Overview 
    • Visualisation & Dashboards 
    • Open-source Data Science 
    • Data Science as a Service 
    • Gallery 
  • Engineering
    • Overview 
    • Cloud Solutions 
    • Enterprise Applications 
  • Our Work
    • Blog 
    • Case Studies 
    • R Package Validation 
    • diffify  

Animated Maps with {ggplot2} and {gganimate}

Author: Osheen MacOscar

Published: July 31, 2025

tags: r, ggplot, spatial, gganimate

In this blog post, we are going to use data from the {gapminder} R package, along with global spatial boundaries from ‘opendatasoft’. We are going to plot the life expectancy of each country in the Americas and animate it to see the changes from 1957 to 2007.

The {gapminder} package we are using is from the Gapminder foundation, an independent educational non-profit fighting global misconceptions. The cover issues like global warming, plastic in the oceans and life satisfaction.

First we will load the full dataset from the gapminder package, and see what is contained within it.

data("gapminder_unfiltered", package = "gapminder")
names(gapminder_unfiltered)
## [1] "country"   "continent" "year"      "lifeExp"   "pop"       "gdpPercap"

Then we will filter the dataset to keep life expectancy data for the years from 1952 to 2007 (in 5-year steps).

A shapefile (*.shp) containing the geographical boundaries of each country can be imported using the {sf} R package.

library(sf)
library(dplyr)
if (getwd() == "/home/osheen/corporate-website"){
  world = st_read("content/blog/2025-animated-map/data/world-administrative-boundaries.shp") |>
    select(-"continent")
} else {
  world = st_read("data/world-administrative-boundaries.shp") |>
    select(-"continent")
    
}
## Reading layer `world-administrative-boundaries' from data source 
##   `/home/osheen/corporate-website/content/blog/2025-animated-map/data/world-administrative-boundaries.shp' 
##   using driver `ESRI Shapefile'
## Simple feature collection with 256 features and 8 fields
## Geometry type: MULTIPOLYGON
## Dimension:     XY
## Bounding box:  xmin: -180 ymin: -58.49861 xmax: 180 ymax: 83.6236
## Geodetic CRS:  WGS 84
head(world)
## Simple feature collection with 6 features and 7 fields
## Geometry type: MULTIPOLYGON
## Dimension:     XY
## Bounding box:  xmin: -58.43861 ymin: -34.94382 xmax: 148.8519 ymax: 51.09111
## Geodetic CRS:  WGS 84
##   iso3                          status color_code                     name
## 1  MNP                    US Territory        USA Northern Mariana Islands
## 2 <NA>           Sovereignty unsettled        RUS            Kuril Islands
## 3  FRA                    Member State        FRA                   France
## 4  SRB                    Member State        SRB                   Serbia
## 5  URY                    Member State        URY                  Uruguay
## 6  GUM US Non-Self-Governing Territory        GUM                     Guam
##            region iso_3166_1_              french_shor
## 1      Micronesia          MP Northern Mariana Islands
## 2    Eastern Asia        <NA>            Kuril Islands
## 3  Western Europe          FR                   France
## 4 Southern Europe          RS                   Serbie
## 5   South America          UY                  Uruguay
## 6      Micronesia          GU                     Guam
##                         geometry
## 1 MULTIPOLYGON (((145.6333 14...
## 2 MULTIPOLYGON (((146.6827 43...
## 3 MULTIPOLYGON (((9.4475 42.6...
## 4 MULTIPOLYGON (((20.26102 46...
## 5 MULTIPOLYGON (((-53.3743 -3...
## 6 MULTIPOLYGON (((144.7094 13...

One of the nice things about the {sf} package is that it stores geographical data in a specialised data-frame structure which allows us to merge our boundary data with the gapminder statistics using the same functions that we would use to combine more typical data-frames. Here we join the two datasets, matching the entries by country name, using the dplyr left_join function.

joined = left_join(gapminder_unfiltered, 
                   world, 
                   by = c("country" = "name")) |>
  st_as_sf()
head(joined)
## Simple feature collection with 6 features and 12 fields
## Geometry type: MULTIPOLYGON
## Dimension:     XY
## Bounding box:  xmin: 60.50417 ymin: 29.40611 xmax: 74.91574 ymax: 38.47198
## Geodetic CRS:  WGS 84
## # A tibble: 6 × 13
##   country     continent  year lifeExp      pop gdpPercap iso3  status color_code
##   <chr>       <fct>     <int>   <dbl>    <int>     <dbl> <chr> <chr>  <chr>     
## 1 Afghanistan Asia       1952    28.8  8425333      779. AFG   Membe… AFG       
## 2 Afghanistan Asia       1957    30.3  9240934      821. AFG   Membe… AFG       
## 3 Afghanistan Asia       1962    32.0 10267083      853. AFG   Membe… AFG       
## 4 Afghanistan Asia       1967    34.0 11537966      836. AFG   Membe… AFG       
## 5 Afghanistan Asia       1972    36.1 13079460      740. AFG   Membe… AFG       
## 6 Afghanistan Asia       1977    38.4 14880372      786. AFG   Membe… AFG       
## # ℹ 4 more variables: region <chr>, iso_3166_1_ <chr>, french_shor <chr>,
## #   geometry <MULTIPOLYGON [°]>

Data comes in all shapes and sizes. It can often be difficult to know where to start. Whatever your problem, Jumping Rivers can help.

I am going to select the country column and plot that using the base R plot function for a quick visualisation.

joined |>
  select("country") |>
  plot()
Map of the World with some countries missing.

Hmmmmmmm that doesn’t look quite right does it?

The issue here is a common one when grabbing a spatial boundaries file from the internet. The data sets being joined have different names for some of the countries. For example, in the world data we have USA as ‘United States’ where as in gapminder it’s ‘United States of America’. The dplyr::anti_join function can be helpful finding countries that don’t match. I will use fct_recode from {forcats} to align the world country names with gapminder. In the example below, I am just fixing the USA but you can see from the plot above that several other countries need to be recoded (19 in total), I am doing this behind the scenes to avoid clogging up the page.

library(forcats)
world = world |> 
  mutate(name = fct_recode(.data$name,
                           "United States" = 
                             "United States of America"))

Okay, lets see what this looks like now.

joined |>
  select("country") |>
  plot()
Map of the World with all countries.

That’s better! Now I’ve got the data I want to plot, I can use ggplot2 to start creating the visualisation that I will be animating. Before that, I will filter the data to keep only the Americas, then use geom_sf to plot the geometry data.

library(ggplot2)

americas = joined |>
  filter(continent == "Americas") 

americas_plot = ggplot(americas) + 
  geom_sf()
Map of The Americas.

This plot looks good but I’m going to change the coordinate reference system (CRS) to one (“EPSG:8858”) that is designed for the Americas. I found this CRS on epsg.io, a website I would recommend if you are looking for some different CRS’s. st_transform can be used to change the CRS to EPSG:8858. This is what it looks like now:

americas = st_transform(americas, "EPSG:8858")

new_crs_plot = ggplot(americas) + 
  geom_sf()
Map of The Americas with EPSG:8858 CRS.

Okay so now the plot looks right we will start preparing it to be animated.

library(ggplot2)

plot = americas %>% 
  filter(year == 2007) %>% 
  ggplot() + 
  geom_sf(aes(fill = lifeExp)) +
  labs(title = "Year: 2007",
       fill = "Life Expectancy") +
  theme_void() +
  ggplot2::scale_fill_viridis_b() +
  theme(legend.position = c("inside"),
        legend.position.inside = c(0.23, 0.23),
        plot.title = element_text(size = 15, 
                                  hjust = 0.5),
        panel.border = element_rect(color = "black", 
                                    fill = NA)) 
Map of The Americas to be animated.

This is the plot we are going to animate now so we’ll use {gganimate}. The transition_states function partitions the data using a states column (here our ‘year’ column), iteratively creating a frame of the animation for each year value in the input data. The next function is animate which will convert these frames into a GIF. Note, make sure you have the dependencies installed or you may end up with 100 PNG files in your working directory rather than a GIF!

library(gganimate)

animation = plot +
  ggtitle("Year: {closest_state}") +
  transition_states(states = year)

animate(animation, 
        renderer = gifski_renderer("img/map.gif"), 
        alt = "Animation with missing values.")
Animation with missing values.

The keener eyed of you will notice some countries don’t have a value for every year.

americas |>
  st_drop_geometry() |> 
  count(country) |> 
  arrange(n) 
## # A tibble: 36 × 2
##    country                  n
##    <chr>                <int>
##  1 French Guiana            1
##  2 Guadeloupe               1
##  3 Martinique               1
##  4 Aruba                    8
##  5 Grenada                  8
##  6 Netherlands Antilles     8
##  7 Suriname                 8
##  8 Bahamas                 10
##  9 Barbados                10
## 10 Belize                  10
## # ℹ 26 more rows

So 25 countries have 12 observations (the max), four have 10 and 8 respectively and three have 1. To fill in these blanks, I’m going to use {tidyr} to compute some mock values using the dataset mean for each year. The countries with one would continue with one value from from 2002.

library(tidyr)

completed = americas |> 
  mutate(country = forcats::fct_drop(country)) |> 
  complete(year, country) |> 
  select(country, lifeExp, year) |> 
  group_by(year) |> 
  mutate(lifeExp = 
           replace_na(lifeExp, 
                      replace = mean(lifeExp, 
                                     na.rm = TRUE)))

geoms = americas |> 
  select(country) |> 
  distinct()

plot = left_join(completed, 
                 geoms, 
                 by = "country") |> 
  st_as_sf() |> 
  st_transform("EPSG:8858") |> 
  ggplot() + 
  geom_sf(aes(fill = lifeExp)) +
  labs(title = "Year: {closest_state}",
       fill = "Life Expectancy") +
  theme_void() +
  ggplot2::scale_fill_viridis_b() +
  theme(legend.position = c("inside"),
        legend.position.inside = c(0.23, 0.23),
        plot.title = element_text(size = 15,
                                  hjust = 0.5),
        panel.border = element_rect(color = "black", 
                                    fill = NA)) 
animation = plot +
  transition_states(states = year)

animate(animation,
        renderer = gifski_renderer("img/map2.gif"))
Final animation with all countries.

So that is our final animated map, of course we could add more styling or complexity - maybe in a future blog. If you want to learn more about working the topic, check out our Spatial Data Analysis with R course or another Jumping Rivers blog, Thinking About Maps and Ice Cream by Nicola Rennie.


Jumping Rivers Logo

Recent Posts

  • Start 2026 Ahead of the Curve: Boost Your Career with Jumping Rivers Training 
  • Should I Use Figma Design for Dashboard Prototyping? 
  • Announcing AI in Production 2026: A New Conference for AI and ML Practitioners 
  • Elevate Your Skills and Boost Your Career – Free Jumping Rivers Webinar on 20th November! 
  • Get Involved in the Data Science Community at our Free Meetups 
  • Polars and Pandas - Working with the Data-Frame 
  • Highlights from Shiny in Production (2025) 
  • Elevate Your Data Skills with Jumping Rivers Training 
  • Creating a Python Package with Poetry for Beginners Part2 
  • What's new for Python in 2025? 

Top Tags

  • R (236) 
  • Rbloggers (182) 
  • Pybloggers (89) 
  • Python (89) 
  • Shiny (63) 
  • Events (26) 
  • Training (23) 
  • Machine Learning (22) 
  • Conferences (20) 
  • Tidyverse (17) 
  • Statistics (14) 
  • Packages (13) 

Authors

  • Amieroh Abrahams 
  • Colin Gillespie 
  • Aida Gjoka 
  • Gigi Kenneth 
  • Osheen MacOscar 
  • Sebastian Mellor 
  • Keith Newman 
  • Pedro Silva 
  • Tim Brock 
  • Shane Halloran 
  • Russ Hyde 
  • Myles Mitchell 
  • Theo Roe 

Keep Updated

Like data science? R? Python? Stan? Then you’ll love the Jumping Rivers newsletter. The perks of being part of the Jumping Rivers family are:

  • Be the first to know about our latest courses and conferences.
  • Get discounts on the latest courses.
  • Read news on the latest techniques with the Jumping Rivers blog.

We keep your data secure and will never share your details. By subscribing, you agree to our privacy policy.

Follow Us

  • GitHub
  • Bluesky
  • LinkedIn
  • YouTube
  • Eventbrite

Find Us

The Catalyst Newcastle Helix Newcastle, NE4 5TG
Get directions

Contact Us

  • hello@jumpingrivers.com
  • + 44(0) 191 432 4340

Newsletter

Sign up

Events

  • North East Data Scientists Meetup
  • Leeds Data Science Meetup
  • Shiny in Production
British Assessment Bureau, UKAS Certified logo for ISO 9001 - Quality management British Assessment Bureau, UKAS Certified logo for ISO 27001 - Information security management Cyber Essentials Certified Plus badge
  • Privacy Notice
  • |
  • Booking Terms

©2016 - present. Jumping Rivers Ltd