Jörn Alexander Quent's notebook

where I share semi-interesting stuff from my work

Creating actually publication-ready figures for journals using ggplot2

If you’ve ever used ggplot2 to create figures for a journal article, you’ve probably experienced this: your plot looks perfect in RStudio, but when you save it at the required dimensions (e.g. relative to an A4 page), the proportions look completely off (e.g. lines too thin/big, the font doesn’t look right). I used to spend hours tweaking each plot individually, trial after trial, until it finally looks acceptable. This is nightmare especially if you want to update figures after changing something in the analysis.

This guide walks through my workflow for creating figures that are actually publication-ready, without writing custom code for every plot or endlessly fiddling with parameters. The goal is to spend less time adjusting figures.

Journal requirements: start with the right dimensions

Before you even start plotting, check your target journal’s figure guidelines. Common width limits range from 177.8 mm to 200 mm (e.g., Elsevier, Science, Cell). The width is usually the constraining factor, so design with that in mind.

Useful resources:

The Problem: Why figures look different when exported?

Here’s a typical scenario: you create a multi-panel figure in RStudio, set fig.width and fig.height in inches and everything looks fine. But when you save it at the required millimeter dimensions, the result is disappointing.

Let’s illustrate with an example—a 1×4 panel figure using the classic iris dataset. Settings used for the chunk: fig.width = 9, fig.height = 2.25.

In my mind, the results looks pretty decent (e.g. font & geom sizes) even without any further styling.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
# Colours
colour_pal <- c("#ffb3ba", "#ffdfba", "#ffffba", "#baffc9", "#bae1ff")

p1 <- ggplot(iris, aes(x = Sepal.Length)) + 
  geom_density(fill = colour_pal[1]) +
  theme_classic() +
  labs(x = "Sepal length (cm)", y = "Density", title = "Sepal length\ndistribution")

p2 <- ggplot(iris, aes(x = Sepal.Width)) + 
  geom_density(fill = colour_pal[2]) +
  theme_classic() +
  labs(x = "Sepal width (cm)", y = "Density", title = "Sepal width\ndistribution")

p3 <- ggplot(iris, aes(x = Species, y = Sepal.Length, fill = Species)) + 
  geom_jitter(height = 0, width = 0.2) +
  geom_boxplot(outlier.shape = NA) +
  theme_classic() +
  scale_fill_manual(values = colour_pal[c(1, 3, 5)]) +
  theme(legend.position = "none") +
  labs(x = "Species", y = "Sepal length (cm)", title = "Sepal length\nby species")

p4 <- ggplot(iris, aes(x = Sepal.Width, y = Sepal.Length, colour = Species)) + 
  geom_point() +
  theme_classic() +
  scale_colour_manual(values = colour_pal[c(1, 3, 5)]) +
  theme(legend.position = "none") +
  labs(x = "Sepal width (cm)", y = "Sepal length (cm)", title = "Sepal length\nby width")

# Combine the figures using cowplot
plot_grid(p1, p2, p3, p4, nrow = 1, ncol = 4, align = "hv")

Now, save it as a 180 mm wide figure:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# Combine the figures using cowplot and save as an object
p <- plot_grid(p1, p2, p3, p4, nrow = 1, ncol = 4, align = "hv")

# Get the r-chunk ratio
fig_ratio <- 2.25/9

# Create folder to save figures in
dir.create("pub_fig_guide", showWarnings = FALSE)

# Save to file
ggsave(p, filename = "pub_fig_guide/fig1.png", 
       dpi = 1000,
       width = 180,
       height = 180*fig_ratio,
       units = "mm")

Some elements look okay, but many are out of proportion. So, here is how I deal with this.

My current approach: A custom theme and updated defaults

1. Create a custom theme function

Instead of adjusting each plot’s theme individually, I define a reusable theme_journal() function. It standardizes text sizes, line widths and other elements based on a chosen base theme but you can go much further with this, which I won’t cover.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
theme_journal <- function(base_size = 7, linewidth = 0.35, theme_name = "classic") {
  # Select a base theme from https://ggplot2.tidyverse.org/reference/ggtheme.html
  if(theme_name == "grey" | theme_name == "gray"){
    base_theme <- theme_grey(base_size = base_size)
    
  } else if(theme_name == "bw"){
    base_theme <- theme_bw(base_size = base_size)
    
  } else if(theme_name == "linedraw"){
    base_theme <- theme_linedraw(base_size = base_size)
    
  } else if(theme_name == "light"){
    base_theme <- theme_light(base_size = base_size)
    
  } else if(theme_name == "dark"){
    base_theme <- theme_dark(base_size = base_size)
    
  } else if(theme_name == "minimal"){
    base_theme <- theme_minimal(base_size = base_size)
    
  } else if(theme_name == "classic"){
    base_theme <- theme_classic(base_size = base_size)
    
  } else {
    stop("Unknown theme. Check https://ggplot2.tidyverse.org/reference/ggtheme.html")
  }
  
  # Base them plus customisation
  base_theme +
    theme(text = element_text(color = "black", size = base_size),
          axis.text = element_text(size = base_size * 0.9),  # 90% of base
          axis.line = element_line(linewidth = linewidth),
          axis.ticks = element_line(linewidth = linewidth),
          legend.text = element_text(size = base_size * 0.9), # 90% of base
          legend.key.size = unit(0.5, "lines"),
          plot.title = element_text(hjust = 0.5, size = base_size))
}

2. Update default aesthetics for geoms

To do this, you also have to know the default values but finding the default aesthetic values for each geom can be annoying. Fortunately, you can inspect them directly (e.g., GeomPoint$default_aes) and then set new defaults globally using update_geom_defaults().

1
2
3
4
# Can be checked via GeomDensity$default_aes, GeomBoxplot$default_aes etc.
update_geom_defaults("point", list(size = 0.5))
update_geom_defaults("density", list(linewidth = 0.25))
update_geom_defaults("boxplot", list(linewidth = 0.25))

This way, every subsequent plot uses these updated defaults - no need to manually set size or linewidth in each geom_*() call.

3. Recreate the figure with the new settings

Now, let’s rebuild the same four-panel figure using our custom theme and updated defaults:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
p1 <- ggplot(iris, aes(x = Sepal.Length)) + 
  geom_density(fill = colour_pal[1]) +
  theme_journal() +
  labs(x = "Sepal length (cm)", y = "Density", title = "Sepal length\ndistribution")

p2 <- ggplot(iris, aes(x = Sepal.Width)) + 
  geom_density(fill = colour_pal[2]) +
  theme_journal() +
  labs(x = "Sepal width (cm)", y = "Density", title = "Sepal width\ndistribution")

p3 <- ggplot(iris, aes(x = Species, y = Sepal.Length, fill = Species)) + 
  geom_jitter(height = 0, width = 0.2) +
  geom_boxplot(outlier.shape = NA) +
  theme_journal() +
  scale_fill_manual(values = colour_pal[c(1, 3, 5)]) +
  theme(legend.position = "none") +
  labs(x = "Species", y = "Sepal length (cm)", title = "Sepal length\nby species")

p4 <- ggplot(iris, aes(x = Sepal.Width, y = Sepal.Length, colour = Species)) + 
  geom_point() +
  theme_journal() +
  scale_colour_manual(values = colour_pal[c(1, 3, 5)]) +
  theme(legend.position = "none") +
  labs(x = "Sepal width (cm)", y = "Sepal length (cm)", title = "Sepal length\nby width")

# Combine the figures using cowplot and save as an object
p <- plot_grid(p1, p2, p3, p4, nrow = 1, ncol = 4, align = "hv")

# Save to file
ggsave(p, filename = "pub_fig_guide/fig2.png", 
       dpi = 1000,
       width = 180,
       height = 180*fig_ratio,
       units = "mm")

The result is a clean, proportionally balanced figure that meets typical journal requirements with minimal manual adjustment.

Extra: Exporting to SVG instead of PNG

For greater flexibility, consider exporting your figures as SVG instead of PNG. SVG files are:

  • Scalable without quality loss.
  • Editable in vector graphics software like Inkscape.
  • Version-control friendly (text-based).

Use svglite for reliable SVG export:

1
2
3
4
5
6
# Use this because viewport/scaling issues
library(svglite)
ggsave(p, filename = plot_filename, 
       device = svglite,  width = 90,  
       height = 45,  units = "mm",
       scaling = 1,  fix_text_size = FALSE)

When importing into Inkscape, select the option “Include SVG image as editable object(s) in the current file.” When I exported my .svg files without svglite, the figures have very odd artefacts in them.

Additional tips

  • PowerPoint users: You can convert SVG images into editable PowerPoint objects.
  • Preview tools: Check out ggview::canvas() to set your RStudio viewing pane to match export proportions and ggpreview() from the nflplotr package for quick previews.

Final thoughts

Creating publication-ready figures in R doesn’t have to be a painful process of trial and error. By defining a custom theme, updating geom defaults globally, and choosing the right export format, you can produce consistent, journal-compliant figures with minimal repetitive code.

Session details

Click here for detailed for libraries used and session information.
1
sessioninfo::session_info()
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
##  Session info ───────────────────────────────────────────────────────────────
##  setting  value
##  version  R version 4.4.0 (2024-04-24)
##  os       Ubuntu 22.04.5 LTS
##  system   x86_64, linux-gnu
##  ui       X11
##  language en_GB:en
##  collate  en_GB.UTF-8
##  ctype    en_GB.UTF-8
##  tz       Asia/Shanghai
##  date     2026-02-05
##  pandoc   3.1.1 @ /usr/lib/rstudio/resources/app/bin/quarto/bin/tools/ (via rmarkdown)
## 
##  Packages ───────────────────────────────────────────────────────────────────
##  package     * version date (UTC) lib source
##  bslib         0.7.0   2024-03-29 [1] CRAN (R 4.4.0)
##  cachem        1.1.0   2024-05-16 [1] CRAN (R 4.4.0)
##  cli           3.6.2   2023-12-11 [1] CRAN (R 4.4.0)
##  colorspace    2.1-0   2023-01-23 [1] CRAN (R 4.4.0)
##  cowplot     * 1.1.3   2024-01-22 [1] CRAN (R 4.4.0)
##  digest        0.6.35  2024-03-11 [1] CRAN (R 4.4.0)
##  dplyr         1.1.4   2023-11-17 [1] CRAN (R 4.4.0)
##  evaluate      0.23    2023-11-01 [1] CRAN (R 4.4.0)
##  fansi         1.0.6   2023-12-08 [1] CRAN (R 4.4.0)
##  fastmap       1.2.0   2024-05-15 [1] CRAN (R 4.4.0)
##  generics      0.1.3   2022-07-05 [1] CRAN (R 4.4.0)
##  ggplot2     * 3.5.2   2025-04-09 [1] CRAN (R 4.4.0)
##  glue          1.7.0   2024-01-09 [1] CRAN (R 4.4.0)
##  gtable        0.3.5   2024-04-22 [1] CRAN (R 4.4.0)
##  htmltools     0.5.8.1 2024-04-04 [1] CRAN (R 4.4.0)
##  jquerylib     0.1.4   2021-04-26 [1] CRAN (R 4.4.0)
##  jsonlite      1.8.8   2023-12-04 [1] CRAN (R 4.4.0)
##  knitr         1.50    2025-03-16 [1] CRAN (R 4.4.0)
##  lifecycle     1.0.4   2023-11-07 [1] CRAN (R 4.4.0)
##  magrittr      2.0.3   2022-03-30 [1] CRAN (R 4.4.0)
##  munsell       0.5.1   2024-04-01 [1] CRAN (R 4.4.0)
##  pillar        1.9.0   2023-03-22 [1] CRAN (R 4.4.0)
##  pkgconfig     2.0.3   2019-09-22 [1] CRAN (R 4.4.0)
##  plyr        * 1.8.9   2023-10-02 [1] CRAN (R 4.4.0)
##  R6            2.5.1   2021-08-19 [1] CRAN (R 4.4.0)
##  Rcpp          1.0.12  2024-01-09 [1] CRAN (R 4.4.0)
##  rlang         1.1.4   2024-06-04 [1] CRAN (R 4.4.0)
##  rmarkdown     2.27    2024-05-17 [1] CRAN (R 4.4.0)
##  rstudioapi    0.16.0  2024-03-24 [1] CRAN (R 4.4.0)
##  sass          0.4.9   2024-03-15 [1] CRAN (R 4.4.0)
##  scales        1.3.0   2023-11-28 [1] CRAN (R 4.4.0)
##  sessioninfo   1.2.2   2021-12-06 [1] CRAN (R 4.4.0)
##  tibble        3.2.1   2023-03-20 [1] CRAN (R 4.4.0)
##  tidyselect    1.2.1   2024-03-11 [1] CRAN (R 4.4.0)
##  utf8          1.2.4   2023-10-22 [1] CRAN (R 4.4.0)
##  vctrs         0.6.5   2023-12-01 [1] CRAN (R 4.4.0)
##  withr         3.0.0   2024-01-16 [1] CRAN (R 4.4.0)
##  xfun          0.52    2025-04-02 [1] CRAN (R 4.4.0)
##  yaml          2.3.12  2025-12-10 [1] CRAN (R 4.4.0)
## 
##  [1] /home/alex/R/x86_64-pc-linux-gnu-library/4.4
##  [2] /usr/local/lib/R/site-library
##  [3] /usr/lib/R/site-library
##  [4] /usr/lib/R/library
## 
## ──────────────────────────────────────────────────────────────────────────────

Share