Building a custom ggplot2 theme

In this dispatch we will learn how to build your own custom theme in ggplot2. This is useful if you want your figures to use a consistent style.

Maybe you frequently submit to a specific journal which has an explicit style guide, and you want to ensure your figures are consistent with that style guide.

It can be a lot of work, and a lot of code to get the aesthetics of your ggplot2 figure just right. Instead of changing the font type, size, coloring, etc. every time you create a plot, just create a theme, that can then be applied to every new figure you create!

What is a theme?

Let’s create a plot using the default style in ggplot2, and explore some of the default themes to get a better understanding of what a ggplot2 theme looks like, and how it’s structured.

Setup

We will be using the Palmer Penguins dataset for this dispatch. To install the Palmer Penguins package, run install.packages("palmerpenguins").

If you have not already installed ggplot2, do so with install.packages("ggplot2"), or install the entire tidyverse with install.packages("tidyverse").

Load the Data

Now load our two libraries:

library(palmerpenguins)
library(tidyverse)
── Attaching core tidyverse packages ──────────────────────── tidyverse 2.0.0 ──
✔ dplyr     1.1.3     ✔ readr     2.1.4
✔ forcats   1.0.0     ✔ stringr   1.5.0
✔ ggplot2   3.4.4     ✔ tibble    3.2.1
✔ lubridate 1.9.3     ✔ tidyr     1.3.0
✔ purrr     1.0.2     
── Conflicts ────────────────────────────────────────── tidyverse_conflicts() ──
✖ dplyr::filter() masks stats::filter()
✖ dplyr::lag()    masks stats::lag()
ℹ Use the conflicted package (<http://conflicted.r-lib.org/>) to force all conflicts to become errors

Explore the Data

We are using the palmerpenguins package for our data during this dispatch. Data were collected and made available by Dr. Kristen Gorman and the Palmer Station, Antarctica LTER, a member of the Long Term Ecological Research Network. The data were conveniently collected as an R package that we use today.

We can get a sense of the contents of our data by looking at the first few rows of our tibble. Remember tibbles are data.frames, just with a few behavioral changes. The palmerpenguins data is in a tibble format by default.

data(package = 'palmerpenguins')

# Remove rows that contain at least one missing value
penguins <- penguins %>%
            drop_na()

print(penguins)
# A tibble: 333 × 8
   species island    bill_length_mm bill_depth_mm flipper_length_mm body_mass_g
   <fct>   <fct>              <dbl>         <dbl>             <int>       <int>
 1 Adelie  Torgersen           39.1          18.7               181        3750
 2 Adelie  Torgersen           39.5          17.4               186        3800
 3 Adelie  Torgersen           40.3          18                 195        3250
 4 Adelie  Torgersen           36.7          19.3               193        3450
 5 Adelie  Torgersen           39.3          20.6               190        3650
 6 Adelie  Torgersen           38.9          17.8               181        3625
 7 Adelie  Torgersen           39.2          19.6               195        4675
 8 Adelie  Torgersen           41.1          17.6               182        3200
 9 Adelie  Torgersen           38.6          21.2               191        3800
10 Adelie  Torgersen           34.6          21.1               198        4400
# ℹ 323 more rows
# ℹ 2 more variables: sex <fct>, year <int>

Themes

Let’s look at a few themes before we build our own. We can use these themes as a starting place.

Default theme

The default theme is theme_gray(). This is the theme that is used when you call ggplot without specifying a theme.

When we compare penguin flipper length to penguin body mass by species the plot created uses theme_gray():

ggplot(data = penguins, aes(x = body_mass_g,
                            y = flipper_length_mm,
                            color = species)) +
  geom_point() +
  xlab("Body Mass (g)") +
  ylab("Flipper Length (mm)")

We can explicitly add the theme and see that nothing changes:

ggplot(data = penguins, aes(x = body_mass_g,
                            y = flipper_length_mm,
                            color = species)) +
  geom_point() +
  xlab("Body Mass (g)") +
  ylab("Flipper Length (mm)") +
  theme_grey()

Changing theme

But if we change the theme, we see our scatter plot change accordingly. Let’s use theme_minimal():

ggplot(data = penguins, aes(x = body_mass_g,
                            y = flipper_length_mm,
                            color = species)) +
  geom_point() +
  xlab("Body Mass (g)") +
  ylab("Flipper Length (mm)") +
  theme_minimal()

Available themes

There are a number of themes available in ggplot2:

Theme Description
theme_gray() The signature ggplot2 theme with a grey background and white gridlines, designed to put the data forward yet make comparisons easy.
theme_bw() The classic dark-on-light ggplot2 theme. May work better for presentations displayed with a projector.
theme_linedraw() A theme with only black lines of various widths on white backgrounds, reminiscent of a line drawing. Serves a purpose similar to theme_bw(). Note that this theme has some very thin lines (<< 1 pt) which some journals may refuse.
theme_light() A theme similar to theme_linedraw() but with light grey lines and axes, to direct more attention towards the data.
theme_dark() The dark cousin of theme_light(), with similar line sizes but a dark background. Useful to make thin coloured lines pop out.
theme_minimal() A minimalistic theme with no background annotations.
theme_classic() A classic-looking theme, with x and y axis lines and no gridlines.
theme_void() A completely empty theme.
theme_test() A theme for visual unit tests. It should ideally never change except for new features.

Customizing a theme

Each of the previous theme changes different aesthetics of the theme. We can specify our own using a custom theme. For a list of parameters that we can change, check out the theme help with ?theme or review the documentation on ggplot2 theming:

Understanding theme contents

Let’s look at the theme_minimal() function, and see what arguments are defined for the different theme parameters:

theme_minimal
function (base_size = 11, base_family = "", base_line_size = base_size/22, 
    base_rect_size = base_size/22) 
{
    theme_bw(base_size = base_size, base_family = base_family, 
        base_line_size = base_line_size, base_rect_size = base_rect_size) %+replace% 
        theme(axis.ticks = element_blank(), legend.background = element_blank(), 
            legend.key = element_blank(), panel.background = element_blank(), 
            panel.border = element_blank(), strip.background = element_blank(), 
            plot.background = element_blank(), complete = TRUE)
}
<bytecode: 0x12aec0630>
<environment: namespace:ggplot2>

Interesting! We can see that theme_minimal() just builds on and modifies theme_bw().

What would we find if we look at theme_bw()?

theme_bw
function (base_size = 11, base_family = "", base_line_size = base_size/22, 
    base_rect_size = base_size/22) 
{
    theme_grey(base_size = base_size, base_family = base_family, 
        base_line_size = base_line_size, base_rect_size = base_rect_size) %+replace% 
        theme(panel.background = element_rect(fill = "white", 
            colour = NA), panel.border = element_rect(fill = NA, 
            colour = "grey20"), panel.grid = element_line(colour = "grey92"), 
            panel.grid.minor = element_line(linewidth = rel(0.5)), 
            strip.background = element_rect(fill = "grey85", 
                colour = "grey20"), legend.key = element_rect(fill = "white", 
                colour = NA), complete = TRUE)
}
<bytecode: 0x12aec30e8>
<environment: namespace:ggplot2>

theme_bw() builds on and modifies theme_grey(). Clearly a pattern is emerging where each ggplot2 theme modifies and builds on another.

And what if we keep going down the rabbit hole and look at theme_grey()?

theme_grey
function (base_size = 11, base_family = "", base_line_size = base_size/22, 
    base_rect_size = base_size/22) 
{
    half_line <- base_size/2
    t <- theme(line = element_line(colour = "black", linewidth = base_line_size, 
        linetype = 1, lineend = "butt"), rect = element_rect(fill = "white", 
        colour = "black", linewidth = base_rect_size, linetype = 1), 
        text = element_text(family = base_family, face = "plain", 
            colour = "black", size = base_size, lineheight = 0.9, 
            hjust = 0.5, vjust = 0.5, angle = 0, margin = margin(), 
            debug = FALSE), axis.line = element_blank(), axis.line.x = NULL, 
        axis.line.y = NULL, axis.text = element_text(size = rel(0.8), 
            colour = "grey30"), axis.text.x = element_text(margin = margin(t = 0.8 * 
            half_line/2), vjust = 1), axis.text.x.top = element_text(margin = margin(b = 0.8 * 
            half_line/2), vjust = 0), axis.text.y = element_text(margin = margin(r = 0.8 * 
            half_line/2), hjust = 1), axis.text.y.right = element_text(margin = margin(l = 0.8 * 
            half_line/2), hjust = 0), axis.ticks = element_line(colour = "grey20"), 
        axis.ticks.length = unit(half_line/2, "pt"), axis.ticks.length.x = NULL, 
        axis.ticks.length.x.top = NULL, axis.ticks.length.x.bottom = NULL, 
        axis.ticks.length.y = NULL, axis.ticks.length.y.left = NULL, 
        axis.ticks.length.y.right = NULL, axis.title.x = element_text(margin = margin(t = half_line/2), 
            vjust = 1), axis.title.x.top = element_text(margin = margin(b = half_line/2), 
            vjust = 0), axis.title.y = element_text(angle = 90, 
            margin = margin(r = half_line/2), vjust = 1), axis.title.y.right = element_text(angle = -90, 
            margin = margin(l = half_line/2), vjust = 0), legend.background = element_rect(colour = NA), 
        legend.spacing = unit(2 * half_line, "pt"), legend.spacing.x = NULL, 
        legend.spacing.y = NULL, legend.margin = margin(half_line, 
            half_line, half_line, half_line), legend.key = element_rect(fill = "grey95", 
            colour = NA), legend.key.size = unit(1.2, "lines"), 
        legend.key.height = NULL, legend.key.width = NULL, legend.text = element_text(size = rel(0.8)), 
        legend.text.align = NULL, legend.title = element_text(hjust = 0), 
        legend.title.align = NULL, legend.position = "right", 
        legend.direction = NULL, legend.justification = "center", 
        legend.box = NULL, legend.box.margin = margin(0, 0, 0, 
            0, "cm"), legend.box.background = element_blank(), 
        legend.box.spacing = unit(2 * half_line, "pt"), panel.background = element_rect(fill = "grey92", 
            colour = NA), panel.border = element_blank(), panel.grid = element_line(colour = "white"), 
        panel.grid.minor = element_line(linewidth = rel(0.5)), 
        panel.spacing = unit(half_line, "pt"), panel.spacing.x = NULL, 
        panel.spacing.y = NULL, panel.ontop = FALSE, strip.background = element_rect(fill = "grey85", 
            colour = NA), strip.clip = "inherit", strip.text = element_text(colour = "grey10", 
            size = rel(0.8), margin = margin(0.8 * half_line, 
                0.8 * half_line, 0.8 * half_line, 0.8 * half_line)), 
        strip.text.x = NULL, strip.text.y = element_text(angle = -90), 
        strip.text.y.left = element_text(angle = 90), strip.placement = "inside", 
        strip.placement.x = NULL, strip.placement.y = NULL, strip.switch.pad.grid = unit(half_line/2, 
            "pt"), strip.switch.pad.wrap = unit(half_line/2, 
            "pt"), plot.background = element_rect(colour = "white"), 
        plot.title = element_text(size = rel(1.2), hjust = 0, 
            vjust = 1, margin = margin(b = half_line)), plot.title.position = "panel", 
        plot.subtitle = element_text(hjust = 0, vjust = 1, margin = margin(b = half_line)), 
        plot.caption = element_text(size = rel(0.8), hjust = 1, 
            vjust = 1, margin = margin(t = half_line)), plot.caption.position = "panel", 
        plot.tag = element_text(size = rel(1.2), hjust = 0.5, 
            vjust = 0.5), plot.tag.position = "topleft", plot.margin = margin(half_line, 
            half_line, half_line, half_line), complete = TRUE)
    ggplot_global$theme_all_null %+replace% t
}
<bytecode: 0x1382de4d8>
<environment: namespace:ggplot2>

We have reached the bottom of the rabbit hole with theme_grey(). This theme explicitly specifies all of the theme arguments (of which there are many).

This explains why all of the other themes build on each other, it is much more concise to only use a theme where all the arguments are already defined, and only change those theme arguments that you want to look different than the theme you are building on.

Copying and modifying a theme

Let’s begin to build a custom theme, and explore different arguments as we do. First lets create a new theme, theme_custom(), that is simply a wrapper to theme minimal:

theme_custom <- function()
{
  theme_minimal()
}

We can call our theme:

ggplot(data = penguins, aes(x = body_mass_g,
                            y = flipper_length_mm,
                            color = species)) +
  geom_point() +
  xlab("Body Mass (g)") +
  ylab("Flipper Length (mm)") +
  theme_custom()

And we see that we get the same output as when we use theme_minimal().