Skip to content
R for the Rest of Us Logo

Use shadows in ggplot to highlight findings

In our consulting work, we make a lot of the data visualization for parameterized reporting. It’s something I spoke about in my 2024 Cascadia R Conf talk, How to Make a Thousand Plots Look Good: Data Viz Tips for Parameterized Reporting.

One example I gave in this talk came from our work with the Johns Hopkins International Vaccine Access Center and the World Health Organization. In this project, we made reports for the Immunization Agenda 2030 project, which tracks the progress countries around the world are making toward vaccination goals.

One challenge we face when making parameterized reports like the ones we made for IA2030 is how to ensure that the focus of each report is highlighted. For example, we made this map, which shows the progress Afghanistan has made toward the eradication of rubella.

As you can see, we highlight Afghanistan in the map so that it is obvious to reader. We do this highlighting using a less conventional technique that you may not have considered: adding shadows. Using shadows to highlight findings can make a big impact on the quality of your maps and graphs, especially when doing parameterized reporting. Let’s talk about how we do it.

Import Our Data

The first step is to import our data. Let’s begin by loading two packages: {tidyverse} for data wrangling and plotting with ggplot and {sf} for importing our geospatial data.

library(tidyverse)
library(sf)

I’ve posted a simplified version of the data we used to make these maps in GeoJSON format. The read_sf() function can import this file, which I save as rubella.

rubella <-
  read_sf("https://github.com/rfortherestofus/blog/raw/refs/heads/main/highlight-shadow/rubella.geojson")

We can take a look at the data:

rubella
#> Simple feature collection with 69 features and 3 fields
#> Geometry type: MULTIPOLYGON
#> Dimension:     XY
#> Bounding box:  xmin: -1466635 ymin: -185219 xmax: 8649202 ymax: 4806756
#> Projected CRS: WGS 84 / World Mercator
#> # A tibble: 69 × 4
#>    country                  region status                               geometry
#>    <chr>                     <dbl> <chr>                      <MULTIPOLYGON [m]>
#>  1 Afghanistan                   1 Not achieved (((8328599 4443845, 8322613 445…
#>  2 United Arab Emirates          1 Not achieved (((6004988 2752921, 5987241 275…
#>  3 Benin                         0 Not achieved (((392437.9 1287537, 386846.1 1…
#>  4 Burkina Faso                  0 Not achieved (((21538.26 1659358, 26885.45 1…
#>  5 Bahrain                       1 Achieved     (((5634224 2996093, 5633599 296…
#>  6 Central African Republic      0 Not achieved (((2701448 953673.8, 2689646 93…
#>  7 China                         0 Not achieved (((8649200 4202120, 8628076 420…
#>  8 Cameroon                      0 Not achieved (((1719405 819975.1, 1695115 80…
#>  9 Djibouti                      1 Not achieved (((4780514 1224241, 4766042 122…
#> 10 Algeria                       0 Not achieved (((961591.4 4389977, 943599.8 4…
#> # ℹ 59 more rows

As you can see, it is geospatial , which is why it has the metadata at the top. In addition to the country variable, region indicates whether the country is part of the region we’re focusing on (1 if yes, 0 if no), and status shows each country’s progress toward rubella eradication.

Make the Basic Map

Let’s begin by making a simple map. To do this, I’ll separate the rubella objects into two pieces. First, I’ll make a new object called rubella_region_0 by filtering countries with region 0.

rubella_region_0 <-
  rubella |>
  filter(region == 0)

If we plot this, we can see that countries with region 0 are those in the surrounding area, but not in the Eastern Mediterranean region, which we are focusing on.

ggplot() +
  geom_sf(
    data = rubella_region_0,
    fill = "grey90",
    color = "grey70",
    linewidth = 0.2
  )

We’ll also create a rubella_region_1 object. However, before doing this, let’s take a look at the data, focusing on the status variable. Droping the geometry data with st_drop_geometry() before filtering our data and then counting status , we see that there are three unique observations in our data: Achieved, No data, and Not achieved.

rubella |>
  st_drop_geometry() |>
  filter(region == 1) |>
  count(status)
#> # A tibble: 3 × 2
#>   status           n
#>   <chr>        <int>
#> 1 Achieved         4
#> 2 No data          2
#> 3 Not achieved    17

While countries in the Eastern Mediterranean region only have these statuses, we want to also show the status Re-established on our map legend. To do this, we use the add_row() function to manually add a row with status as Re-established. We then convert status into a factor so that the statuses will show up in the correct order in the legend.

rubella_region_1 <-
  rubella |>
  filter(region == 1) |>
  add_row(status = "Re-established") |>
  mutate(status = fct(
    status,
    levels = c(
      "Achieved",
      "Re-established",
      "Not achieved",
      "No data"
    )
  ))

We can run count() again on the status variable. Now we see that there is a single Re-established observation, sufficient to ensure it will show up in our legend.

rubella_region_1 |>
  st_drop_geometry() |>
  count(status)
#> # A tibble: 4 × 2
#>   status             n
#>   <fct>          <int>
#> 1 Achieved           4
#> 2 Re-established     1
#> 3 Not achieved      17
#> 4 No data            2

Let’s make a simple map using rubella_region_1 :

ggplot() +
  geom_sf(
    data = rubella_region_1,
    aes(fill = status),
    linewidth = 0.2,
    color = "white",
    alpha = 1
  )

We can see the countries in the Eastern Mediterranean region, with each status in a different color.

Next, let’s combine the two maps we made. Doing this will give us a map that shows both the Eastern Mediterranean countries as well as surrounding countries.

ggplot() +
  geom_sf(
    data = rubella_region_0,
    fill = "grey90",
    color = "grey70",
    linewidth = 0.2
  ) +
  geom_sf(
    data = rubella_region_1,
    aes(fill = status),
    linewidth = 0.2,
    color = "white"
  )

Let’s adjust our colors now. Using scale_fill_manual() we specify the colors we want for each status.

ggplot() +
  geom_sf(
    data = rubella_region_0,
    fill = "grey90",
    color = "grey70",
    linewidth = 0.2
  ) +
  geom_sf(
    data = rubella_region_1,
    aes(fill = status),
    linewidth = 0.2,
    color = "white"
  ) +
  scale_fill_manual(
    name = "",
    values = c(
      "Achieved" = "navy",
      "Re-established" = "gold1",
      "Not achieved" = "red2",
      "No data" = "grey80"
    )
  )

The last step in making our basic map is to adjust the theme. I use theme_void() to remove everything from our map, use the Inter Tight , and make some additional tweaks using the theme() function.

ggplot() +
  geom_sf(
    data = rubella_region_0,
    fill = "grey90",
    color = "grey70",
    linewidth = 0.2
  ) +
  geom_sf(
    data = rubella_region_1,
    aes(fill = status),
    linewidth = 0.2,
    color = "white"
  ) +
  scale_fill_manual(
    name = "",
    values = c(
      "Achieved" = "navy",
      "Re-established" = "gold1",
      "Not achieved" = "red2",
      "No data" = "grey80"
    )
  ) +
  theme_void(base_family = "Inter Tight") +
  theme(
    legend.text = element_text(
      size = 12,
      color = "grey40"
    ),
    plot.margin = margin(rep(20, 4)),
    legend.position = "bottom"
  )

Our plot is looking pretty decent at this point!

Turn Our Basic Map into a Function

Before adding shadows for highlighting, I’m going to turn the code I’ve created to make a basic map into a function. It’s as simple as wrapping the code above in a function called region_map() :

region_map <- function() {
  ggplot() +
    geom_sf(
      data = rubella_region_0,
      fill = "grey90",
      color = "grey70",
      linewidth = 0.2
    ) +
    geom_sf(
      data = rubella_region_1,
      aes(fill = status),
      linewidth = 0.2,
      color = "white"
    ) +
    scale_fill_manual(
      name = "",
      values = c(
        "Achieved" = "navy",
        "Re-established" = "gold1",
        "Not achieved" = "red2",
        "No data" = "grey80"
      )
    ) +
    theme_void(base_family = "Inter Tight") +
    theme(
      legend.text = element_text(
        size = 12,
        color = "grey40"
      ),
      plot.margin = margin(rep(20, 4)),
      legend.position = "bottom"
    )
}

Now, I simply run region_map() and I get my basic map.

region_map()

Add Shadow to Highlight

Now that we’ve got a function to make a basic map, we can add a shadow behind any country in order to highlight it. To do this, we first load the {ggfx} package .

library(ggfx)

This package has a number of functions to tweak your plots. I’m going to use the with_shadow() function. This function, which you wrap around any geom, will add a shadow. Here you can see that I’m wrapping it around geom_sf() , which I’m using to plot Afghanistan on the map. The arguments in with_shadow() adjust the appearance of the shadow: x_offset and y_offset determine how far the shadow will be from the geom while sigma determines the level of blurring. With options set for each argument, we can now see a shadow added to Afghanistan.

region_map() +
  with_shadow(
    geom_sf(
      data = rubella |> filter(country == "Afghanistan"),
      aes(fill = status),
    ),
    sigma = 0,
    x_offset = 4,
    y_offset = 4
  )

When we use shadows to highlight, we don’t typically just add shadows. One additional thing we often do is add an outline. Using the lines linewidth = 0.8 and color = "white we create a white outline around Afghanistan, which makes it even easier to pick out.

region_map() +
  with_shadow(
    geom_sf(
      data = rubella |> filter(country == "Afghanistan"),
      aes(fill = status),
      linewidth = 0.8,
      color = "white"
    ),
    sigma = 0,
    x_offset = 4,
    y_offset = 4
  )

Afghanistan is more and more obvious, but there’s one more thing we can do: adjust the opacity of the background map to make the highlight country pop even more. We do this by adding an argument to our region_map() function. The opacity_level argument applies to the geom made with rubella_region_1 .

region_map <- function(opacity_level = 1) {
  ggplot() +
    geom_sf(
      data = rubella_region_0,
      fill = "grey90",
      color = "grey70",
      linewidth = 0.2
    ) +
    geom_sf(
      data = rubella_region_1,
      aes(fill = status),
      linewidth = 0.2,
      color = "white",
      alpha = opacity_level
    ) +
    scale_fill_manual(
      name = "",
      values = c(
        "Achieved" = "navy",
        "Re-established" = "gold1",
        "Not achieved" = "red2",
        "No data" = "grey80"
      )
    ) +
    theme_void(base_family = "Inter Tight") +
    theme(
      legend.text = element_text(
        size = 12,
        color = "grey40"
      ),
      plot.margin = margin(rep(20, 4)),
      legend.position = "bottom"
    )
}

To show you what this looks like, let’s run this code:

region_map(opacity_level = 0.25)

As you can see, the countries in the Eastern Mediterranean region are faded. Now, 25% opacity, as I did above, is probably a bit too much. Instead, let’s set our opacity to 75% (0.75) and see how it looks with Afghanistan added.

region_map(opacity_level = 0.75) +
  with_shadow(
    geom_sf(
      data = rubella |> filter(country == "Afghanistan"),
      aes(fill = status),
      linewidth = 0.8,
      color = "white"
    ),
    sigma = 0,
    x_offset = 4,
    y_offset = 4
  )

Looks great! For someone just flipping through the Afghanistan report, they’ll be able to easily pick out Afghanistan, which is exactly what we want.

In this blog post, I’ve given an example of using the with_shadow() function to highlight a country on a map, but it can work with any geom. Try it next time you want to highlight a bar, line, or anything else you might create with ggplot!

Sign up for the newsletter

Get blog posts like this delivered straight to your inbox.

Let us know what you think by adding a comment below.

You need to be signed-in to comment on this post. Login.

David Keyes
By David Keyes
April 24, 2025

Sign up for the newsletter

R tips and tricks straight to your inbox.