rguides

Customizing ggplot2 Themes

Every ggplot2 plot has two distinct parts: the data mappings (aesthetics) and everything else. The “everything else” is the theme system. Axis lines, tick marks, panel backgrounds, grid lines, plot titles, subtitles, captions, legend positioning, and facet labels all fall under theme control. Understanding themes means you can make any plot look exactly the way you want it to look.

What you’ll learn

This tutorial covers the key concepts and practical techniques for working with Customizing ggplot2 Themes. By the end, you will know how to apply the core functions in real data analysis workflows.

Built-in themes

ggplot2 ships with eight complete themes. Each one sets every non-data visual element at once, giving your plots a consistent style with a single function call.

ThemeBest for
theme_grey()Default. Grey background, white panel, light grey grid
theme_bw()Black and white. White panel with black border. Printer-friendly
theme_minimal()No background, no axis borders. Cleanest option
theme_classic()No grid lines. Plain black axes. Good for editorial use
theme_light()Like minimal but with light grey grid lines
theme_dark()Dark background. Grid lines are also dark
theme_linedraw()Only horizontal grid lines, black axes
theme_void()Completely empty. Only the geometry remains
library(ggplot2)

p <- ggplot(mtcars, aes(wt, mpg, color = factor(cyl))) +
  geom_point(size = 3) +
  labs(
    title = "Fuel Economy vs. Weight",
    subtitle = "By number of cylinders",
    color = "Cylinders"
  )

# Apply different themes — each returns a ggplot object you can further modify
p + theme_minimal()
p + theme_classic()
p + theme_bw()

theme_grey() is the default, applied automatically if you don’t specify one. theme_gray() is identical, both names work.

How the theme system works

The theme system is a cascading override. You start with a complete built-in theme, then selectively override individual elements using theme(). Each element expects a specific type: element_text(), element_line(), element_rect(), or element_blank().

This matters because passing the wrong element type causes an error or silent failure. For example, panel.grid.major expects a line element, so you must pass element_line() or element_blank(), not element_text().

# Start from a base theme, then override specifics
p + theme_minimal() +
  theme(
    axis.text = element_text(size = 12),
    panel.grid.major = element_line(color = "gray90")
  )

Tweaking individual elements with theme()

The theme() function accepts hundreds of named arguments. The naming follows a dot-separated hierarchy that groups elements logically.

Text elements

p + theme(
  plot.title = element_text(size = 16, face = "bold", hjust = 0),
  plot.subtitle = element_text(size = 12, color = "gray40", hjust = 0),
  plot.caption = element_text(size = 9, color = "gray50", hjust = 1),
  axis.title.x = element_text(size = 12, face = "bold"),
  axis.text.x = element_text(color = "darkred", angle = 45)
)

element_text() arguments include family (font name like "serif" or "sans"), face ("bold", "italic", "bold.italic"), size (in points), color, hjust and vjust (0 to 1 for alignment), angle (degrees), and margin (using margin()).

Line elements

p + theme(
  panel.grid.major = element_line(color = "gray80", size = 0.5, linetype = "dashed"),
  panel.grid.minor = element_blank(),
  axis.line = element_line(color = "black", size = 0.8),
  axis.ticks = element_line(color = "black")
)

element_line() accepts color, size (in mm), linetype ("solid", "dashed", "dotted", etc.), and lineend ("round", "butt", "square"). Use element_blank() to remove a line element entirely.

Rectangle elements

p + theme(
  panel.background = element_rect(fill = "white", color = NA),
  plot.background = element_rect(fill = "#F0F0F0", color = NA),
  panel.border = element_rect(color = "gray30", fill = NA, size = 1)
)

element_rect() controls filled boxes: backgrounds, borders, legend boxes. Use fill for background color and color for the border. Setting color = NA removes the border.

Legend customization

Legends are controlled through several theme elements. legend.position accepts "none", "left", "right", "top", "bottom", or a numeric vector c(x, y) for precise placement inside the plot area.

# Move legend to bottom, horizontal
p + theme(legend.position = "bottom")

# Place legend inside the plot
p + theme(legend.position = c(0.8, 0.2))

# Remove legend entirely
p + theme(legend.position = "none")

# Style legend text and title
p + theme(
  legend.title = element_text(size = 11, face = "bold"),
  legend.text = element_text(size = 10),
  legend.margin = margin(t = 5, r = 5, b = 5, l = 5)
)

For multiple legends arranged horizontally:

p + theme(
  legend.box = "horizontal",
  legend.box.just = "center",
  legend.position = "bottom"
)

Styling facet labels

Facet labels (the headers showing facet variable values) are controlled with strip.text and strip.background.

p + theme(
  strip.text = element_text(size = 12, face = "bold", color = "white"),
  strip.background = element_rect(fill = "steelblue", color = NA)
)

# Style x and y strips independently
p + theme(
  strip.text.x = element_text(size = 11, face = "bold.italic"),
  strip.text.y = element_text(size = 11, face = "bold.italic", angle = 0)
)

Controlling spacing

Two elements handle the whitespace around your plot. panel.spacing controls the gap between panels in faceted plots. plot.margin adds outer padding around the entire plot.

# Space between panels in facet_wrap or facet_grid
p + facet_wrap(~ cyl) +
  theme(
    panel.spacing = unit(0.5, "cm"),
    panel.spacing.x = unit(1, "cm"),
    panel.spacing.y = unit(0.3, "cm")
  )

# Outer margins around the full plot (top, right, bottom, left)
p + theme(plot.margin = margin(t = 1, r = 1.5, b = 0.5, l = 0.5, unit = "cm"))

Building a reusable custom theme

When you find yourself applying the same theme settings across multiple plots, wrap them in a custom theme function. This makes your plots consistent and reduces repetition.

The key detail here is %+replace%. This operator replaces the base theme entirely, unlike + which would layer on top and cause unexpected behavior.

theme_publication <- function(base_size = 11, base_family = "sans") {
  theme_minimal(base_size = base_size, base_family = base_family) %+replace%
    theme(
      # Text styling
      plot.title = element_text(
        size = 16,
        face = "bold",
        hjust = 0,
        margin = margin(b = 8)
      ),
      plot.subtitle = element_text(
        size = 12,
        color = "gray40",
        hjust = 0,
        margin = margin(b = 12)
      ),
      plot.caption = element_text(
        size = 9,
        color = "gray50",
        hjust = 1
      ),
      axis.title = element_text(size = 11, face = "bold"),
      axis.text = element_text(size = 10, color = "gray30"),
      # Grid
      panel.grid.major = element_line(color = "gray90", size = 0.4),
      panel.grid.minor = element_blank(),
      # Legend
      legend.position = "bottom",
      legend.box = "horizontal",
      legend.title = element_text(size = 10, face = "bold"),
      legend.text = element_text(size = 9),
      # Spacing
      plot.margin = margin(t = 0.5, r = 0.5, b = 0.5, l = 0.5, unit = "cm"),
      axis.ticks.length = unit(0.2, "cm")
    )
}

# Apply it to any plot
ggplot(mtcars, aes(wt, mpg, color = factor(cyl))) +
  geom_point(size = 3) +
  labs(title = "Fuel Economy vs. Weight", color = "Cylinders") +
  theme_publication()

The %+replace% operator is what makes this work correctly. If you use + instead, elements from the base theme that you didn’t override would still apply, potentially creating duplicate or conflicting settings.

Comparing built-in themes side by side

When choosing a theme, it helps to see them side by side. Patchwork or gridExtra::grid.arrange() let you compare multiple themes on the same data.

library(ggplot2)
library(patchwork)

base <- ggplot(diamonds[1:500,], aes(carat, price, color = cut)) +
  geom_point(alpha = 0.6) +
  labs(
    title = "Diamond Price vs. Carat",
    subtitle = "Sample of 500 diamonds",
    color = "Cut"
  )

minimal  <- base + theme_minimal() + labs(title = "theme_minimal()")
classic  <- base + theme_classic() + labs(title = "theme_classic()")
bw       <- base + theme_bw()      + labs(title = "theme_bw()")
linedraw <- base + theme_linedraw() + labs(title = "theme_linedraw()")

(minimal | classic) / (bw | linedraw)

This kind of comparison helps you decide which base theme to start from before applying your customizations.

Complete themes

ggplot2 ships with several complete themes that replace the default grey background with polished alternatives. theme_minimal() removes the grey background, uses subtle grey gridlines, and is suitable for presentations and reports. theme_bw() uses a white background with black border. theme_classic() uses only x and y axis lines with no gridlines, similar to base R defaults.

theme_void() removes all non-data elements, axes, gridlines, background, labels. It is the starting point for maps and network graphs where coordinate axes are meaningless.

From extension packages: ggthemes::theme_economist() mimics The Economist’s house style; ggthemes::theme_fivethirtyeight() matches FiveThirtyEight’s palette; hrbrthemes::theme_ipsum() uses the inter typeface with balanced typography for technical reports.

Modifying theme elements

theme() modifies individual theme elements. Elements have types: element_text() for text, element_line() for lines, element_rect() for filled rectangles, element_blank() to remove an element.

theme(
  plot.title = element_text(size = 16, face = "bold", hjust = 0.5),
  axis.title.x = element_text(margin = margin(t = 10)),
  panel.grid.minor = element_blank(),
  legend.position = "bottom",
  strip.background = element_rect(fill = "#f0f0f0", color = NA)
)

legend.position accepts "top", "bottom", "left", "right", "none", or a two-element numeric vector for placement within the plot area (e.g., c(0.8, 0.9) places the legend in the top-right). legend.direction = "horizontal" arranges legend keys in a row.

Custom theme functions

Create reusable themes by wrapping theme() in a function:

theme_company <- function(base_size = 12, base_family = "sans") {
  theme_minimal(base_size = base_size, base_family = base_family) %+replace%
    theme(
      plot.background = element_rect(fill = "#ffffff", color = NA),
      plot.title = element_text(color = "#003366", face = "bold"),
      axis.text = element_text(color = "#444444"),
      panel.grid.major = element_line(color = "#dddddd"),
      panel.grid.minor = element_blank()
    )
}

%+replace% in theme inheritance replaces elements completely rather than merging. When building on theme_minimal() or another complete theme, use %+replace% if you want your changes to override all inherited values.

theme_set(theme_company()) applies a theme globally for the R session, all subsequent ggplot2 charts use it by default. This is the cleanest way to enforce brand consistency in a report or presentation.

Typography in charts

element_text() controls font properties: family (font name), size (points), face (“plain”, “bold”, “italic”, “bold.italic”), colour, hjust, vjust, angle, margin.

System fonts are available by name: "Arial", "Georgia", "Courier New". Custom fonts require systemfonts::register_font() or the extrafont package to load fonts from the system font library into R. After registering, use the font name in element_text(family = "Roboto").

For embedding fonts in PDF output, cairo_pdf() handles font embedding correctly. For PNG output, ragg::agg_png() renders text through the AGG library and supports custom fonts without system font registration.

Plot margins and spacing

plot.margin = margin(t = 20, r = 20, b = 20, l = 20, unit = "pt") sets the outer margin around the entire plot. Use this to add breathing room when embedding plots in reports.

panel.spacing controls the gap between facet panels. panel.spacing = unit(1, "cm") for facet_wrap() and facet_grid().

axis.text.x = element_text(angle = 45, hjust = 1) rotates x-axis labels, useful for long category names. The hjust = 1 right-aligns the rotated text so it points toward the axis tick.

Why theme consistency matters

In professional data reporting, visual consistency across charts is as important as the individual chart quality. When every chart in a report uses the same fonts, color palette, and layout conventions, the report reads as a coherent whole. Readers do not have to reorient for each chart. The visual language of the report becomes familiar, reducing cognitive load.

Setting a theme once with theme_set() at the beginning of a script or R Markdown document enforces this consistency automatically. You do not need to remember to add theme() calls to every plot. Adding a custom theme function to a team’s shared package ensures consistency across all team members’ work.

The business case for visual consistency extends beyond aesthetics. Reports that look polished and consistent signal professionalism and attention to detail. Presentations where charts have conflicting styles suggest they were assembled from different sources rather than built as a coherent analysis. Standardizing chart styles reduces this appearance even when the underlying work is thorough.

Building a theme library

For organizations that produce many reports, a theme library, a package containing shared theme functions, color palettes, and ggplot2 extensions — provides consistency at scale. The package defines theme_company() with brand colors, standard font sizes, and axis formatting conventions. Every analyst who loads the package gets the same defaults.

Building the theme library involves documenting the organization’s visual standards: primary and secondary colors, typeface choices, spacing conventions, and the meaning of specific colors (red for negative, green for positive, for instance). These design decisions should be made once and encoded in code, not left to each individual analyst.

The maintenance burden is low once the library is established. When the brand updates, changing the library’s color constants updates all subsequent reports automatically. Existing reports with pinned package versions maintain their historical appearance while new reports use the updated style.

Responding to output format requirements

Different output formats have different theme requirements. Charts for web display can use thin lines and subtle colors that display well at screen resolution. Charts for print need thicker lines and higher contrast. Charts for presentations need larger text and simpler designs visible from the back of a room.

Managing this variation with a theme system means having theme_web(), theme_print(), and theme_presentation() variants with appropriate settings for each context. Switching context means changing one function call. This is far more maintainable than having separate versions of each chart for each context.

For journal submission, always read the target journal’s figure guidelines before designing charts. Common requirements: minimum 600 DPI for figures, specific size constraints, black-and-white compatibility requirement, specific fonts. Building a theme for each journal you submit to ensures consistent compliance with those requirements.

Next steps

Now that you understand customizing ggplot2 themes, explore these related topics to deepen your knowledge and apply these techniques in more complex scenarios.

See also