Customizing ggplot2 Themes

· 7 min read · Updated March 28, 2026 · intermediate
r ggplot2 data-visualization tidyverse

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.

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.

See Also

Written

  • File: sites/rguides/src/content/tutorials/customizing-ggplot2-themes.md
  • Words: ~1150
  • Read time: 5 min
  • Topics covered: built-in themes, theme() function, element_text, element_line, element_rect, legend customization, facet labels, spacing, custom theme functions, %+replace% operator
  • Verified via: ggplot2 documentation, R documentation (rdocumentation.org)
  • Unverified items: none