Advanced ggplot2 Geoms: Box, Violin, and Density Plots in R
Advanced ggplot2 geoms let you move beyond scatter plots and bar charts to reveal distribution shapes, compare groups, and layer multiple visual encodings. In this tutorial, you will learn how to create box plots, violin plots, density visualizations, and multi-layer charts that expose patterns invisible in basic plots.
Box plots: summarizing distributions
Box plots (box-and-whisker plots) provide a compact way to compare distributions across categories. They show the median, quartiles, and potential outliers at a glance.
library(ggplot2)
library(dplyr)
# Use the mpg dataset for examples
glimpse(mpg)
#> Rows: 234
#> Columns: 11
#> $ manufacturer: chr "audi" "audi" "audi" ...
#> $ model : chr "a4" "a4" "a4" ...
#> $ displ : num 1.8 1.8 2 2 2.2 2.2 2.5 2.5 2.5 2.5 ...
#> $ year : int 1999 1999 1999 1999 1999 1999 ...
#> $ cyl : int 4 4 4 4 4 4 4 4 4 4 ...
#> $ trans : chr "auto(l5)" "manual(m5)" "manual(m5)" ...
#> $ drv : chr "f" "f" "f" "f" "f" ...
#> $ cty : int 18 21 20 21 22 23 24 25 26 26 ...
#> $ hwy : int 25 28 27 28 29 31 32 31 30 29 ...
#> $ fl : chr "p" "p" "p" "p" "p" ...
#> $ class : chr "compact" "compact" "compact" ...
# Basic box plot: highway mpg by number of cylinders
ggplot(mpg, aes(x = factor(cyl), y = hwy)) +
geom_boxplot() +
labs(
title = "Highway MPG by Cylinder Count",
x = "Number of Cylinders",
y = "Highway Miles Per Gallon"
) +
theme_minimal()
The box represents the interquartile range (IQR), the middle 50% of your data. The line inside is the median. Whiskers extend to 1.5 × IQR, and points beyond are potential outliers.
Horizontal box plots and customization
Rotate your box plot for better label readability or add notchs to compare medians:
# Horizontal box plot with notches
ggplot(mpg, aes(y = factor(cyl), x = hwy)) +
geom_boxplot(notch = TRUE, fill = "steelblue", alpha = 0.7) +
labs(
title = "Highway MPG by Cylinders (Horizontal)",
y = "Number of Cylinders",
x = "Highway Miles Per Gallon"
) +
theme_minimal()
Notches that don’t overlap between boxes suggest statistically different medians.
Violin plots: distribution density
Violin plots combine box plots with kernel density estimation, revealing the full shape of distributions, bimodal, skewed, or uniform, that box plots obscure.
# Violin plot: highway mpg by vehicle class
ggplot(mpg, aes(x = class, y = hwy)) +
geom_violin(fill = "coral", alpha = 0.7) +
labs(
title = "Highway MPG Distribution by Vehicle Class",
x = "Vehicle Class",
y = "Highway MPG"
) +
theme_minimal() +
coord_flip()
Combining violin with box plots
Overlay a box plot inside the violin for the best of both worlds:
# Violin + box plot combination
ggplot(mpg, aes(x = class, y = hwy, fill = class)) +
geom_violin(alpha = 0.5) +
geom_boxplot(width = 0.2, fill = "white", alpha = 0.8) +
labs(
title = "Highway MPG: Distribution Shape + Summary",
x = "Vehicle Class",
y = "Highway MPG"
) +
theme_minimal() +
theme(legend.position = "none")
Density plots: smooth distribution visualization
For continuous variables, density plots show probability density without binning artifacts:
# Overlapping density plots by category
ggplot(mpg, aes(x = hwy, fill = drv)) +
geom_density(alpha = 0.5) +
labs(
title = "Highway MPG Distribution by Drive Type",
x = "Highway MPG",
fill = "Drive Type"
) +
theme_minimal()
2D density plots
Visualize the relationship between two continuous variables with contour or raster density:
# 2D density contour
ggplot(mpg, aes(x = displ, y = hwy)) +
geom_density_2d() +
labs(
title = "Engine Displacement vs Highway MPG",
x = "Engine Displacement (L)",
y = "Highway MPG"
) +
theme_minimal()
# Or use filled contours for easier reading
ggplot(mpg, aes(x = displ, y = hwy)) +
geom_density_2d_filled() +
theme_minimal()
Jitter plots: reducing overplotting
When you have many points, jittering adds random noise to reduce overlap:
# Jitter plot with median line
ggplot(mpg, aes(x = class, y = hwy, color = class)) +
geom_jitter(alpha = 0.6, width = 0.2) +
stat_summary(fun = median, geom = "line", color = "black",
aes(group = 1), linewidth = 1) +
theme_minimal() +
theme(legend.position = "none")
Scatter plots with smoothing
The geom_smooth() layer adds trend lines with confidence intervals:
# Scatter with smoothing splines
ggplot(mpg, aes(x = displ, y = hwy)) +
geom_point(alpha = 0.5) +
geom_smooth(method = "loess", se = TRUE, color = "red") +
labs(
title = "Engine Size vs Highway MPG with Trend",
x = "Engine Displacement (L)",
y = "Highway MPG"
) +
theme_minimal()
Choose methods: "lm" for linear, "loess" for local regression, "gam" for generalized additive models.
Multi-Layer visualizations
Combine multiple geoms for rich, informative graphics:
# Publication-ready multi-layer plot
ggplot(mpg, aes(x = displ, y = hwy, color = drv, shape = factor(cyl))) +
geom_point(size = 2, alpha = 0.7) +
geom_smooth(method = "lm", se = FALSE, linewidth = 1) +
facet_wrap(~class, ncol = 3) +
labs(
title = "Highway MPG Analysis by Class",
subtitle = "Engine displacement vs highway mileage",
x = "Engine Displacement (L)",
y = "Highway MPG",
color = "Drive Type",
shape = "Cylinders"
) +
theme_minimal() +
theme(
plot.title = element_text(face = "bold", size = 14),
legend.position = "bottom"
)
Error bars: showing uncertainty
Add error bars to bar charts or point plots to communicate variability:
# Bar chart with error bars
summary_mpg <- mpg %>%
group_by(class) %>%
summarise(
mean_hwy = mean(hwy),
sd_hwy = sd(hwy),
n = n(),
se = sd_hwy / sqrt(n)
)
ggplot(summary_mpg, aes(x = class, y = mean_hwy, fill = class)) +
geom_bar(stat = "identity", alpha = 0.7) +
geom_errorbar(
aes(ymin = mean_hwy - se, ymax = mean_hwy + se),
width = 0.3
) +
labs(
title = "Mean Highway MPG by Class with Standard Error",
x = "Vehicle Class",
y = "Mean Highway MPG"
) +
theme_minimal() +
theme(legend.position = "none", axis.text.x = element_text(angle = 45))
Statistical geoms
geom_smooth(method = "loess") adds a smoothed conditional mean with confidence interval. method = "lm" fits a linear regression line. geom_density(aes(fill = group), alpha = 0.5) overlays kernel density estimates for multiple groups. geom_violin() combines density with the box plot shape. geom_dotplot(binaxis = "y", stackdir = "center") stacks points to show raw data distribution.
Uncertainty visualization
geom_errorbar(aes(ymin = mean - sd, ymax = mean + sd)) draws error bars. geom_ribbon(aes(ymin, ymax), alpha = 0.3) shades a confidence band around a line. geom_pointrange(aes(ymin, ymax)) combines a point with a range, compact for showing point estimates with intervals. For Bayesian posterior distributions, ggdist::stat_halfeye() renders half-eye plots showing the posterior density and interval.
Heatmaps and tile plots
geom_tile(aes(x = col, y = row, fill = value)) creates a heatmap from a tidy data frame. Pair with scale_fill_viridis_c() for perceptually uniform color scales. geom_raster() is faster than geom_tile() for regular grids (no variable tile sizes). coord_equal() ensures square tiles. theme(axis.text.x = element_text(angle = 45, hjust = 1)) rotates x-axis labels for long category names.
Geographic maps
geom_sf() renders sf spatial objects in ggplot2. Combine a polygon layer (countries, states) with a point layer (locations): ggplot() + geom_sf(data = world) + geom_sf(data = cities, aes(color = population)). coord_sf(xlim = c(-10, 40), ylim = c(35, 70)) zooms to a region. scale_fill_distiller() applies diverging color scales to choropleth maps.
Annotation and text geoms
geom_text() adds text at positions mapped to x and y aesthetics. The label aesthetic provides the text. hjust and vjust control horizontal and vertical alignment (0 = left/bottom, 0.5 = center, 1 = right/top). For overlapping text, ggrepel::geom_text_repel() and ggrepel::geom_label_repel() automatically move labels to avoid overlap, which is essential for scatter plots with many labeled points.
geom_label() is like geom_text() but draws a filled rectangle behind the text. Useful when labels need to stand out against a busy background. Control the fill and label padding with fill and label.padding.
annotate() adds single annotations, text, rectangles, arrows, using literal coordinates rather than data aesthetics. annotate("text", x = 5, y = 20, label = "Outlier") places text at a fixed position. annotate("rect", xmin = 2, xmax = 4, ymin = 0, ymax = 30, alpha = 0.2) draws a shaded rectangle to highlight a region.
Ribbon, area, and step geoms
geom_ribbon(aes(ymin = lower, ymax = upper)) fills the area between two y boundaries at each x. This is the standard way to show confidence intervals alongside a line. Pair it with geom_line() on the same data, with the ribbon drawn first so the line appears on top.
geom_area() is equivalent to geom_line() with the area between the line and zero filled. geom_area(position = "stack") stacks multiple series, producing a stacked area chart. position = "fill" normalizes to 100%, showing proportions over time.
geom_step() connects points with horizontal then vertical segments (or the reverse), creating a step function appearance. It is the natural geom for survival curves, empirical CDFs, and histograms built from pre-computed bin values.
Spatial geoms
geom_sf() renders sf spatial objects, points, lines, polygons. It works like other geoms but handles the CRS and coordinate transformation automatically. Add coord_sf(crs = st_crs(4326)) to set the map projection. Stack multiple geom_sf() layers to overlay points on polygons on a base map.
geom_sf_text() and geom_sf_label() place text at the centroid of each feature, with the same options as geom_text() and geom_label().
For raster data, tidyterra::geom_spatraster() renders a terra SpatRaster within ggplot2, and scale_fill_viridis_c() applies a continuous color scale to the cell values.
Custom statistical geoms
stat_smooth() and geom_smooth() are the same layer (stat computes, geom renders). The method argument controls the smoother: "lm" (linear), "loess" (local polynomial), "gam" (GAM from mgcv), or a custom function. formula = y ~ poly(x, 2) specifies a quadratic fit within method = "lm". se = FALSE hides the confidence band.
stat_summary() aggregates y values at each x and draws the result. stat_summary(fun = mean, geom = "point") draws the group mean as a point. stat_summary(fun.data = mean_cl_normal, geom = "errorbar") draws mean plus normal confidence interval errorbars. This is more flexible than precomputing summaries because the aggregation respects ggplot’s grouping aesthetics.
stat_density_2d() computes and renders 2D kernel density estimates. geom_density_2d() draws contour lines; stat_density_2d(aes(fill = ..density..), geom = "raster") draws a smooth heatmap. Pair with geom_point(alpha = 0.3) to show both the raw data and the density estimate.
Specialized distribution geoms
geom_violin() shows the distribution shape as a mirrored kernel density estimate. Set trim = FALSE to extend the violin tails beyond the data range. geom_boxplot() inside a violin (with width = 0.1) adds a narrow boxplot summary on top of the distribution shape.
geom_dotplot() places one dot per observation, stacking dots that fall in the same bin. Unlike a histogram, it preserves the individual data point count. Set binaxis = "y" and stackdir = "center" for a vertical orientation useful with geom_violin().
ggdist::stat_halfeye() is a popular extension for Bayesian analysis visualizations. It draws a probability density half-violin with a dot interval (median and credible intervals) overlaid. It works with raw samples from posterior distributions and is the standard visualization in brms and tidybayes workflows.
Beyond the standard geometries
ggplot2 includes the most common geometries: points, lines, bars, histograms, boxes. Advanced visualization needs often require less common geometries that are included in ggplot2 but less frequently documented, or geometries from extension packages that add specialized chart types. Understanding the full range of available geometries expands what you can communicate with data.
The choice of geometry should be driven by the data structure and the comparison you want to make. Geoms that display distributions, violin, ridgeline, density, show more information than those that show only summaries. Geoms that show relationships — scatter with smooths, connected line, area — communicate different aspects than those that show only one variable. Choosing the right geometry for the analytical question is as important as the data mapping.
Statistical geometry variants
Many ggplot2 geoms have siblings that apply statistical transformations. geom_smooth fits a model to the data and draws the fitted curve with a confidence band. geom_density estimates and draws a kernel density. geom_bin2d bins two-dimensional data and draws a heatmap of counts. These stat-based geoms wrap the common pattern of applying a transformation and then drawing a geometry.
Customizing the underlying statistical transformation often produces more informative visualizations. Changing geom_smooth’s method from the default to a specific model — linear, logistic, polynomial — controls what relationship is displayed. Adjusting the bandwidth in geom_density controls the smoothness of the estimated distribution. These parameters expose the assumptions built into the visualization.
Key takeaways
- Box plots reveal median, quartiles, and outliers, ideal for comparing categories
- Violin plots show distribution shape, especially useful for bimodal data
- Density plots visualize continuous distributions, with 2D versions for bivariate analysis
- Jitter plots reduce overplotting while showing individual points
- geom_smooth() adds trend lines with confidence intervals
- Combine geoms for maximum insight, violin + box, points + smooth
Master these advanced geometries, and you’ll communicate data patterns that basic charts cannot reveal.
Next steps
Now that you understand advanced geoms in ggplot2, explore these related topics to deepen your knowledge and apply these techniques in more complex scenarios.