ggplot2 Themes Exercises in R: 20 Practice Problems
Twenty practice problems on ggplot2 themes covering built-ins, axis text, legends, gridlines, plot titles, plot margins, and reusable theme functions. Each problem hides its solution behind a click to keep you honest.
Section 1. Built-in themes (3 problems)
Exercise 1.1: Replace the default gray theme with theme_minimal on an mpg scatter
Task: A junior analyst is sharing an early exploration of fuel economy. Build a scatter plot from the built-in mpg dataset with displ on the x-axis, hwy on the y-axis, and apply theme_minimal() so the default gray background disappears. Save the result to ex_1_1.
Expected result:
#> Scatter plot of displ vs hwy. White panel background, light gray gridlines, no axis lines, sans-serif axis labels and tick text.
Difficulty: Beginner
Think about which built-in theme swaps the default gray panel for a clean white background while keeping faint gridlines.
Append theme_minimal() as the final layer, after geom_point().
Click to reveal solution
Explanation: theme_minimal() is the most common drop-in replacement for the default theme_gray(). It removes the gray panel fill, keeps subtle gridlines, and drops axis ticks, which makes the plot read more like a publication figure. Position theme calls AFTER all geoms and scales so later additions cannot override the look you just set.
Exercise 1.2: Style a diamonds boxplot with theme_classic for a journal figure
Task: A code reviewer is standardizing team chart style and recommends theme_classic() for print figures because it strips gridlines and leaves only the two axis lines. Build a boxplot of diamonds price by cut, apply theme_classic(), and save to ex_1_2.
Expected result:
#> Boxplot of price by cut. White panel, NO gridlines, only x and y axis lines visible (L-shape), no panel border. Looks like a base R plot but typeset cleaner.
Difficulty: Beginner
You need the built-in theme that keeps only the two axis lines and strips every gridline and the panel border.
Append theme_classic() after geom_boxplot().
Click to reveal solution
Explanation: theme_classic() strips the panel border AND the gridlines, leaving just the two axis lines, which mimics journal style. The visual contrast with theme_bw() (which keeps the grid and adds a black border) is what makes theme_classic() the right default for static print figures where any gridline clutter competes with the data. For dashboards where readers need to estimate exact values, prefer theme_bw() or theme_minimal() instead.
Exercise 1.3: Strip every non-data element with theme_void for a sparkline
Task: A product manager wants a tiny sparkline of economics unemployment to embed inside a KPI card with no axis chrome at all. Plot unemploy over date as a line, apply theme_void(), and save to ex_1_3 so the resulting plot is pure ink.
Expected result:
#> Single line of unemploy over time. No axes, no ticks, no labels, no panel border, no background fill, no title.
Difficulty: Intermediate
For a sparkline you want the most aggressive built-in theme - one that erases all chrome: axes, ticks, labels, and background.
Append theme_void() after geom_line().
Click to reveal solution
Explanation: theme_void() is more aggressive than theme_minimal(): it removes EVERY non-data element, including axes, ticks, labels, and the panel background. Use it for sparklines, map insets, or composite figures where surrounding axes would compete with the data. If you only need to drop ONE element, prefer overriding it inside theme() rather than starting from theme_void() and adding everything back.
Section 2. Text, font, and axis customization (3 problems)
Exercise 2.1: Rotate long x-axis labels 45 degrees for a category bar chart
Task: A marketing analyst built a bar chart of average cty mileage by manufacturer from mpg, but the manufacturer labels overlap and become unreadable. Rotate the x-axis text to 45 degrees with right-justified anchoring inside theme() and save the corrected plot to ex_2_1.
Expected result:
#> Bar chart of average cty by manufacturer. x-axis labels rotated 45 degrees, top-right corner of each label sits flush against its tick.
Difficulty: Intermediate
Rotating tick labels alone leaves them centered on the tick; you also need to re-anchor where the label edge meets its tick.
Inside theme(), set axis.text.x = element_text(angle = 45, hjust = 1).
Click to reveal solution
Explanation: angle = 45 rotates the text counter-clockwise, but rotation alone leaves the labels centered on the tick, causing them to drift away. hjust = 1 re-anchors the right edge of the label to the tick mark, which is the visual contract readers expect. If you rotate 90 degrees instead, use vjust = 0.5 to recenter vertically.
Exercise 2.2: Bold axis titles and increase base font size for a poster print
Task: A statistician is preparing a ChickWeight plot for a conference poster and needs all axis titles in bold and the overall font scaled up. Build a line plot of weight over Time colored by Diet, then customize axis.title to be bold and apply theme_minimal(base_size = 16). Save to ex_2_2.
Expected result:
#> Line plot of weight by Time, four colored lines for Diet. All text is larger than default, axis titles "Time" and "weight" rendered in bold.
Difficulty: Intermediate
One theme argument scales every text element at once; bolding the axis titles is a separate per-element override applied last.
Use theme_minimal(base_size = 16) then theme(axis.title = element_text(face = "bold")).
Click to reveal solution
Explanation: base_size is a single argument that scales every text element proportionally, which is the right knob for poster vs slide vs paper figures. Applying theme_minimal(base_size = 16) BEFORE the theme() override matters: a built-in theme always wipes whatever you set previously, so customizations must come last. face accepts "plain", "italic", "bold", or "bold.italic".
Exercise 2.3: Color and bold an individual axis title for emphasis
Task: An ops engineer building a status dashboard wants the y-axis title "Unemployed (thousands)" to render in dark red and bold to flag it as the metric of interest, while leaving the x-axis title alone. Plot economics$unemploy over date as a line and configure only axis.title.y. Save to ex_2_3.
Expected result:
#> Line plot of unemploy over date. y-axis title shown in dark red, bold face. x-axis title in default black non-bold.
Difficulty: Intermediate
Target the single child element for the y-axis title so the x-axis title stays at its default look.
Set axis.title.y = element_text(color = "darkred", face = "bold") inside theme().
Click to reveal solution
Explanation: axis.title is the parent element; axis.title.x and axis.title.y let you set each independently. The same hierarchical pattern applies to axis.text.x/axis.text.y and axis.ticks.x/axis.ticks.y. Override the most specific level you need; touching the parent affects both axes, which is rarely what you want when emphasizing a single metric.
Section 3. Legend placement and styling (3 problems)
Exercise 3.1: Move the legend to the bottom and drop its title
Task: A reporting analyst is squeezing a diamonds density plot into a narrow column and needs the legend below the panel, not on the right where it eats horizontal space. Build a geom_density() of price filled by cut, move the legend to the bottom, and drop the legend title via legend.title = element_blank(). Save to ex_3_1.
Expected result:
#> Density plot of price filled by cut. Legend strip sits below the x-axis. No "cut" label above the legend keys.
Difficulty: Beginner
One theme setting relocates the legend below the panel; another erases the legend's title text.
In theme(), set legend.position = "bottom" and legend.title = element_blank().
Click to reveal solution
Explanation: legend.position accepts "top", "bottom", "left", "right", "none", or a numeric vector like c(0.8, 0.2) for inside-the-panel placement using normalized coordinates. element_blank() is the universal "delete this element" function and is preferable to setting color = NA because it also collapses the space the element would have occupied.
Exercise 3.2: Tighten legend key size and add a panel-style background
Task: A geneticist preparing a clinical heatmap legend wants compact keys with a light gray background and a thin black border so the legend reads as its own self-contained unit. Build a scatter of mpg$hwy vs displ colored by class, set legend.key.size to 0.5 cm and configure legend.background. Save to ex_3_2.
Expected result:
#> Scatter colored by class. Legend keys ~0.5 cm tall, the legend sits in a light gray rounded-ish box with a thin black border.
Difficulty: Intermediate
Compact keys are a sizing measurement; the framed look comes from styling the legend's rectangular background separately.
Set legend.key.size = unit(0.5, "cm") and legend.background = element_rect(fill = "gray95", color = "black", linewidth = 0.3).
Click to reveal solution
Explanation: unit() from the grid package controls measurement. cm, in, mm, pt, and lines are common units; lines scales with the base font size, which is the safest choice if your theme size changes. element_rect() is the rectangular-element constructor used for backgrounds, panels, and strips, and linewidth (not size) is the modern argument for border thickness since ggplot2 3.4.
Exercise 3.3: Hide the legend completely when color is self-explanatory
Task: A scout has a ChickWeight plot where each Chick is its own line and the line color is purely decorative since chicks are unnamed. Hide the legend entirely with legend.position = "none" and save the cleaned-up plot to ex_3_3.
Expected result:
#> Spaghetti plot of weight vs Time, lines colored by Chick. No legend on the right, plot panel fills the whole figure width.
Difficulty: Beginner
There is a single legend-position value that suppresses the legend for every aesthetic at once.
Set legend.position = "none" inside theme().
Click to reveal solution
Explanation: Setting legend.position = "none" is the canonical way to suppress the legend for ALL aesthetics. To hide just ONE legend while keeping others, use guides(color = "none") instead, scoped to the specific aesthetic. The third option is scale_color_discrete(guide = "none") which is the most local. Pick the narrowest one that does the job.
Section 4. Gridlines and panel customization (3 problems)
Exercise 4.1: Drop minor gridlines and keep only major y-gridlines
Task: An audit team reviewing a txhousing median-sales line plot finds the minor gridlines distracting and wants major y-gridlines only. Drop panel.grid.minor entirely and remove panel.grid.major.x so vertical major lines also disappear, leaving horizontal majors as the only grid. Save to ex_4_1.
Expected result:
#> Line plot of median over date. Horizontal gridlines visible at major y-ticks. No vertical gridlines anywhere. No minor gridlines either.
Difficulty: Intermediate
Gridlines follow a parent-child hierarchy; remove the minor set entirely, then the vertical half of the major set.
In theme(), set panel.grid.minor = element_blank() and panel.grid.major.x = element_blank().
Click to reveal solution
Explanation: Gridlines follow the parent-child hierarchy: panel.grid covers all four, panel.grid.major and panel.grid.minor cover the two halves, and the .x/.y suffix targets a single axis direction. When you want a horizontal-only grid (common in dashboards for reading time-series values), the combination of "kill minor entirely, kill major-x" is the cleanest path. Avoid panel.grid.major.x = element_line(color = NA): the element still occupies space and can affect spacing math.
Exercise 4.2: Switch panel background to off-white for a slide deck theme
Task: A growth team is recoloring a mpg bubble chart for a slide deck where the slide background is off-white (#FAF7F2) and the panel must match so the chart blends in. Set both panel.background and plot.background to that hex value, drop all gridlines, and save to ex_4_2.
Expected result:
#> Bubble chart of hwy vs displ sized by cty. Both inside-the-axes panel and outside-the-axes plot background are off-white #FAF7F2. No gridlines visible.
Difficulty: Intermediate
The region inside the axes and the region outside it are two separate elements - recolor both so no seam shows.
Set panel.background and plot.background to element_rect(fill = "#FAF7F2", color = NA), and panel.grid = element_blank().
Click to reveal solution
Explanation: panel.background is the region INSIDE the axes; plot.background is the region OUTSIDE the axes including the margins. Setting only one causes a visible color boundary where the panel meets the margin, which looks broken on a slide. Always set both to the same color when matching a slide template. color = NA removes the border line that element_rect() would otherwise draw at full color.
Exercise 4.3: Replace the gray panel with a black panel border for a journal figure
Task: A biostatistician submitting to a journal needs a strict figure style: white panel, no gridlines, and a thin black panel border. Build a scatter of iris$Sepal.Length vs Petal.Length colored by Species, start from theme_bw(), then override panel.grid and panel.border. Save to ex_4_3.
Expected result:
#> Scatter of Sepal.Length vs Petal.Length, three Species colors. White panel inside a thin black rectangle. No gridlines. Axis ticks visible.
Difficulty: Advanced
Starting from a theme that already ships a black border, clear the grid and redraw the border at the thickness you want.
Set panel.grid = element_blank() and panel.border = element_rect(color = "black", fill = NA, linewidth = 0.5).
Click to reveal solution
Explanation: Starting from theme_bw() gives you the black border for free, but the border element it ships with is sometimes too thick or the wrong color for a target journal. Re-declaring panel.border with explicit fill = NA is critical: if you forget fill = NA, the panel rectangle paints OVER your data points. fill = NA means "no fill, only outline", which is the contract for rectangular borders in ggplot2.
Section 5. Titles, captions, and margins (5 problems)
Exercise 5.1: Center the plot title and set it to bold 16pt
Task: A finance team prep for a monthly memo wants the chart title centered and visually dominant. Build any line plot of AirPassengers converted to a tibble (provided), add a labs(title = ...), then set plot.title to bold 16pt with horizontal-just 0.5 so it sits over the middle of the plot. Save to ex_5_1.
Expected result:
#> Line plot of AirPassengers monthly counts. Title "Monthly air passengers, 1949-1960" rendered bold and centered over the plot panel.
Difficulty: Beginner
Centering a title is a horizontal-justification value; bold and size are separate text properties on the same element.
Set plot.title = element_text(face = "bold", size = 16, hjust = 0.5) inside theme().
Click to reveal solution
Explanation: By default ggplot2 left-aligns titles, which is a deliberate choice for editorial style: the title aligns with the first axis tick. hjust = 0.5 centers the title over the entire plot area (not the panel) but it is a strong convention in business reports and slides. Use hjust = 0 for newsletter style or hjust = 1 to right-align under the legend.
Exercise 5.2: Style title, subtitle, and caption together for a publication
Task: A climatologist publishing a co2 trend chart needs the title bold size 14, subtitle in italic gray, and the caption in small light gray right-aligned. Build the chart with all three labels via labs(), then customize all three corresponding theme elements. Save to ex_5_2.
Expected result:
#> Line plot of co2 over time. Bold title at top. Italic gray subtitle just below. Small light gray caption "Source: Mauna Loa" right-aligned at the bottom.
Difficulty: Intermediate
Title, subtitle, and caption are three sibling elements you can style independently for a typographic hierarchy.
In theme(), set plot.title, plot.subtitle, and plot.caption each with element_text() (give the caption hjust = 1).
Click to reveal solution
Explanation: plot.title, plot.subtitle, and plot.caption are siblings under the plot.* namespace and each can be customized independently. A consistent typographic hierarchy (bold title, italic subtitle, small caption) is what separates dashboard-quality figures from script output. The caption is right-aligned by convention in journalism (think NYT charts), left-aligned in academic style.
Exercise 5.3: Tighten plot.margin for a dense dashboard layout
Task: A SRE building a Grafana-style ops dashboard needs to pack multiple mpg charts into one row with minimal whitespace. Override plot.margin to 2 mm on all four sides instead of the default 5.5 pt and save the resulting compact scatter to ex_5_3.
Expected result:
#> Scatter plot of hwy vs displ. The visible white space between the figure edge and the axis labels is noticeably tighter than default, ~2 mm on every side.
Difficulty: Intermediate
The whitespace around the whole figure is a single theme element measured on all four sides.
Set plot.margin = margin(2, 2, 2, 2, "mm") inside theme().
Click to reveal solution
Explanation: margin() takes top, right, bottom, left, unit in that order, mirroring CSS. The default is margin(5.5, 5.5, 5.5, 5.5, "pt"). Shrinking to 2 mm gains real estate in dashboard tiles where every pixel counts, but be careful: tight margins can cut off rotated axis labels or long y-axis titles. For dashboard composition, also consider patchwork::wrap_plots() which handles margin coordination across panels.
Exercise 5.4: Apply a project-wide theme via theme_set then restore the default
Task: A platform engineer wants every subsequent chart in a notebook to use theme_minimal(base_size = 13) without retyping it on each plot. Use theme_set() to install that as the global default, build a quick iris scatter to confirm, capture it as ex_5_4, then reset with theme_set(theme_gray()) so other code is unaffected.
Expected result:
#> Scatter of iris Sepal.Length vs Petal.Length with the minimal theme applied automatically (no theme_minimal() in the plot call). Base size visibly larger than default.
Difficulty: Advanced
Installing a notebook-wide default mutates global state, and that change hands back the previous value so you can undo it.
Call theme_set(theme_minimal(base_size = 13)), capture its return value, build the plot, then theme_set() the old value back.
Click to reveal solution
Explanation: theme_set() mutates global state and RETURNS the previous theme, which is the idiomatic R pattern for reversible configuration (same shape as par()). Always capture the return value so you can restore on exit, especially in shared notebooks or package code. For a strict restore guarantee, wrap the body in on.exit(theme_set(old_theme)) inside a function. The companion functions are theme_update() (modify the current default in place) and theme_replace() (replace specific elements only).
Exercise 5.5: Add a tag label in the top-left corner for figure numbering
Task: A pharmacology team submitting a multi-panel manuscript figure needs a bold "A" tag in the top-left corner of one panel to identify it as Panel A in the figure caption. Build a ToothGrowth boxplot, add labs(tag = "A"), then style plot.tag to be bold and slightly larger. Save to ex_5_5.
Expected result:
#> Boxplot of len by dose. Bold letter "A" rendered in the top-left corner outside the plot panel, larger than body text.
Difficulty: Intermediate
The figure-numbering label is its own dedicated theme element, distinct from the plot title.
Set plot.tag = element_text(face = "bold", size = 14) inside theme().
Click to reveal solution
Explanation: plot.tag is a less-known theme element designed exactly for multi-panel figure labels in scientific publishing. Its default position is the top-left corner via plot.tag.position = "topleft". You can also pass a numeric c(x, y) in normalized panel coordinates for fine-grained control. Composing with patchwork is a more powerful path when you have many panels: the plot_annotation(tag_levels = "A") argument auto-generates A, B, C, ...
Section 6. Reusable custom themes (3 problems)
Exercise 6.1: Modify theme_minimal in place with %+replace% for a quick rebrand
Task: A reporting analyst inherits a mpg chart styled with theme_minimal() but the brand now requires a serif font everywhere and no panel gridlines. Use the %+replace% operator to create a new theme that starts from theme_minimal() and overrides text family and gridlines in one expression. Save the styled plot to ex_6_1.
Expected result:
#> Scatter of hwy vs displ. All text rendered in serif font. No gridlines on the panel. Otherwise looks like theme_minimal.
Difficulty: Intermediate
You want the operator that wholesale-replaces named elements rather than merging properties into them.
Inside the %+replace% theme(...) call, set text = element_text(family = "serif") and panel.grid = element_blank().
Click to reveal solution
Explanation: The %+replace% operator REPLACES specified elements wholesale, where + only MERGES the changed properties into the existing element. Pick %+replace% when you want a clean slate per element (no leftover italic/bold from the parent theme); pick + when you only want to tweak a property like color without losing the size or face that the parent theme defined. Both operators build up reusable theme objects you can + onto any plot.
Exercise 6.2: Wrap a corporate color palette into a reusable theme function
Task: A platform engineer rolling out a company style guide needs a theme_corporate() function that takes a base_size argument and returns a theme with a light gray panel, navy axis titles, no minor gridlines, and serif font. Write the function, then apply it to a diamonds boxplot at base_size = 12 and save to ex_6_2.
Expected result:
#> Boxplot of diamonds price by cut. Light gray panel inside the axes. Axis titles "cut" and "price" rendered in navy serif font. Only major gridlines visible.
Difficulty: Advanced
Return a theme object from the function so every chart reuses one styled definition, and thread the size argument down to the base theme.
In the body, return theme_minimal(base_size = base_size) %+replace% theme(panel.background = element_rect(fill = "gray95", color = NA), axis.title = element_text(color = "navy", family = "serif"), panel.grid.minor = element_blank(), text = element_text(family = "serif")).
Click to reveal solution
Explanation: Wrapping theme customization in a function gives you a single source of truth: change theme_corporate() once and every chart that uses it updates. Always pass base_size through to the underlying base theme (theme_minimal() here) so callers can scale text for slides vs print without rewriting. Production teams often expose base_family and accent_color as additional arguments for further parameterization.
Exercise 6.3: Build a dark-mode theme for embedded slide backgrounds
Task: A presentation designer wants a dark theme: near-black panel and plot backgrounds, light gray text, and white gridlines at 10 percent opacity. Wrap the customization in a function called theme_dark_slide(), apply it to a faithful density plot, and save to ex_6_3.
Expected result:
#> Density plot of eruptions duration. Dark near-black background covering both panel and plot regions. Light gray axis text and titles. Faint white gridlines visible against the dark backdrop.
Difficulty: Advanced
A dark theme recolors both background regions and the text, then ghosts the gridlines down to a low opacity.
Return a %+replace% theme(...) setting plot.background and panel.background to element_rect(fill = "gray10", color = NA), text and axis.text to light gray, and panel.grid = element_line(color = alpha("white", 0.1)).
Click to reveal solution
Explanation: Dark themes need careful contrast: pure black (#000000) is harsh on screen, so gray10 reads softer while still feeling dark. The alpha() helper from scales ranges 0 to 1; gridlines at 0.1 alpha give a "ghost grid" effect that orients the eye without competing with data. Always recolor text AND axis.text separately: text is the parent for axis.title and labels but ggplot2 quirks mean axis.text sometimes needs its own override to fully invert.
What to do next
- Re-style any of these plots with ggplot2 Customization Exercises covering color, scales, and annotations.
- Practice axis ranges and breaks with ggplot2 Axes Exercises in R.
- Pair theme styling with multi-panel layouts in ggplot2 Facets Exercises in R.
- Reread the parent walkthrough at ggplot2 Themes in R to revisit the theme element hierarchy.
r-statistics.co · Verifiable credential · Public URL
This document certifies mastery of
ggplot2 Themes Mastery
Every certificate has a public verification URL that proves the holder passed the assessment. Anyone with the link can confirm the recipient and date.
197 learners have earned this certificate