Publication-Ready Figures with ggplot2

R
visualization
tutorial
MA
Move beyond exploratory plots to publication-ready figures: choose the right chart type, apply the 7-item checklist, and use patchwork, ggrepel, gghighlight, ggtext, and scales to produce submission-quality output.
Author

Claudius Gräbner-Radkowitsch

Published

June 16, 2026

Modified

June 16, 2026

1 Introduction

The Visualization with ggplot2 tutorial covers the fundamentals: layers, aesthetics, themes, and ggsave(). This tutorial picks up where that one ends and addresses the gap between a working exploratory plot and a figure that is genuinely publication-ready.

That gap has three components. First, many figures are ugly — they use default styling that obscures the message. Second, some figures are bad — they use the wrong encoding for the question and actively mislead. Third, a few are technically wrong — truncated axes, missing uncertainty, or incorrect aggregation. Each problem requires a different fix.

1.1 Packages

Code
library(tidyverse)
library(patchwork)
library(ggrepel)
library(gghighlight)
library(ggtext)
library(scales)
library(gapminder)   # demo data: countries, GDP per capita, life expectancy
library(broom)       # for coefficient plots

2 Diagnosing Figures: Ugly, Bad, Wrong

The clearest framework for figure critique comes from Wilke (2019):

Label Problem type Example Fix
Ugly Aesthetic Grey background, raw variable names as axis labels theme_classic(), labs()
Bad Perceptual Wrong chart type, overlapping labels, spaghetti line Rethink the encoding
Wrong Mathematical No uncertainty, truncated y-axis, incorrect aggregation Fix the underlying logic
Important

“It doesn’t look great” is not a diagnosis. “The y-axis is truncated and the comparison is therefore wrong” is. Always identify which category of problem you are fixing before changing any code.

2.1 Same data, two figures

Code
ggplot(gapminder, aes(x = gdpPercap, y = lifeExp)) +
  geom_point()
Figure 1: Exploratory: default output — ugly but functional.
Code
gapminder |>
  filter(year == 2007) |>
  ggplot(aes(x = gdpPercap, y = lifeExp, color = continent)) +
  geom_point(alpha = 0.7, size = 2) +
  geom_text_repel(
    aes(label = country),
    data = \(d) filter(d, country %in% c("Germany", "Afghanistan", "China")),
    size = 3.5, show.legend = FALSE
  ) +
  scale_x_log10(labels = label_dollar(suffix = "k", scale = 0.001)) +
  scale_color_viridis_d() +
  theme_classic(base_size = 11) +
  labs(
    x       = "GDP per capita (USD, log scale)",
    y       = "Life expectancy (years)",
    color   = NULL,
    caption = "Source: Gapminder. Year: 2007."
  )
Figure 2: Publication-ready: subset to 2007, log scale, labelled points, clean theme.

The first version is exploratory — fine for your own analysis. The second is ready for a report or submission. Notice what changed: log scale to linearise the relationship, country labels for key points, color by continent, clean theme, human-readable axis labels, and a source caption.

3 Choosing the Right Chart Type

ImportantDecide before you code

Choose the chart type based on the research question, before opening any AI tool or writing any ggplot2 code. AI defaults to whatever was most common in its training data — usually a bar chart.

Question type Chart Key geom(s)
Regression coefficients + uncertainty Coefficient plot geom_pointrange()
Distributions across groups Violin + jitter geom_violin() + geom_jitter()
Bivariate with subgroup heterogeneity Faceted scatter geom_smooth() + facet_wrap()
Time series, multiple units Highlighted lines geom_line() + gghighlight()
Event study / treatment effect Centred point-range geom_pointrange() + geom_vline()

3.1 Example 1: Coefficient plot

“What predicts life expectancy, and how uncertain are we?”

Code
gap07 <- gapminder |> filter(year == 2007)
fit   <- lm(lifeExp ~ log(gdpPercap) + continent, data = gap07)

tidy(fit, conf.int = TRUE) |>
  filter(term != "(Intercept)") |>
  ggplot(aes(x = estimate, y = reorder(term, estimate),
             xmin = conf.low, xmax = conf.high)) +
  geom_pointrange(colour = "#1e4a7b") +
  geom_vline(xintercept = 0, linetype = "dashed", colour = "grey50") +
  theme_classic(base_size = 11) +
  labs(x = "Coefficient estimate (95% CI)", y = NULL,
       caption = "OLS. Source: Gapminder 2007.")
Figure 3: Coefficient plot: estimate + 95% CI for life expectancy predictors (Gapminder 2007).

Key geom: geom_pointrange() — estimate and uncertainty in a single layer. Always more informative than a bar chart showing only point estimates.

3.2 Example 2: Violin + jitter

“How does life expectancy distribute across continents?”

Code
gapminder |>
  filter(year == 2007) |>
  ggplot(aes(x = reorder(continent, lifeExp, median), y = lifeExp, fill = continent)) +
  geom_violin(alpha = 0.6, linewidth = 0.3, trim = FALSE) +
  geom_jitter(width = 0.07, alpha = 0.5, size = 1.5, colour = "grey30") +
  scale_fill_viridis_d(guide = "none") +
  coord_flip() +
  theme_classic(base_size = 11) +
  labs(x = NULL, y = "Life expectancy (years)", caption = "Source: Gapminder 2007.")
Figure 4: Distribution of life expectancy by continent (Gapminder 2007).

Violins show the shape of the distribution; jitter shows the raw data points. Together they communicate more than a box plot alone.

3.3 Example 3: Highlighted lines

“How has life expectancy evolved across European countries, highlighting Germany and Bulgaria?”

Code
gapminder |>
  filter(continent == "Europe") |>
  ggplot(aes(x = year, y = lifeExp, group = country)) +
  geom_line(linewidth = 0.6) +
  gghighlight(
    country %in% c("Germany", "Bulgaria"),
    use_direct_label = TRUE,
    label_params = list(size = 3.5)
  ) +
  theme_classic(base_size = 11) +
  labs(x = NULL, y = "Life expectancy (years)", caption = "Source: Gapminder.")
Figure 5: Life expectancy trends in Europe. Germany and Bulgaria highlighted with gghighlight.

gghighlight greys out non-highlighted lines and adds direct labels — keeping context without spaghetti.

4 Five Extensions Worth Knowing

4.1 patchwork — multi-panel layout

Compose separate ggplot objects with +, |, and /:

Code
p1 <- gapminder |>
  filter(year == 2007) |>
  ggplot(aes(log(gdpPercap), lifeExp, color = continent)) +
  geom_point(alpha = 0.7, show.legend = FALSE) +
  scale_color_viridis_d() +
  theme_classic(base_size = 10) +
  labs(x = "Log GDP per capita", y = "Life expectancy (years)", title = "A")

p2 <- gapminder |>
  filter(year == 2007) |>
  ggplot(aes(reorder(continent, lifeExp, median), lifeExp, fill = continent)) +
  geom_violin(alpha = 0.6, show.legend = FALSE) +
  scale_fill_viridis_d() +
  theme_classic(base_size = 10) +
  labs(x = NULL, y = NULL, title = "B")

p1 | p2
Figure 6: Two-panel figure composed with patchwork, panels labelled A and B.

For more control, use plot_annotation() for shared titles and plot_layout(guides = "collect") to merge legends:

Code
(p1 | p2) +
  plot_annotation(
    title = "Income, continents, and life expectancy (2007)",
    tag_levels = "A"
  ) +
  plot_layout(guides = "collect")

4.2 ggrepel — non-overlapping labels

Code
geom_text_repel(aes(label = country))     # repels text labels
geom_label_repel(aes(label = country))    # repels label boxes

Use max.overlaps = Inf to force all labels, or filter data = to label only specific points.

4.3 gghighlight — focus attention

Code
gghighlight(condition)                  # grey out everything else
gghighlight(continent == "Europe")      # keep only one group

Combine with use_direct_label = FALSE if you want to add your own labels via geom_text_repel.

4.4 ggtext — Markdown in plot text

Use element_markdown() to render Markdown in titles, subtitles, and axis labels:

Code
ggplot(data, aes(x, y)) +
  labs(title = "**Treated** firms earn *significantly* more than <span style='color:#D04A2F'>controls</span>") +
  theme(plot.title = element_markdown())

This allows bold, italic, and coloured text without external image editing — useful for publication titles that reference specific groups or model specifications.

4.5 scales — axis formatting

Code
scale_y_continuous(labels = label_comma())        # 1,234,567
scale_y_continuous(labels = label_percent())      # 12.3%
scale_x_log10(labels = label_dollar())            # $1,000
scale_x_log10(labels = label_dollar(suffix = "k", scale = 0.001)) # $1k

Never leave raw variable names or scientific notation on axes in a submitted figure.

5 The 7-Item Publication Checklist

Before finalising any figure, verify:

  1. Axis labels with units — no raw variable names; include units where applicable
  2. Colorblind-safe palette — scale_colour_viridis_d() or Okabe-Ito (scale_colour_manual(values = palette.colors(8, "Okabe-Ito")))
  3. Uncertainty on every estimate — confidence intervals, not just point estimates
  4. Clean theme — theme_classic() or theme_minimal(); no default grey background
  5. Base size ~10–12pt for a 3.5-inch figure; use theme_*(base_size = 11)
  6. Source and notes in labs(caption = "...")
  7. Saved with explicit dimensions — ggsave() with width, height, and units

5.1 Saving correctly

Never use the RStudio Export button — it produces inconsistently sized output that will not match a submitted manuscript:

Code
ggsave(
  "figures/fig1.pdf",
  plot   = p,
  width  = 5.5,
  height = 3.5,
  units  = "in",
  device = cairo_pdf    # embeds fonts correctly for journal submission
)

For HTML output, save as .png with dpi = 300. For Word output, save as .png with dpi = 300 and insert via knitr::include_graphics().

6 Summary

Skill Key tool When to use
Diagnose a figure Ugly/bad/wrong framework Before any code changes
Choose chart type Question → chart table Before any code, before AI
Multi-panel layout patchwork Two or more related panels
Label specific points ggrepel Scatter plots with labelled outliers
Focus on one subset gghighlight Time series with many units
Markdown in text ggtext Titles referencing groups/colours
Axis formatting scales Any numeric axis
Save correctly ggsave() Every figure in every project

7 Further Reading

  • Wilke, C. (2019). Fundamentals of Data Visualization. O’Reilly. clauswilke.com/dataviz — Open access; the ugly/bad/wrong framework comes from here.
  • Healy, K. (2018). Data Visualization: A Practical Introduction. Princeton UP. socviz.co — Open access draft; strong on the sociology of figures.

Citation

BibTeX citation:
@inreference{graebner2026-publication-ready-figures-ggplot2,
  author = {Gräbner-Radkowitsch, Claudius},
  editor = {Gräbner-Radkowitsch, Claudius},
  title = {Publication-Ready {Figures} with Ggplot2},
  booktitle = {Method and Inquiry},
  date = {2026-06-16},
  url = {https://euf-methodology-hub.netlify.app/resources/publication-ready-figures-ggplot2},
  langid = {en}
}
For attribution, please cite this work as:
Gräbner-Radkowitsch, Claudius. 2026. “Publication-Ready Figures with Ggplot2.” In Method and Inquiry, edited by Claudius Gräbner-Radkowitsch. June 16. https://euf-methodology-hub.netlify.app/resources/publication-ready-figures-ggplot2.