Customizing ggplot2 Charts: Themes, Colors, and Labels
Customizing ggplot2 charts transforms functional plots into polished, publication-ready graphics that communicate data clearly. Once you’ve mastered the basics of ggplot2, the real power comes from fine-tuning every visual detail: titles, labels, color palettes, theme elements, and legend placement. These choices match the story your data tells.
Prerequisites
library(ggplot2)
library(dplyr)
# Sample data for examples
mpg_summary <- mpg |>
group_by(manufacturer) |>
summarise(avg_hwy = mean(hwy), count = n())
Modifying axis labels and titles
The labs() function is your go-to for adding and customizing labels. It accepts arguments for title, subtitle, x, y, and caption in a single call, which keeps your plot-building code tidy. Every label element can include plain text or basic markdown, and you can wrap long titles with the str_wrap() function from stringr if they overflow the plot margin:
ggplot(mpg_summary, aes(x = manufacturer, y = avg_hwy)) +
geom_col(fill = "steelblue") +
labs(
title = "Average Highway MPG by Manufacturer",
subtitle = "Based on EPA mileage data",
x = "Car Manufacturer",
y = "Average Highway MPG",
caption = "Data: EPA mpg dataset"
) +
theme_minimal()
You can also use ggtitle(), xlab(), and ylab() individually, but labs() is more concise because it sets all labels in one function rather than spreading them across three separate calls that get harder to track as your plot pipeline grows.
Customizing colors
Continuous color scales
For continuous variables, use scale_fill_gradient() or scale_color_gradient() to map numeric values to a two-color range. The low and high arguments define the endpoints, and ggplot interpolates every intermediate shade automatically. This is useful for encoding magnitude; darker shades draw the eye to larger values without adding a separate axis or label:
ggplot(mpg_summary, aes(x = manufacturer, y = avg_hwy, fill = avg_hwy)) +
geom_col() +
scale_fill_gradient(
low = "lightblue",
high = "darkblue",
name = "MPG"
) +
theme_minimal()
Discrete color scales
For categorical data, scale_fill_manual() lets you specify exact hex colors for each category level. This is essential for brand consistency, matching a style guide, or ensuring that the same category always appears in the same color across multiple charts in a dashboard or report. Pass a named vector to values where the names correspond to factor levels:
ggplot(mpg_summary, aes(x = manufacturer, y = avg_hwy, fill = manufacturer)) +
geom_col() +
scale_fill_manual(
values = c("audi" = "#E41A1C", "chevrolet" = "#377EB8",
"ford" = "#4DAF4A", "honda" = "#984EA3"),
name = "Manufacturer"
) +
theme_minimal() +
theme(legend.position = "none")
The viridis color scale is an excellent alternative to manual hex codes when you need perceptually uniform colors that work for colorblind viewers and print well in grayscale. Unlike typical rainbow palettes that create artificial boundaries, viridis maps data values to a smooth, perceptually linear gradient. Use scale_fill_viridis_c() for continuous variables and scale_fill_viridis_d() for discrete ones:
ggplot(mpg_summary, aes(x = manufacturer, y = avg_hwy, fill = avg_hwy)) +
geom_col() +
scale_fill_viridis_c(option = "plasma", name = "MPG") +
theme_minimal()
Using themes
Themes control the overall visual style of a ggplot without touching the data or geometry layers. Switching a theme changes the background color, gridlines, font family, and axis styling in one operation. ggplot2 comes with several built-in themes that range from the default gray background to minimal and dark variants:
# Different theme options
theme_gray() # Default gray background
theme_bw() # Black and white
theme_minimal() # Minimal, no background
theme_classic() # Classic look with axes
theme_dark() # Dark background
Apply a theme globally with theme_set() to avoid repeating the same theme line in every ggplot call throughout a script. This is especially useful in R Markdown or Quarto documents where you want a consistent look across all figures. Once set, every subsequent plot inherits the theme until you call theme_set() again or reset it with theme_set(theme_gray()):
theme_set(theme_minimal())
Customizing theme elements
Beyond applying a built-in theme, theme() gives you granular control over individual visual elements. You can rotate axis labels to prevent overlapping text, center the plot title, remove unnecessary gridlines, or reposition the legend. Each element accepts element_text(), element_line(), element_rect(), or element_blank() to control its appearance:
ggplot(mpg_summary, aes(x = manufacturer, y = avg_hwy)) +
geom_col(fill = "steelblue") +
labs(title = "Average Highway MPG") +
theme(
plot.title = element_text(size = 16, face = "bold", hjust = 0.5),
axis.text.x = element_text(angle = 45, hjust = 1),
panel.grid.major.x = element_blank(),
legend.position = "top"
)
Common theme elements:
plot.title,plot.subtitle,plot.captionaxis.title,axis.text,axis.linelegend.position,legend.title,legend.textpanel.background,panel.grid
Modifying legends
Changing legend position
Control where the legend appears relative to the plot area. The legend.position argument accepts "top", "bottom", "left", "right", or "none" to remove the legend entirely:
ggplot(mpg_summary, aes(x = manufacturer, y = avg_hwy, fill = manufacturer)) +
geom_col() +
theme(legend.position = "top") # "bottom", "left", "right", "none"
Customizing legend labels
Rename the legend title and style the title text with theme() elements. The name argument inside any scale_*() function changes the legend heading text, while element_text() controls the font weight, size, and face. Use legend.title = element_text(face = "bold") to make the title stand out from the item labels, or element_blank() to hide the title entirely when the legend entries are self-explanatory:
ggplot(mpg_summary, aes(x = manufacturer, y = avg_hwy, color = manufacturer)) +
geom_point(size = 3) +
scale_color_discrete(name = "Car Maker") +
theme(legend.title = element_text(face = "bold"))
Removing the legend altogether
When a single plot conveys the color mapping clearly through the title or context, removing the legend reduces visual clutter and gives more space to the data. This is common in small-multiple layouts where the same color scheme repeats across panels, or when the chart title already names the categories. Add this theme line to any existing ggplot call:
+ theme(legend.position = "none")
Changing scales
Scales control how data values translate into visual properties on the plot. Beyond colors and fills, you can modify axis ranges, tick mark positions, and axis label formats. Understanding the difference between zooming with coord_cartesian() and filtering with scale_*_continuous(limits = ...) is important: the former preserves all data points but changes the viewport, while the latter removes out-of-range data before plotting, which can affect trend lines and summary statistics computed from the visible data.
Modifying axis limits
# Using coord_cartesian() - preserves the data
ggplot(mpg, aes(x = displ, y = hwy)) +
geom_point() +
coord_cartesian(xlim = c(1, 7), ylim = c(10, 50))
# Using scale functions - actually filters the data
ggplot(mpg, aes(x = displ, y = hwy)) +
geom_point() +
scale_x_continuous(limits = c(1, 7)) +
scale_y_continuous(limits = c(10, 50))
Customizing tick marks
Tick marks anchor the viewer’s understanding of axis values. Customizing their positions with the breaks argument lets you highlight specific values, such as quartiles or thresholds, while the labels argument replaces the raw numeric values with readable text. This is especially useful when the axis represents a transformed variable (log scale, percentages) and you want labels that match what the reader expects:
ggplot(mpg, aes(x = displ, y = hwy)) +
geom_point() +
scale_x_continuous(
breaks = c(2, 4, 6),
labels = c("2L", "4L", "6L")
) +
scale_y_continuous(
breaks = seq(10, 50, by = 10)
)
Faceting
Faceting creates small multiples: a grid of identical chart layouts, each showing a different subset of your data. Instead of encoding every category in a single plot with colors or shapes that can become overwhelming, faceting gives each category its own panel. This makes it easy to compare patterns across groups without overplotting or relying on a crowded legend:
# Column faceting
ggplot(mpg, aes(x = displ, y = hwy)) +
geom_point() +
facet_wrap(~ class, ncol = 3) +
theme_minimal()
# Grid faceting
ggplot(mpg, aes(x = displ, y = hwy)) +
geom_point() +
facet_grid(drv ~ cyl) +
theme_minimal()
Customize facet labels to make the panel headers more informative. The labeller argument accepts built-in labellers like label_both (shows “variable: value”) or label_value (shows only the value), and you can write custom labeller functions for more control. Style the strip text background and font with theme(strip.text = ...) to match the rest of your chart’s typography:
ggplot(mpg, aes(x = displ, y = hwy)) +
geom_point() +
facet_wrap(~ class, labeller = label_both) +
theme(strip.text = element_text(face = "bold"))
Saving your plots
Use ggsave() to export plots to files at specific dimensions and resolution. The function saves the last displayed plot by default, but you can also pass a plot object explicitly to save a specific ggplot without displaying it first. Control the output format by changing the file extension: .png, .pdf, .svg, and .jpeg are all supported. Set the physical size with width and height (in inches by default) alongside dpi for raster output quality:
# Save as PNG
ggsave("my_plot.png", width = 8, height = 6, dpi = 300)
# Save as PDF
ggsave("my_plot.pdf", width = 8, height = 6)
# Save with specific dimensions
ggsave("plot.png", width = 10, height = 8, units = "cm")
Theme elements
theme() controls every non-data element of a ggplot. It accepts element_text(), element_line(), element_rect(), and element_blank() for each component. element_blank() removes an element entirely: theme(panel.grid.minor = element_blank()) removes minor gridlines. element_text(size = 12, face = "bold", color = "navy") styles text. rel(0.8) specifies relative sizes: element_text(size = rel(0.8)) makes text 80% of the base size.
Scales
Scales map data values to visual properties. scale_x_continuous(limits, breaks, labels, trans) controls the x-axis. scale_color_manual(values = c("A" = "red", "B" = "blue")) maps factor levels to specific colors. scale_size_area() maps values to area (more perceptually honest than radius for bubble charts). scale_x_log10() applies log transformation to the axis while displaying original values as labels.
Facets
facet_wrap(~ variable) creates one panel per value of the variable. facet_grid(rows ~ cols) creates a 2D grid. scales = "free" allows each facet to have its own axis range, useful when variables have different scales. labeller = labeller(variable = c("a" = "Label A", "b" = "Label B")) provides custom facet labels. strip.text in theme() styles the facet header text.
Annotations
annotate("text", x = 3, y = 10, label = "peak") adds text at a specific data coordinate. annotate("rect", xmin, xmax, ymin, ymax, alpha = 0.2) highlights a region. geom_hline(yintercept = 100) and geom_vline(xintercept = 5) add reference lines. For non-overlapping text labels on data points, ggrepel::geom_text_repel(aes(label = name)) automatically avoids collisions.
Combining plots
patchwork is the standard way to arrange multiple ggplot2 charts. p1 + p2 places plots side by side; p1 / p2 stacks them vertically; (p1 | p2) / p3 creates complex layouts. plot_annotation(title = "Overall Title", tag_levels = "A") adds panel labels (A, B, C). plot_layout(guides = "collect") merges identical legends from multiple panels into one.
Coordinate systems
coord_cartesian(xlim = c(0, 10)) zooms without clipping data. coord_flip() swaps x and y axes, used to create horizontal bar charts. coord_polar() creates polar charts (pie charts are bar charts with coord_polar()). coord_equal() forces equal unit lengths on both axes, essential for maps and aspect-ratio-sensitive visualizations. coord_sf() handles geographic projections for sf spatial objects.
Colors in depth
ggplot2 scales control color mappings. For continuous data: scale_color_gradient(low = "blue", high = "red") creates a two-color gradient; scale_color_gradient2() creates a diverging scale with a midpoint. scale_color_viridis_c() uses the viridis colorblind-safe palette. For discrete data: scale_color_brewer(palette = "Dark2") uses ColorBrewer. scale_color_manual(values = named_vector) provides complete control over which color maps to which level.
Plot sizing and export
ggsave("plot.png", width = 8, height = 6, dpi = 300) saves the last ggplot. The plot argument saves a specific object: ggsave("fig.pdf", plot = p, width = 3.5). For embedding in Quarto/R Markdown, chunk options fig.width and fig.height control size in inches. ragg::agg_png() is a higher-quality raster renderer than the default PNG device, set as default with options(device = ragg::agg_png).
Scales and axis control
Scales control how data values map to aesthetic properties. scale_x_continuous(limits = c(0, 100), breaks = seq(0, 100, 20)) sets axis range and break positions. scale_x_log10() uses a log scale. scale_x_reverse() reverses the direction. scale_x_date(date_breaks = "3 months", date_labels = "%b %Y") formats date axes.
scale_y_continuous(labels = scales::comma) formats y-axis labels with commas for thousands. scales::dollar, scales::percent, scales::scientific are other label formatters. labels = function(x) paste0(x, "k") applies a custom transformation. expand = expansion(mult = 0.05) adds 5% padding on each side of the axis range.
Removing axis clutter: scale_x_continuous(labels = NULL) hides axis labels. theme(axis.text.x = element_blank(), axis.ticks.x = element_blank()) removes labels and tick marks. coord_cartesian(xlim = c(0, 10)) zooms without removing data (unlike scale_x_continuous(limits = ...) which removes out-of-range data).
Color scales
scale_color_manual(values = c("A" = "#e41a1c", "B" = "#377eb8")) maps categorical values to specific colors. Order of values matches the order of levels in the data. scale_fill_manual() works the same for filled geometries.
For continuous data: scale_color_gradient(low = "white", high = "darkblue") creates a two-color gradient. scale_color_gradient2(low = "blue", mid = "white", high = "red", midpoint = 0) creates a diverging scale around a midpoint. scale_color_gradientn(colors = viridisLite::viridis(9)) uses a multi-color gradient.
Palette packages: scale_color_brewer(palette = "Set2") from ColorBrewer. scale_color_viridis_d() for discrete viridis. scale_color_tableau() from ggthemes. scale_color_futurama() from ggsci for discrete palettes inspired by scientific journals.
Annotations and labels
annotate("text", x = 5, y = 20, label = "Peak", size = 3) places text at coordinates. annotate("rect", xmin = 3, xmax = 7, ymin = -Inf, ymax = Inf, alpha = 0.1) shades a vertical band. annotate("segment", x = 2, xend = 4, y = 10, yend = 18, arrow = arrow(length = unit(0.2, "cm"))) draws an arrow.
ggplot2::geom_vline(xintercept = mean(df$x), linetype = "dashed") draws a reference line. geom_hline() draws horizontal lines. geom_abline(slope = 1, intercept = 0) draws a line with a specific slope, the identity line for scatter plots.
ggrepel::geom_text_repel(aes(label = name)) adds non-overlapping labels to a scatter plot. The repel algorithm nudges labels to avoid overlap with other labels and the points they label. max.overlaps = 20 controls how aggressively it tries to avoid overlap.
Faceting in depth
facet_wrap(~ var, ncol = 3) creates a wrapped multi-panel layout. scales = "free" allows each panel its own axis range, useful when variables have very different ranges. scales = "free_x" or "free_y" frees only one axis.
facet_grid(row_var ~ col_var) creates a matrix of panels. labeller = labeller(var = c(a = "Label A", b = "Label B")) customizes panel labels. strip.position = "left" moves strip labels to the side.
facet_wrap(~ var, nrow = 1) with theme(strip.background = element_blank(), strip.text = element_text(face = "bold")) produces a clean multi-panel layout with minimal visual overhead.
Visual design principles for data visualization
Effective data visualization communicates a specific insight quickly. Before customizing a chart, clarify what question it answers. The customization choices should make that answer more obvious, not add visual complexity.
The most important customization is often what you remove, not what you add. Default ggplot2 charts have grey backgrounds, minor gridlines, and other elements that take visual space without adding information. Switching to a minimal theme, removing minor gridlines, and stripping unnecessary borders often makes a chart more readable than adding annotations and colors.
Color is one of the most powerful encodings but also one of the most easily misused. Use color to encode a third variable that matters for the chart’s message. Do not use color just because the chart looks more interesting with it. When using color for categories, choose palettes designed for data visualization (ColorBrewer, viridis, Okabe-Ito) rather than default ggplot2 colors, which are not optimized for readability or accessibility.
Typography in charts follows a hierarchy. The chart title should be the largest text and should state the key insight (not just label what the chart shows). Axis titles should be smaller and explain what is being measured, including units. Tick labels should be smaller still. Annotation text should match the size that is readable without being prominent.
The data-to-ink ratio principle, popularized by Edward Tufte, suggests removing any ink that does not represent data. Borders around the plot area, gridlines for every tick, redundant axis labels (a legend and axis that both identify the same groups), and decorative elements all reduce the ratio. Removing them usually improves clarity.
Choosing the right geometry
The geometry choice encodes the structure of the comparison. Bar charts compare discrete categories. Scatter plots reveal relationships between two continuous variables. Line charts show change over a continuous variable (typically time). Box plots compare distributions. Choosing the wrong geometry misleads viewers even when the data is correct.
Common mistakes: using bar charts for continuous distributions (a histogram or density plot is more informative), using pie charts when bar charts would be easier to compare (most humans cannot accurately judge angles), using line charts between unordered categories (implies a trend that may not exist), using 3D effects (distort relative sizes).
For the same data, different geometries answer different questions. A scatter plot shows individual observations and their relationship. A box plot summarizes the distribution at each category. A jitter plot shows the density at each category. Choosing which is most appropriate depends on the question the chart is answering.
Summary
Key customization functions:
labs(), Add titles, labels, captionsscale_fill_*()/scale_color_*(), Customize colorstheme(), Modify visual appearancecoord_*(), Change coordinate systemsfacet_*(), Create small multiplesggsave(), Export plots to files
With these tools, you can transform basic ggplot2 visualizations into publication-ready charts that communicate your data effectively.
Next steps
Now that you understand customizing ggplot2 charts, explore these related topics to deepen your knowledge and apply these techniques in more complex scenarios.
See also
- Introduction to ggplot2: Start here if you’re new to the ggplot2 grammar of graphics.
- Maps with ggplot2: Apply customization skills to geographic data.
- Plotly Interactive Charts: Make your customized ggplot2 charts interactive with plotly.
- ggplot2 Extensions and Advanced Charts: Extend ggplot2 beyond the built-in geometries.