Cookbook

Every exported function and every option that changes the output, as worked examples. Each figure below was rendered by ggnext when this page was built, and several sections check the computed numbers against base R.

This page is generated from inst/examples/ggnext-full-reference.Rmd, which ships with the package. Open it in RStudio to run any of it yourself: system.file("examples", "ggnext-full-reference.Rmd", package = "ggnext")

Sections
  1. 1. The grammar
  2. 2. Essential geoms
  3. 3. Distributions
  4. 4. Scales and axes
  5. 5. Coordinates
  6. 6. Facets
  7. 7. Titles, labels and themes
  8. 8. Layout geoms
  9. 9. Machine-learning geoms
  10. 10. Clinical geoms
  11. 11. Interactivity and animation
  12. 12. Exact data export
  13. 13. Rendering and the logo
  14. 14. Extending the package
  15. 15. Error handling
  16. 16. Edge cases
  17. 17. Session information

This document exercises every exported function of ggnext and every option that changes what is drawn. It doubles as a regression check: if any layer, scale, coordinate system or theme setting breaks, this document fails to knit.

Writing a plot object in a chunk renders it inline — ggnext registers a knit_print() method, so no results='asis' boilerplate is needed.

packageVersion("ggnext")
#> [1] '0.1.0'
length(getNamespaceExports("ggnext"))
#> [1] 154

1. The grammar

1.1 Minimal plot

A plot is data, an aesthetic mapping, and at least one layer.

ggnext(cars, aes(speed, dist)) + geom_point()
5 10 15 20 25 0 20 40 60 80 100 120 speed dist

1.2 Plots are immutable values

+ returns a new plot, so a partial specification is a reusable template.

base <- ggnext(iris, aes(Sepal.Length, Sepal.Width))
length(base@layers)            # still empty
#> [1] 0
length((base + geom_point())@layers)
#> [1] 1
length(base@layers)            # base is unchanged
#> [1] 0

1.3 aes() — mapping vs setting

aes(speed, dist, color = gear)
#> <ggnext aesthetic mapping>
#>   x -> speed
#>   y -> dist
#>   color -> gear

A constant inside aes() that names a real colour is honoured literally rather than being treated as a one-level category.

ggnext(cars, aes(speed, dist, color = "steelblue")) + geom_point(size = 4)
5 10 15 20 25 0 20 40 60 80 100 120 speed dist

1.4 Layer-level data and mapping

A layer can override both, which is how you annotate one plot with a second dataset.

means <- aggregate(Sepal.Length ~ Species, iris, mean)

ggnext(iris, aes(Species, Sepal.Length)) +
  geom_jitter(alpha = 0.3) +
  geom_point(aes(Species, Sepal.Length), data = means,
             color = "#C1462F", size = 7)
setosa versicolor virginica 5 6 7 8 Species Sepal.Length

1.5 Output size

p_wide <- ggnext(cars, aes(speed, dist), width = 900, height = 260) +
  geom_point()
p_wide
5 10 15 20 25 0 20 40 60 80 100 120 speed dist

plot_size() does the same thing after the fact.

b <- ggnext:::build_geometry(ggnext(cars, aes(speed, dist)) +
                                geom_point() + plot_size(800, 600))
c(width = b$width, height = b$height)
#>  width height 
#>    800    600

2. Essential geoms

2.1 geom_point

ggnext(cars, aes(speed, dist)) + geom_point(color = "#2B6BE0", size = 4,
                                             alpha = 0.7)
5 10 15 20 25 0 20 40 60 80 100 120 speed dist

Size and colour can be mapped instead of set:

ggnext(iris, aes(Sepal.Length, Sepal.Width,
                  color = Species, size = Petal.Length)) +
  geom_point(alpha = 0.75)
5 6 7 8 2 2.5 3 3.5 4 4.5 Sepal.Length Sepal.Width Species setosa versicolor virginica

2.2 geom_jitter

ggnext(iris, aes(Species, Sepal.Width, color = Species)) +
  geom_jitter(alpha = 0.7) +
  theme(legend_position = "none")
setosa versicolor virginica 2 2.5 3 3.5 4 4.5 Species Sepal.Width

Jitter is seeded, so the same plot renders identically every time — and it restores the caller’s random stream:

set.seed(42); before <- .Random.seed
invisible(render(ggnext(iris, aes(Species, Sepal.Width)) + geom_jitter()))
identical(.Random.seed, before)
#> [1] TRUE

2.3 geom_line, geom_path, geom_step

ts <- data.frame(
  t = rep(1:12, 2),
  v = c(cumsum(rnorm(12, 2)), cumsum(rnorm(12, 1))),
  g = rep(c("A", "B"), each = 12)
)
ggnext(ts, aes(t, v, color = g)) + geom_line(linewidth = 2) + theme_minimal()
2 4 6 8 10 12 0 10 20 30 t v g A B

geom_path() follows data order rather than x order; geom_step() holds each value until the next observation.

steps <- data.frame(t = 1:8, v = c(2, 2, 3, 3, 5, 4, 4, 6))
ggnext(steps, aes(t, v)) + geom_step(color = "#12A594", linewidth = 2) +
  geom_point() + theme_minimal()
2 4 6 8 2 3 4 5 6 t v

Dashed lines via dash (an SVG dash pattern):

ggnext(steps, aes(t, v)) + geom_line(dash = "6,4") + theme_minimal()
2 4 6 8 2 3 4 5 6 t v

2.4 geom_area and geom_ribbon

ggnext(data.frame(t = 1:10, v = c(2, 4, 3, 6, 8, 7, 9, 8, 11, 13)),
        aes(t, v)) +
  geom_area(alpha = 0.45) + theme_minimal()
2 4 6 8 10 0 5 10 t v
band <- data.frame(t = 1:12, lo = (1:12) * 0.8, hi = (1:12) * 1.4)
ggnext(band, aes(t, ymin = lo, ymax = hi)) +
  geom_ribbon(alpha = 0.4) + theme_minimal()
2 4 6 8 10 12 5 10 15 t y

2.5 geom_segment and geom_dumbbell

seg <- data.frame(x = 1:4, y = c(2, 4, 3, 5),
                  xe = (1:4) + 0.7, ye = c(4, 6, 2, 7))
ggnext(seg, aes(x, y, xend = xe, yend = ye)) +
  geom_segment(linewidth = 2) + theme_minimal()
1 2 3 4 2 3 4 5 6 7 x y
db <- data.frame(g = c("North", "South", "East", "West"),
                 before = c(12, 18, 9, 14), after = c(19, 21, 15, 13))
ggnext(db, aes(before, xend = after, y = g)) +
  geom_dumbbell() + theme_minimal()
10 15 20 East North South West before g

2.6 Reference lines

geom_hline() and geom_vline() span the panel and ignore the plot’s aes(), so they never disturb the data mapping.

ggnext(cars, aes(speed, dist)) +
  geom_point(alpha = 0.6) +
  geom_hline(mean(cars$dist), dash = "5,4", color = "#C1462F") +
  geom_vline(mean(cars$speed), dash = "5,4", color = "#C1462F") +
  theme_minimal()
5 10 15 20 25 0 20 40 60 80 100 120 speed dist

2.7 geom_text

lab <- data.frame(x = c(1, 2, 3), y = c(3, 1, 2),
                  l = c("alpha", "beta", "gamma"))
ggnext(lab, aes(x, y, label = l)) +
  geom_point(size = 6, alpha = 0.25) + geom_text() + theme_minimal()
alpha beta gamma 1 1.5 2 2.5 3 1 1.5 2 2.5 3 x y

2.8 geom_tile

grid <- expand.grid(x = 1:10, y = 1:8)
grid$z <- as.vector(outer(1:10, 1:8, function(a, b) sin(a / 2) + cos(b / 2)))
ggnext(grid, aes(x, y, color = z)) + geom_tile() + theme_minimal()
0 2 4 6 8 10 2 4 6 8 x y z -1.96752261 -1.00687257 -0.04622253 0.91442751 1.87507755

3. Distributions

3.1 geom_bar and geom_col

geom_bar() counts rows; geom_col() takes the height from y.

cnt <- data.frame(g = c("a", "a", "a", "b", "b", "c"))
ggnext(cnt, aes(g)) + geom_bar() + theme_minimal()
a b c 0 1 2 3 g count
vals <- data.frame(g = c("alpha", "beta", "gamma", "delta"),
                   v = c(12, 27, 19, 8))
ggnext(vals, aes(g, v, color = g)) + geom_col() +
  theme(legend_position = "none")
alpha beta delta gamma 0 5 10 15 20 25 g v

3.2 Position adjustments

grp <- data.frame(g = rep(c("Q1", "Q2", "Q3"), each = 3),
                  grp = rep(c("a", "b", "c"), 3),
                  v = c(4, 6, 3, 7, 4, 5, 5, 8, 2))
ggnext(grp, aes(g, v, color = grp)) + geom_col(position = "stack") +
  labs(title = 'position = "stack" (default)') + theme_minimal()
position = "stack" (default) Q1 Q2 Q3 0 5 10 15 g v grp a b c
ggnext(grp, aes(g, v, color = grp)) + geom_col(position = "dodge") +
  labs(title = 'position = "dodge"') + theme_minimal()
position = "dodge" Q1 Q2 Q3 0 2 4 6 8 g v grp a b c
ggnext(grp, aes(g, v, color = grp)) + geom_col(position = "fill") +
  labs(title = 'position = "fill" (proportions)') + theme_minimal()
position = "fill" (proportions) Q1 Q2 Q3 0 2 4 6 8 g v grp a b c

Stacked totals train the axis correctly — the domain covers the stack, not the tallest single bar:

stacked <- ggnext:::build_geometry(
  ggnext(grp, aes(g, v, color = grp)) + geom_col(position = "stack")
)
stacked$panels[[1]]$y$domain
#> [1] -0.8 16.8

3.3 geom_histogram

bins = n yields exactly n bins, and counts sum to the row count.

ggnext(cars, aes(speed)) + geom_histogram(bins = 8) + theme_minimal()
5 10 15 20 25 0 5 10 speed count
h <- plot_data(ggnext(cars, aes(speed)) + geom_histogram(bins = 5))
h
#>      x  y ymin ymax xmin xmax
#> 1  6.1  5    0    5  4.0  8.2
#> 2 10.3 10    0   10  8.2 12.4
#> 3 14.5 13    0   13 12.4 16.6
#> 4 18.7 15    0   15 16.6 20.8
#> 5 22.9  7    0    7 20.8 25.0
c(bins = nrow(h), total = sum(h$y), rows = nrow(cars))
#>  bins total  rows 
#>     5    50    50

binwidth overrides bins:

ggnext(cars, aes(speed)) + geom_histogram(binwidth = 5) + theme_minimal()
5 10 15 20 25 30 0 5 10 15 speed count

3.4 geom_density

ggnext(iris, aes(Sepal.Length, color = Species)) +
  geom_density(alpha = 0.45) + theme_minimal()
4 5 6 7 8 0 0.5 1 Sepal.Length density Species setosa versicolor virginica

The estimate integrates to 1:

dd <- compute_stat(stat_density(), list(x = rnorm(500),
                                        group = rep("all", 500)))
round(sum(dd$y) * diff(dd$x[1:2]), 3)
#> [1] 1

3.5 geom_boxplot

ggnext(iris, aes(Species, Sepal.Length, color = Species)) +
  geom_boxplot() + theme(legend_position = "none")
setosa versicolor virginica 5 6 7 8 Species Sepal.Length

Quartiles match stats::quantile():

bx <- plot_data(ggnext(iris, aes(Species, Sepal.Length)) + geom_boxplot())
setosa <- iris$Sepal.Length[iris$Species == "setosa"]
c(computed = bx$middle[1], base_r = median(setosa))
#> computed   base_r 
#>        5        5

3.6 geom_violin

ggnext(iris, aes(Species, Sepal.Width, color = Species)) +
  geom_violin() + theme(legend_position = "none")
setosa versicolor virginica 2 3 4 Species Sepal.Width

3.7 Layered distribution view

Layers draw in the order added: shape underneath, summary, raw data on top.

ggnext(iris, aes(Species, Sepal.Length, color = Species)) +
  geom_violin(alpha = 0.25) +
  geom_boxplot() +
  geom_jitter(alpha = 0.4) +
  theme(legend_position = "none")
setosa versicolor virginica 4 5 6 7 8 Species Sepal.Length

3.8 geom_ridgeline

ggnext(iris, aes(Sepal.Length, Species)) +
  geom_ridgeline() + theme_minimal()
4 5 6 7 8 setosa versicolor virginica Sepal.Length Species
ggnext(iris, aes(Sepal.Length, Species)) +
  geom_ridgeline(scale = 3, alpha = 0.55) +
  labs(title = "scale = 3 makes ridges overlap") + theme_minimal()
scale = 3 makes ridges overlap 4 5 6 7 8 setosa versicolor virginica Sepal.Length Species

3.9 geom_smooth

ggnext(cars, aes(speed, dist)) +
  geom_point(alpha = 0.6) + geom_smooth(method = "lm") + theme_minimal()
5 10 15 20 25 0 50 100 speed dist

The linear fit matches stats::lm():

sm <- compute_stat(stat_smooth(method = "lm"),
                   list(x = cars$speed, y = cars$dist,
                        group = rep("all", nrow(cars))))
fit <- lm(dist ~ speed, cars)
max(abs(sm$y - unname(predict(fit, data.frame(speed = sm$x)))))
#> [1] 0
ggnext(cars, aes(speed, dist)) +
  geom_point(alpha = 0.6) + geom_smooth(method = "loess", se = FALSE) +
  theme_minimal()
5 10 15 20 25 0 20 40 60 80 100 120 speed dist

3.10 Error bars and point ranges

eb <- data.frame(g = c("A", "B", "C", "D"), m = c(5, 7, 4, 8),
                 lo = c(4, 6.2, 3.1, 7.1), hi = c(6, 7.8, 4.9, 8.9))
ggnext(eb, aes(g, m, ymin = lo, ymax = hi)) +
  geom_errorbar() + theme_minimal()
A B C D 3 4 5 6 7 8 9 g m
ggnext(eb, aes(g, m, ymin = lo, ymax = hi)) +
  geom_pointrange() + theme_minimal()
A B C D 3 4 5 6 7 8 9 g m

3.11 geom_waterfall

wf <- data.frame(
  step = factor(c("Start", "Sales", "Costs", "Tax", "End"),
                levels = c("Start", "Sales", "Costs", "Tax", "End")),
  v = c(100, 45, -30, -12, 0)
)
ggnext(wf, aes(step, v)) + geom_waterfall() + theme_minimal()
Start Sales Costs Tax End 0 50 100 150 step v

4. Scales and axes

4.1 Axis titles, breaks, labels, padding

ggnext(cars, aes(speed, dist)) +
  geom_point() +
  scale_x_continuous(
    name = "Speed (mph)",
    breaks = c(5, 10, 15, 20, 25),
    expand = 0
  ) +
  scale_y_continuous(
    name = "Stopping distance",
    labels = function(v) paste0(v, " ft")
  ) +
  theme_minimal()
5 10 15 20 25 0 ft 20 ft 40 ft 60 ft 80 ft 100 ft 120 ft Speed (mph) Stopping distance

expand = 0 makes the domain exactly the data range:

tight <- ggnext:::build_geometry(
  ggnext(cars, aes(speed, dist)) + geom_point() +
    scale_x_continuous(expand = 0)
)
rbind(domain = tight$panels[[1]]$x$domain, data = range(cars$speed))
#>        [,1] [,2]
#> domain    4   25
#> data      4   25

4.2 Limits

ggnext(cars, aes(speed, dist)) + geom_point() +
  xlim(0, 30) + ylim(0, 150) + theme_minimal()
0 10 20 30 0 50 100 150 speed dist

lims() sets both at once. Limits set the domain, not a crop — data outside still goes through the stats and is clipped when drawn.

ggnext(cars, aes(speed, dist)) + geom_point() +
  lims(x = c(10, 20)) +
  geom_smooth(method = "lm") +
  labs(subtitle = "the fit still uses all 50 rows") + theme_minimal()
the fit still uses all 50 rows 10 12 14 16 18 20 0 50 100 speed dist

4.3 Transforms

lg <- data.frame(x = 10^(1:5), y = 1:5)
ggnext(lg, aes(x, y)) + geom_point(size = 5) + scale_x_log10() +
  theme_minimal()
10 100 1000 10000 100000 1 2 3 4 5 x y

Positions are the log of the data; labels stay in original units.

lb <- ggnext:::build_geometry(
  ggnext(lg, aes(x, y)) + geom_point() + scale_x_log10()
)
unlist(lb$panels[[1]]$x$ticks$labels)
#> [1] "10"     "100"    "1000"   "10000"  "100000"
sq <- data.frame(x = c(1, 4, 9, 16, 25), y = 1:5)
ggnext(sq, aes(x, y)) + geom_point(size = 5) + scale_x_sqrt() +
  theme_minimal()
1 4 9 16 25 1 2 3 4 5 x y
ggnext(sq, aes(x, y)) + geom_point(size = 5) + scale_y_reverse() +
  labs(title = "scale_y_reverse()") + theme_minimal()
scale_y_reverse() 0 5 10 15 20 25 5 4 3 2 1 x y

4.4 Discrete scales

Explicit level order — how you sort bars by size rather than alphabetically.

srt <- data.frame(name = c("delta", "alpha", "gamma", "beta"),
                  value = c(8, 12, 19, 27))
srt <- srt[order(-srt$value), ]
ggnext(srt, aes(name, value)) + geom_col() +
  scale_x_discrete(limits = srt$name) + theme_minimal()
beta gamma alpha delta 0 5 10 15 20 25 name value

4.5 Colour scales

ggnext(iris, aes(Sepal.Length, Sepal.Width, color = Species)) +
  geom_point(size = 3) +
  scale_color_manual(c("#2B6BE0", "#E05A2B", "#12A594")) +
  theme_minimal()
5 6 7 8 2 2.5 3 3.5 4 4.5 Sepal.Length Sepal.Width Species setosa versicolor virginica
ggnext(iris, aes(Sepal.Length, Sepal.Width, color = Petal.Length)) +
  geom_point(size = 3) +
  scale_color_gradient(low = "#FFF3B0", high = "#9E2A2B") +
  labs(color = "Petal length") + theme_minimal()
5 6 7 8 2 2.5 3 3.5 4 4.5 Sepal.Length Sepal.Width Petal length 1 2.475 3.95 5.425 6.9

5. Coordinates

5.1 coord_flip

ggnext(vals, aes(g, v, color = g)) + geom_col() + coord_flip() +
  theme(legend_position = "none")
0 5 10 15 20 25 alpha beta delta gamma v g

5.2 coord_polar

days <- data.frame(d = c("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"),
                   v = c(4, 7, 6, 9, 12, 15, 11))
ggnext(days, aes(d, v, color = d)) + geom_col() + coord_polar() +
  theme(legend_position = "none")
0 5 10 15 Fri Mon Sat Sun Thu Tue Wed d v

Donut hole, direction and start angle:

ggnext(days, aes(d, v, color = d)) + geom_col() +
  coord_polar(inner = 0.35, direction = -1, start = pi / 4) +
  theme(legend_position = "none")
0 5 10 15 Fri Mon Sat Sun Thu Tue Wed d v

Categories wrap evenly around the full turn:

pol <- ggnext:::build_geometry(
  ggnext(days, aes(d, v)) + geom_col() + coord_polar()
)
unlist(pol$panels[[1]]$polar$spokes)
#> [1] 0.0000000 0.1428571 0.2857143 0.4285714 0.5714286 0.7142857 0.8571429

6. Facets

6.1 facet_wrap

ggnext(iris, aes(Sepal.Length, Sepal.Width, color = Species)) +
  geom_point() + facet_wrap(Species) +
  theme(legend_position = "none")
setosa 2 2.5 3 3.5 4 4.5 versicolor 5 6 7 8 virginica 5 6 7 8 2 2.5 3 3.5 4 4.5 Sepal.Length Sepal.Width

6.2 Grid shape and free scales

ggnext(iris, aes(Sepal.Length, Sepal.Width, color = Species)) +
  geom_point() + facet_wrap(Species, ncol = 2, scales = "free") +
  theme(legend_position = "none")
setosa 4.5 5 5.5 2.5 3 3.5 4 4.5 versicolor 5 5.5 6 6.5 7 2 2.5 3 virginica 5 6 7 8 2.5 3 3.5 Sepal.Length Sepal.Width

Fixed scales share one domain; free scales train per panel.

fixed <- ggnext:::build_geometry(
  ggnext(iris, aes(Sepal.Length, Sepal.Width)) + geom_point() +
    facet_wrap(Species))
free <- ggnext:::build_geometry(
  ggnext(iris, aes(Sepal.Length, Sepal.Width)) + geom_point() +
    facet_wrap(Species, scales = "free"))
rbind(
  fixed_p1 = fixed$panels[[1]]$x$domain,
  fixed_p3 = fixed$panels[[3]]$x$domain,
  free_p1  = free$panels[[1]]$x$domain,
  free_p3  = free$panels[[3]]$x$domain
)
#>           [,1]  [,2]
#> fixed_p1 4.120 8.080
#> fixed_p3 4.120 8.080
#> free_p1  4.225 5.875
#> free_p3  4.750 8.050

6.3 facet_grid

mt <- transform(mtcars, cyl = factor(cyl), am = factor(am))
ggnext(mt, aes(disp, mpg)) + geom_point() + facet_grid(am, cyl) +
  theme_minimal()
0 | 8 10 15 20 25 30 35 0 | 6 1 | 4 1 | 6 100 200 300 400 10 15 20 25 30 35 0 | 4 100 200 300 400 1 | 8 100 200 300 400 disp mpg

6.4 A layer without the faceting variable repeats in every panel

ggnext(iris, aes(Sepal.Length, Sepal.Width)) +
  geom_point(alpha = 0.6) +
  geom_hline(3, color = "#C1462F", dash = "4,3") +
  facet_wrap(Species) + theme_minimal()
setosa 2 2.5 3 3.5 4 4.5 versicolor 5 6 7 8 virginica 5 6 7 8 2 2.5 3 3.5 4 4.5 Sepal.Length Sepal.Width

7. Titles, labels and themes

7.1 The title block

ggnext(cars, aes(speed, dist)) +
  geom_point() +
  labs(
    title    = "Stopping distance rises with speed",
    subtitle = "1920s road tests, 50 observations",
    caption  = "Source: datasets::cars",
    tag      = "A",
    x = "Speed (mph)", y = "Distance (ft)"
  ) +
  theme_minimal()
A Stopping distance rises with speed 1920s road tests, 50 observations Source: datasets::cars 5 10 15 20 25 0 20 40 60 80 100 120 Speed (mph) Distance (ft)

labs() merges across repeated calls; ggtitle(), xlab(), ylab() are shorthands.

lab_plot <- ggnext(cars, aes(speed, dist)) + geom_point() +
  labs(title = "first") + labs(subtitle = "second") + xlab("X") + ylab("Y")
b <- ggnext:::build_geometry(lab_plot)
unlist(b$labels[c("title", "subtitle")])
#>    title subtitle 
#>  "first" "second"

7.2 The six presets

theme_demo <- function(th, nm) {
  ggnext(iris, aes(Sepal.Length, Sepal.Width, color = Species)) +
    geom_point(alpha = 0.85) + labs(title = nm) + th
}
theme_demo(theme_ggnext(), "theme_ggnext()")
theme_ggnext() 5 6 7 8 2 2.5 3 3.5 4 4.5 Sepal.Length Sepal.Width Species setosa versicolor virginica
theme_demo(theme_minimal(), "theme_minimal()")
theme_minimal() 5 6 7 8 2 2.5 3 3.5 4 4.5 Sepal.Length Sepal.Width Species setosa versicolor virginica
theme_demo(theme_classic(), "theme_classic()")
theme_classic() 5 6 7 8 2 2.5 3 3.5 4 4.5 Sepal.Length Sepal.Width Species setosa versicolor virginica
theme_demo(theme_modern(), "theme_modern()")
theme_modern() 5 6 7 8 2 2.5 3 3.5 4 4.5 Sepal.Length Sepal.Width Species setosa versicolor virginica
theme_demo(theme_dark(), "theme_dark()")
theme_dark() 5 6 7 8 2 2.5 3 3.5 4 4.5 Sepal.Length Sepal.Width Species setosa versicolor virginica
theme_demo(theme_void(), "theme_void()")
theme_void() Species setosa versicolor virginica

7.3 Customising a theme

base picks the preset to start from; everything else applies over it.

ggnext(iris, aes(Sepal.Length, Sepal.Width, color = Species)) +
  geom_point(size = 3) +
  labs(title = "Custom theme") +
  theme(
    base            = theme_minimal(),
    panel_fill      = "#FFF8F0",
    grid_major_x    = FALSE,
    grid_color      = "#E0D5C8",
    plot_title_size = 22,
    legend_position = "bottom",
    point_palette   = c("#2B6BE0", "#E05A2B", "#12A594")
  )
Custom theme 5 6 7 8 2 2.5 3 3.5 4 4.5 Sepal.Length Sepal.Width setosa versicolor virginica

7.4 All 35 theme settings

defaults <- ggnext:::THEME_DEFAULTS
data.frame(
  setting = names(defaults),
  default = vapply(defaults, function(v) {
    if (length(v) == 0) "—" else paste(format(v), collapse = ", ")
  }, character(1)),
  row.names = NULL
)
#>               setting                      default
#> 1                name                       ggnext
#> 2          background                      #FFFFFF
#> 3          panel_fill                      #F4F4F6
#> 4          grid_color                      #FFFFFF
#> 5    grid_color_minor                             
#> 6          axis_color                      #3A3A3A
#> 7         label_color                      #3A3A3A
#> 8         title_color                      #1A1A22
#> 9      subtitle_color                      #5A5A66
#> 10         strip_fill                      #E4E4EA
#> 11        strip_color                      #2A2A33
#> 12  legend_text_color                      #3A3A3A
#> 13       panel_border                             
#> 14               font Helvetica, Arial, sans-serif
#> 15         title_font                             
#> 16     tick_font_size                           11
#> 17    title_font_size                           13
#> 18    plot_title_size                           17
#> 19 plot_subtitle_size                           12
#> 20       caption_size                           10
#> 21    strip_font_size                           11
#> 22   legend_font_size                           11
#> 23         title_face                         bold
#> 24           tick_len                            5
#> 25       grid_major_x                         TRUE
#> 26       grid_major_y                         TRUE
#> 27        axis_line_x                         TRUE
#> 28        axis_line_y                         TRUE
#> 29            ticks_x                         TRUE
#> 30            ticks_y                         TRUE
#> 31        axis_text_x                         TRUE
#> 32        axis_text_y                         TRUE
#> 33       axis_title_x                         TRUE
#> 34       axis_title_y                         TRUE
#> 35    legend_position                        right
#> 36      point_palette                            —
#> 37       gradient_low                             
#> 38      gradient_high

Individual toggles, verified against the rendered SVG:

count_tag <- function(p, tag) {
  svg <- render(p)
  length(gregexpr(tag, svg, fixed = TRUE)[[1]]) *
    (!grepl("^\\s*$", svg)) * as.integer(grepl(tag, svg, fixed = TRUE))
}
p0 <- ggnext(cars, aes(speed, dist)) + geom_point()
data.frame(
  setting = c("grid on (default)", "grid_major_x = FALSE",
              "grid_color = ''", "axis_text_x = FALSE"),
  gridlines_or_labels = c(
    count_tag(p0, "stroke=\"#FFFFFF\""),
    count_tag(p0 + theme(grid_major_x = FALSE), "stroke=\"#FFFFFF\""),
    count_tag(p0 + theme(grid_color = ""), "stroke=\"#FFFFFF\""),
    count_tag(p0 + theme(axis_text_x = FALSE), "text-anchor=\"middle\"")
  )
)
#>                setting gridlines_or_labels
#> 1    grid on (default)                  12
#> 2 grid_major_x = FALSE                   7
#> 3      grid_color = ''                   0
#> 4  axis_text_x = FALSE                   2

7.5 Legends

ggnext(iris, aes(Sepal.Length, Sepal.Width, color = Species)) +
  geom_point() + theme(legend_position = "bottom")
5 6 7 8 2 2.5 3 3.5 4 4.5 Sepal.Length Sepal.Width setosa versicolor virginica
ggnext(iris, aes(Sepal.Length, Sepal.Width, color = Species)) +
  geom_point() + theme(legend_position = "none")
5 6 7 8 2 2.5 3 3.5 4 4.5 Sepal.Length Sepal.Width

8. Layout geoms

8.1 geom_radar

radar <- data.frame(
  axis  = rep(c("Speed", "Power", "Range", "Cost", "Safety"), 2),
  value = c(8, 6, 7, 4, 9, 5, 9, 4, 8, 6),
  model = rep(c("A", "B"), each = 5)
)
ggnext(radar, aes(axis, value, color = model)) +
  geom_radar() + coord_polar() + theme_minimal()
0 2 4 6 8 Cost Power Range Safety Speed axis value model A B

8.2 geom_treemap

tm <- data.frame(region = c("North", "South", "East", "West", "Central"),
                 revenue = c(52, 38, 27, 19, 11))
ggnext(tm, aes(size = revenue, label = region, color = region)) +
  geom_treemap() + theme(legend_position = "none")
North South East West Central

8.3 geom_sankey

flows <- data.frame(
  from = c("Visited", "Visited", "Signed up", "Signed up"),
  to   = c("Signed up", "Left", "Purchased", "Churned"),
  n    = c(400, 600, 150, 250)
)
ggnext(flows, aes(x = from, xend = to, y = n)) + geom_sankey()
Visited Signed up Left Purchased Churned

8.4 geom_network

net <- data.frame(from = c("A", "A", "B", "C", "D", "E", "B"),
                  to   = c("B", "C", "C", "D", "E", "A", "E"))
ggnext(net, aes(x = from, xend = to)) + geom_network()
A B C D E

8.5 geom_chord

ch <- data.frame(from = c("A", "A", "B", "C"), to = c("B", "C", "C", "A"),
                 n = c(5, 3, 7, 2))
ggnext(ch, aes(x = from, xend = to, y = n)) + geom_chord()
A B C

8.6 geom_stream

st <- data.frame(
  t = rep(1:8, 3),
  v = c(2, 4, 6, 5, 3, 2, 4, 5, 1, 3, 5, 8, 6, 4, 3, 2, 5, 4, 3, 4, 6, 7, 5, 4),
  grp = rep(c("a", "b", "c"), each = 8)
)
ggnext(st, aes(t, v, color = grp)) + geom_stream() + theme_minimal()
2 4 6 8 -5 0 5 t v grp a b c

8.7 geom_bump

bump <- data.frame(
  year = rep(2020:2024, 4),
  rank = c(1, 2, 3, 3, 2, 2, 1, 1, 2, 1, 3, 3, 2, 1, 3, 4, 4, 4, 4, 4),
  team = rep(c("A", "B", "C", "D"), each = 5)
)
ggnext(bump, aes(year, rank, color = team)) +
  geom_bump() + scale_y_reverse() + theme_minimal()
2020 2021 2022 2023 2024 4 3 2 1 year rank team A B C D

8.8 geom_funnel

fn <- data.frame(
  stage = factor(c("Visits", "Signups", "Trials", "Paid"),
                 levels = c("Visits", "Signups", "Trials", "Paid")),
  n = c(10000, 3200, 1100, 420)
)
ggnext(fn, aes(stage, n, color = stage)) + geom_funnel() +
  theme(legend_position = "none")
Visits 10,000 Signups 3,200 Trials 1,100 Paid 420

8.9 geom_parallel

sub <- iris[c(1, 20, 60, 80, 110, 140), ]
par_d <- data.frame(
  id  = rep(rownames(sub), 4),
  var = rep(c("SL", "SW", "PL", "PW"), each = nrow(sub)),
  val = c(sub$Sepal.Length, sub$Sepal.Width, sub$Petal.Length, sub$Petal.Width),
  sp  = rep(as.character(sub$Species), 4)
)
ggnext(par_d, aes(var, val, group = id, color = sp)) +
  geom_parallel() + theme_minimal()
PL PW SL SW 0 0.2 0.4 0.6 0.8 1 var val sp setosa versicolor virginica

8.10 geom_upset

us <- data.frame(sets = c("A", "A&B", "B", "A&B&C", "C", "A&B",
                          "A", "B&C", "A&C", "A&B"))
ggnext(us, aes(label = sets)) + geom_upset()
3 2 1 1 1 1 1 A B C

9. Machine-learning geoms

9.1 geom_shap

shap <- data.frame(
  feature = rep(c("age", "income", "tenure"), each = 40),
  shap    = c(rnorm(40, 0.3, 0.2), rnorm(40, -0.1, 0.3), rnorm(40, 0, 0.15)),
  value   = runif(120)
)
ggnext(shap, aes(shap, feature, color = value)) +
  geom_shap() + geom_vline(0, dash = "3,3") +
  labs(x = "SHAP value", y = NULL) + theme_minimal()
-0.5 0 0.5 1 age income tenure SHAP value feature value 0.001272528 0.247711965 0.494151402 0.740590839 0.987030275

9.2 geom_partial_dependence

pdp <- data.frame(
  x = rep(1:10, 8), id = rep(1:8, each = 10),
  pred = as.vector(sapply(1:8, function(i) (1:10) * 0.1 * i + rnorm(10, 0, .15)))
)
ggnext(pdp, aes(x, pred, group = id)) +
  geom_partial_dependence() + theme_minimal()
2 4 6 8 10 0 2 4 6 8 x pred

9.3 geom_roc

sc <- runif(300)
roc_d <- data.frame(score = sc, truth = rbinom(300, 1, sc))
ggnext(roc_d, aes(score = score, truth = truth)) +
  geom_roc() + theme_minimal()
0 0.2 0.4 0.6 0.8 1 0 0.2 0.4 0.6 0.8 1 false positive rate true positive rate

A perfect classifier gives an exact staircase:

perfect <- compute_stat(stat_roc(), list(
  truth = c(1, 1, 0, 0), score = c(0.9, 0.8, 0.2, 0.1),
  group = rep("all", 4)
))
data.frame(fpr = perfect$x, tpr = perfect$y)
#>   fpr tpr
#> 1 0.0 0.0
#> 2 0.0 0.5
#> 3 0.0 1.0
#> 4 0.5 1.0
#> 5 1.0 1.0
#> 6 0.0 0.0
#> 7 1.0 1.0

9.4 geom_calibration

pr <- runif(400)
cal <- data.frame(pred = pr, obs = rbinom(400, 1, pr^1.3))
ggnext(cal, aes(pred, obs)) + geom_calibration() + theme_minimal()
0.2 0.4 0.6 0.8 0 0.2 0.4 0.6 0.8 1 pred obs

9.5 geom_lift_gain

s2 <- runif(250)
lg_d <- data.frame(score = s2, y = rbinom(250, 1, s2))
ggnext(lg_d, aes(score = score, truth = y)) +
  geom_lift_gain(type = "gain") + theme_minimal()
0 0.2 0.4 0.6 0.8 1 0 0.2 0.4 0.6 0.8 1 proportion targeted proportion of positives
ggnext(lg_d, aes(score = score, truth = y)) +
  geom_lift_gain(type = "lift") + theme_minimal()
0 0.2 0.4 0.6 0.8 1 1 1.2 1.4 1.6 1.8 2 proportion targeted lift

9.6 geom_confusion_matrix

cm <- data.frame(
  predicted = c(rep("cat", 14), rep("dog", 9), rep("bird", 6)),
  actual = c(rep("cat", 11), rep("dog", 3), rep("dog", 7),
             rep("cat", 2), rep("bird", 5), "cat")
)
ggnext(cm, aes(predicted, actual)) + geom_confusion_matrix() +
  theme(legend_position = "none")
5 1 11 3 2 7 bird cat dog bird cat dog predicted actual

9.7 geom_residual

m <- lm(dist ~ speed, cars)
res <- data.frame(fitted = fitted(m), resid = resid(m))
ggnext(res, aes(fitted, resid)) + geom_residual() + theme_minimal()
0 20 40 60 80 -20 0 20 40 fitted resid

9.8 geom_learning_curve

lc <- data.frame(
  n = rep(c(50, 100, 200, 400, 800), 2),
  score = c(.72, .80, .85, .88, .90, .66, .75, .81, .85, .88),
  split = rep(c("train", "validation"), each = 5)
)
ggnext(lc, aes(n, score, color = split)) +
  geom_learning_curve() + theme_minimal()
200 400 600 800 0.65 0.7 0.75 0.8 0.85 0.9 n score split train validation

9.9 geom_silhouette

sil <- data.frame(
  cluster = rep(c("1", "2", "3"), each = 25),
  width = c(runif(25, .3, .9), runif(25, .1, .7), runif(25, -.1, .6))
)
ggnext(sil, aes(width, cluster, color = cluster)) +
  geom_silhouette() + theme(legend_position = "none")
0 0.2 0.4 0.6 0.8 1 2 3 width cluster

9.10 geom_embedding

emb <- data.frame(d1 = c(rnorm(40), rnorm(40, 4)),
                  d2 = c(rnorm(40), rnorm(40, 3)),
                  cluster = rep(c("a", "b"), each = 40))
ggnext(emb, aes(d1, d2, color = cluster)) +
  geom_embedding() + theme_minimal()
-2 0 2 4 6 -2 0 2 4 d1 d2 cluster a b

9.11 geom_decision_boundary

gr <- expand.grid(x = seq(0, 1, 0.04), y = seq(0, 1, 0.04))
gr$cls <- ifelse(gr$x + gr$y > 1, "a", "b")
ggnext(gr, aes(x, y, color = cls)) +
  geom_decision_boundary() + theme_minimal()
0 0.2 0.4 0.6 0.8 1 0 0.2 0.4 0.6 0.8 1 x y cls a b

9.12 geom_forecast_band

fc <- data.frame(
  t = 1:12, v = c(3, 4, 4, 5, 6, 6, 7, 8, 9, 10, 11, 12),
  lo = c(rep(NA, 7), 6.5, 7, 7.5, 8, 8.5),
  hi = c(rep(NA, 7), 9.5, 11, 12.5, 14, 15.5),
  part = rep(c("actual", "forecast"), c(7, 5))
)
ggnext(fc, aes(t, v, ymin = lo, ymax = hi, group = part)) +
  geom_forecast_band() + theme_minimal()
2 4 6 8 10 12 5 10 15 t v

10. Clinical geoms

10.1 geom_km

km_d <- data.frame(
  t = c(rexp(60, 0.08), rexp(60, 0.14)),
  ev = rbinom(120, 1, 0.75),
  arm = rep(c("Treatment", "Control"), each = 60)
)
ggnext(km_d, aes(time = t, status = ev, color = arm)) +
  geom_km() + theme_minimal()
0 10 20 30 40 50 60 0.2 0.4 0.6 0.8 1 time survival probability arm Control Treatment

The product-limit estimate, checked by hand on four subjects — events at t = 1, 2, 4 and a censoring at t = 3, so S(1) = 3/4, S(2) = 1/2, S(4) = 0:

km_check <- compute_stat(stat_km(), list(
  time = c(1, 2, 3, 4), status = c(1, 1, 0, 1), group = rep("all", 4)
))
curve <- which(km_check$role == "curve")
vapply(c(1, 2, 4),
       function(tt) min(km_check$y[curve][km_check$x[curve] == tt]),
       numeric(1))
#> [1] 0.75 0.50 0.00

10.2 geom_cuminc

ci <- data.frame(t = rexp(150, 0.1),
                 ev = sample(0:2, 150, replace = TRUE, prob = c(.4, .35, .25)))
ggnext(ci, aes(time = t, status = ev)) + geom_cuminc() + theme_minimal()
0 10 20 30 40 50 60 0 0.1 0.2 0.3 0.4 0.5 time cumulative incidence NULL event 1 event 2

10.3 geom_forest

fp <- data.frame(
  study = c("Trial A", "Trial B", "Trial C", "Trial D", "Pooled"),
  hr = c(0.82, 0.71, 0.95, 0.88, 0.83),
  lo = c(0.65, 0.52, 0.78, 0.70, 0.74),
  hi = c(1.03, 0.97, 1.16, 1.10, 0.93),
  weight = c(30, 22, 28, 20, 100)
)
ggnext(fp, aes(hr, study, ymin = lo, ymax = hi, size = weight)) +
  geom_forest() + labs(x = "Hazard ratio (95% CI)", y = NULL) +
  theme_minimal()
0.6 0.8 1 Pooled Trial A Trial B Trial C Trial D Hazard ratio (95% CI) study

10.4 geom_swimmer

sw <- data.frame(
  subject = paste0("S", 1:8),
  months = c(4, 9, 14, 6, 20, 11, 17, 7),
  response = c("PR", "CR", "CR", "SD", "PR", "SD", "CR", "PD"),
  ongoing = c(FALSE, FALSE, TRUE, FALSE, TRUE, FALSE, TRUE, FALSE)
)
ggnext(sw, aes(months, subject, color = response, label = ongoing)) +
  geom_swimmer() + labs(x = "Months", y = NULL) + theme_minimal()
0 5 10 15 20 S1 S2 S3 S4 S5 S6 S7 S8 Months subject response CR PD PR SD

10.5 geom_spider_response

sp <- data.frame(
  month = rep(c(0, 2, 4, 6, 8), 4),
  pct = c(0, -20, -35, -40, -42, 0, 10, 25, 40, 55,
          0, -5, -10, -8, -12, 0, -30, -45, -50, -48),
  subject = rep(c("S1", "S2", "S3", "S4"), each = 5)
)
ggnext(sp, aes(month, pct, color = subject)) +
  geom_spider_response() +
  labs(x = "Month", y = "% change from baseline") + theme_minimal()
0 2 4 6 8 -40 -20 0 20 40 60 Month % change from baseline subject S1 S2 S3 S4

10.6 geom_waterfall_response

wr <- data.frame(subject = paste0("S", 1:24),
                 pct = sort(runif(24, -78, 48), decreasing = TRUE))
ggnext(wr, aes(subject, pct)) + geom_waterfall_response() +
  labs(y = "% change from baseline", x = NULL) +
  theme(axis_text_x = FALSE)
-60 -40 -20 0 20 40 subject % change from baseline NULL #2E7D5B #C1462F #D9A441

10.7 geom_spaghetti

sg <- data.frame(
  week = rep(0:5, 10), id = rep(1:10, each = 6),
  score = as.vector(sapply(1:10, function(i) 50 + i + (0:5) * 2 + rnorm(6, 0, 3)))
)
ggnext(sg, aes(week, score, group = id)) + geom_spaghetti() + theme_minimal()
0 1 2 3 4 5 50 55 60 65 70 week score

10.8 geom_bland_altman

a <- rnorm(80, 100, 12)
ba <- data.frame(method_a = a, method_b = a + rnorm(80, 2, 5))
ggnext(ba, aes(method_a, method_b)) + geom_bland_altman() + theme_minimal()
80 100 120 -15 -10 -5 0 5 10 method_a method_b

10.9 geom_dose_response

dr <- data.frame(
  dose = rep(c(0.1, 1, 10, 100, 1000), each = 3),
  resp = c(5, 7, 6, 18, 22, 20, 52, 48, 55, 82, 79, 85, 95, 97, 93)
)
ggnext(dr, aes(dose, resp)) + geom_dose_response() + scale_x_log10() +
  theme_minimal()
0.1 1 10 100 1000 20 40 60 80 100 dose resp

10.10 geom_ae_heatmap

ae <- expand.grid(arm = c("Placebo", "Low", "High"),
                  ae = c("Nausea", "Fatigue", "Headache", "Rash"))
ae$pct <- c(5, 12, 22, 8, 15, 26, 3, 6, 11, 2, 9, 17)
ggnext(ae, aes(arm, ae, size = pct)) + geom_ae_heatmap() +
  theme(legend_position = "none")
5 12 22 8 15 26 3 6 11 2 9 17 Placebo Low High Nausea Fatigue Headache Rash arm ae

10.11 geom_shift

sh <- data.frame(
  baseline = c("G0", "G0", "G1", "G1", "G2", "G0", "G1", "G0"),
  followup = c("G0", "G1", "G1", "G2", "G2", "G0", "G0", "G1")
)
ggnext(sh, aes(baseline, followup)) + geom_shift() +
  theme(legend_position = "none")
2 2 1 1 1 1 G0 G1 G2 G0 G1 G2 baseline followup

10.12 geom_consort

cs <- data.frame(
  stage = c("Assessed for eligibility", "Randomised",
            "Received allocation", "Completed follow-up", "Analysed"),
  n = c(420, 300, 291, 276, 271)
)
ggnext(cs, aes(label = stage, size = n)) + geom_consort()
Assessed for eligibility (n = 420) Randomised (n = 300) Received allocation (n = 291) Completed follow-up (n = 276) Analysed (n = 271)

11. Interactivity and animation

Plots are static first. render(p) gives an SVG; + interact() switches the default target to a self-contained HTML page.

p_int <- ggnext(iris, aes(Sepal.Length, Sepal.Width, color = Species)) +
  geom_point()

nchar(render(p_int))                       # static SVG
#> [1] 14498
nchar(render(p_int + interact()))          # interactive HTML
#> [1] 43828
substr(render(p_int + interact()), 1, 15)
#> <ggnext interactive render: 15 characters>
#> Use render(p, file = ...) to save it, or cat(x) to see the source.

Interaction flags travel in the buffer:

opts <- ggnext:::build_geometry(
  p_int + interact(tooltip = c("Species"), zoom = FALSE, brush = TRUE)
)$interaction
unlist(opts)
#> tooltip    zoom   brush 
#>    TRUE   FALSE    TRUE

Tooltips can be built from named data columns:

html <- render(p_int + interact(tooltip = c("Species", "Petal.Length")))
grepl("Species: setosa", html, fixed = TRUE)
#> [1] TRUE

Both targets consume the same geometry buffer, so mark counts always agree:

svg  <- render(p_int)
html <- render(p_int, target = "interactive")
c(
  svg_points  = length(gregexpr("<circle ", svg, fixed = TRUE)[[1]]),
  html_points = length(gregexpr("\"type\":\"circle\"", html, fixed = TRUE)[[1]]),
  data_rows   = nrow(iris)
)
#>  svg_points html_points   data_rows 
#>         150         150         150

Animation reruns the pipeline per level of the transition variable, with axes fixed across frames:

anim_d <- data.frame(
  x = rep(1:5, 3), y = c(1:5, (1:5) * 2, (1:5) * 3),
  step = rep(c(1, 2, 3), each = 5)
)
p_anim <- ggnext(anim_d, aes(x, y)) + geom_point(size = 5) +
  animate(step, duration = 600)
ab <- ggnext:::build_animation(p_anim, ggnext:::build_geometry(p_anim))
c(frames = length(ab$animation$frames),
  labels = paste(unlist(ab$animation$labels), collapse = ","),
  marks_in_frame_1 = length(ab$animation$frames[[1]][[1]][[1]]$marks))
#>           frames           labels marks_in_frame_1 
#>              "3"          "1,2,3"              "5"

An animated plot still renders statically:

p_anim
vs

12. Exact data export

plot_data() returns precisely the values drawn — post-stat, post-position, post-facet, in data units.

head(plot_data(ggnext(cars, aes(speed, dist)) + geom_point()))
#>   x  y
#> 1 4  2
#> 2 4 10
#> 3 7  4
#> 4 7 22
#> 5 8 16
#> 6 9 10

For a stat-backed layer it reflects what was computed, not the input:

plot_data(ggnext(iris, aes(Species, Sepal.Length)) + geom_boxplot())
#>   x   y lower middle upper ymin ymax    role  group
#> 1 1  NA 4.800    5.0   5.2  4.3  5.8     box all\r1
#> 2 2  NA 5.600    5.9   6.3  4.9  7.0     box all\r2
#> 3 3  NA 6.225    6.5   6.9  5.6  7.9     box all\r3
#> 4 3 4.9    NA     NA    NA   NA   NA outlier all\r3

Facets add a panel column:

fd <- plot_data(ggnext(iris, aes(Sepal.Length, Sepal.Width)) +
                  geom_point() + facet_wrap(Species))
head(fd)
#>    panel   x   y
#> 1 setosa 5.1 3.5
#> 2 setosa 4.9 3.0
#> 3 setosa 4.7 3.2
#> 4 setosa 4.6 3.1
#> 5 setosa 5.0 3.6
#> 6 setosa 5.4 3.9
table(fd$panel)
#> 
#>     setosa versicolor  virginica 
#>         50         50         50

Selecting layers and panels:

multi <- ggnext(cars, aes(speed, dist)) + geom_point() + geom_smooth()
length(plot_data(multi))                    # one table per layer
#> [1] 2
nrow(plot_data(multi, layer = 1))
#> [1] 50
nrow(plot_data(multi, panel = 1))
#> NULL

Writing it out alongside the figure:

csv <- file.path(tempdir(), "figure-data.csv")
write_plot_data(ggnext(cars, aes(speed, dist)) + geom_point(), csv)
head(read.csv(csv), 3)
#>   x  y
#> 1 4  2
#> 2 4 10
#> 3 7  4

13. Rendering and the logo

p <- ggnext(cars, aes(speed, dist)) + geom_point()
svg_file  <- file.path(tempdir(), "plot.svg")
html_file <- file.path(tempdir(), "plot.html")
render(p, file = svg_file)
render(p + interact(), file = html_file)
file.exists(c(svg_file, html_file))
#> [1] TRUE TRUE

render() output prints as a summary rather than dumping markup:

render(p)
5 10 15 20 25 0 20 40 60 80 100 120 speed dist

The hex sticker is drawn by the package’s own SVG writer:

cat(ggnext_logo(width = 260))
ggnext NEXT-GENERATION GRAMMAR OF GRAPHICS
cat(ggnext_logo(width = 260, style = "monogram"))
GG ggnext

14. Extending the package

A geom is an S7 subclass plus one build_marks() method returning primitives in normalized panel coordinates. Both renderers consume those primitives, so a new geom needs no renderer changes.

GeomCross <- S7::new_class("GeomCross", parent = Geom,
  constructor = function() {
    S7::new_object(Geom(name = "cross",
                        default_params = list(size = 6, alpha = 1,
                                              color = "#C1462F")))
  }
)

S7::method(build_marks, GeomCross) <- function(geom, scaled) {
  unlist(lapply(seq_along(scaled$x), function(i) {
    r <- scaled$size[[i]] / 400
    list(
      ggnext:::mk_line(c(scaled$x[[i]] - r, scaled$x[[i]] + r),
                        rep(scaled$y[[i]], 2), scaled$color[[i]], width = 2),
      ggnext:::mk_line(rep(scaled$x[[i]], 2),
                        c(scaled$y[[i]] - r, scaled$y[[i]] + r),
                        scaled$color[[i]], width = 2)
    )
  }), recursive = FALSE)
}

geom_cross <- function(mapping = NULL, data = NULL, ...) {
  ggnext:::layer_new(GeomCross(), stat_identity(), mapping, data, list(...))
}

ggnext(cars, aes(speed, dist)) + geom_cross() + theme_minimal()
5 10 15 20 25 0 20 40 60 80 100 120 speed dist

The custom geom works with every other part of the grammar — facets, scales, themes and the interactive target — without further work.

ggnext(iris, aes(Sepal.Length, Sepal.Width)) +
  geom_cross(size = 4) + facet_wrap(Species) + theme_minimal()
setosa 2 2.5 3 3.5 4 4.5 versicolor 5 6 7 8 virginica 5 6 7 8 2 2.5 3 3.5 4 4.5 Sepal.Length Sepal.Width

15. Error handling

Every failure mode below raises a clear, actionable message.

show_error <- function(expr) {
  tryCatch({ force(expr); "no error" },
           error = function(e) conditionMessage(e))
}
# A name that is not a column, where R finds a function of that name.
show_error(render(ggnext(mtcars, aes(disp, hp, color = class)) + geom_point()))
#> [1] "Aesthetic `color = class` evaluated to a function, not a data column. Is `class` a column in your data? (Columns present: mpg, cyl, disp, hp, drat, wt, qsec, vs, am, gear, carb)"
# Missing required aesthetics.
show_error(render(ggnext(cars, aes(speed)) + geom_point()))
#> [1] "geom_point() requires the aesthetic(s): y. Map them in aes(), or use a stat that computes them."
show_error(render(ggnext(cars, aes(speed, dist)) + geom_sankey()))
#> [1] "geom_sankey() requires the aesthetic(s): xend. Map them in aes(), or use a stat that computes them."
# No layers, no data.
show_error(render(ggnext(cars, aes(speed, dist))))
#> [1] "Cannot render a plot with no layers; add e.g. geom_point()."
show_error(render(ggnext() + geom_point()))
#> [1] "Layer has no data: supply data to ggnext() or to the layer."
# Impossible scales.
show_error(render(ggnext(data.frame(x = c(0, 1, 10), y = 1:3), aes(x, y)) +
                    geom_point() + scale_x_log10()))
#> [1] "A log10 scale requires strictly positive values."
show_error(scale_x_continuous(limits = c(10, 1)))
#> [1] "`limits` must be NULL or an increasing numeric vector of length 2."
# Unsupported aesthetics and theme settings.
show_error(aes(x, y, shape = z))
#> [1] "Unsupported aesthetic(s): shape. This spike supports: x, y, color, size, group, label, xmin, xmax, ymin, ymax, xend, yend, time, status, truth, score, sample."
show_error(theme(not_a_setting = 1))
#> [1] "Unknown theme setting(s): not_a_setting. Available: background, panel_fill, grid_color, grid_color_minor, axis_color, label_color, title_color, subtitle_color, strip_fill, strip_color, legend_text_color, panel_border, font, title_font, tick_font_size, title_font_size, plot_title_size, plot_subtitle_size, caption_size, strip_font_size, legend_font_size, title_face, tick_len, grid_major_x, grid_major_y, axis_line_x, axis_line_y, ticks_x, ticks_y, axis_text_x, axis_text_y, axis_title_x, axis_title_y, legend_position, point_palette, gradient_low, gradient_high."
show_error(ggnext(cars, aes(speed, dist)) + geom_point() + 42)
#> [1] "Cannot add an object of class <numeric> to a ggnext plot."
# Faceting on a variable that does not exist.
show_error(render(ggnext(iris, aes(Sepal.Length, Sepal.Width)) +
                    geom_point() + facet_wrap(NotAColumn)))
#> [1] "Faceting variable(s) not found in the data: NotAColumn"

16. Edge cases

edge <- function(label, expr) {
  out <- tryCatch({ nchar(render(expr)); "rendered" },
                  error = function(e) paste("ERROR:", conditionMessage(e)))
  data.frame(case = label, result = out)
}

do.call(rbind, list(
  edge("single row",
       ggnext(data.frame(x = 1, y = 1), aes(x, y)) + geom_point()),
  edge("two rows",
       ggnext(data.frame(x = 1:2, y = 1:2), aes(x, y)) + geom_point()),
  edge("zero variance in x",
       ggnext(data.frame(x = rep(5, 10), y = rnorm(10)), aes(x, y)) +
         geom_point()),
  edge("zero variance in both",
       ggnext(data.frame(x = rep(1, 5), y = rep(2, 5)), aes(x, y)) +
         geom_point()),
  edge("NA in y",
       ggnext(data.frame(x = 1:5, y = c(1, NA, 3, NA, 5)), aes(x, y)) +
         geom_point()),
  edge("very large values",
       ggnext(data.frame(x = c(1e9, 2e9, 3e9), y = 1:3), aes(x, y)) +
         geom_point()),
  edge("very small values",
       ggnext(data.frame(x = c(1e-9, 2e-9, 3e-9), y = 1:3), aes(x, y)) +
         geom_point()),
  edge("unicode in labels",
       ggnext(cars, aes(speed, dist)) + geom_point() +
         labs(title = "éàü 中文 — dash")),
  edge("XML-special characters in labels",
       ggnext(cars, aes(speed, dist)) + geom_point() +
         labs(title = "a < b & c > d")),
  edge("single facet level",
       ggnext(iris[iris$Species == "setosa", ],
               aes(Sepal.Length, Sepal.Width)) +
         geom_point() + facet_wrap(Species)),
  edge("many categories (palette recycles)",
       ggnext(data.frame(g = letters[1:12], v = 1:12),
               aes(g, v, color = g)) + geom_col())
))
#>                                  case   result
#> 1                          single row rendered
#> 2                            two rows rendered
#> 3                  zero variance in x rendered
#> 4               zero variance in both rendered
#> 5                             NA in y rendered
#> 6                   very large values rendered
#> 7                   very small values rendered
#> 8                   unicode in labels rendered
#> 9    XML-special characters in labels rendered
#> 10                 single facet level rendered
#> 11 many categories (palette recycles) rendered

17. Session information

sessionInfo()
#> R version 4.6.1 (2026-06-24)
#> Platform: aarch64-apple-darwin23
#> Running under: macOS Golden Gate 27.0
#> 
#> Matrix products: default
#> BLAS:   /Library/Frameworks/R.framework/Versions/4.6/Resources/lib/libRblas.0.dylib 
#> LAPACK: /Library/Frameworks/R.framework/Versions/4.6/Resources/lib/libRlapack.dylib;  LAPACK version 3.12.1
#> 
#> locale:
#> [1] C.UTF-8/C.UTF-8/C.UTF-8/C/C.UTF-8/C.UTF-8
#> 
#> time zone: Asia/Kolkata
#> tzcode source: internal
#> 
#> attached base packages:
#> [1] stats     graphics  grDevices datasets  utils     methods   base     
#> 
#> other attached packages:
#> [1] ggnext_0.1.0   testthat_3.3.2
#> 
#> loaded via a namespace (and not attached):
#>  [1] vctrs_0.7.3       knitr_1.51        cli_3.6.6         xfun_0.57        
#>  [5] rlang_1.3.0       otel_0.2.0        purrr_1.2.2       pkgload_1.5.3    
#>  [9] renv_1.1.8        S7_0.2.2          glue_1.8.1        markdown_2.0     
#> [13] rprojroot_2.1.1   pkgbuild_1.4.8    brio_1.1.5        evaluate_1.0.5   
#> [17] ellipsis_0.3.3    fastmap_1.2.0     yaml_2.3.12       lifecycle_1.0.5  
#> [21] memoise_2.0.1     compiler_4.6.1    fs_2.1.0          sessioninfo_1.2.4
#> [25] rstudioapi_0.19.0 R6_2.6.1          usethis_3.2.1     magrittr_2.0.5   
#> [29] withr_3.0.2       tools_4.6.1       devtools_2.5.2    cachem_1.1.0     
#> [33] desc_1.4.3