A ggplot object is like a blueprint for building a house. Every time you draw a ggplot object, the ggplot_build and ggplot_gtable functions act like builders, turning your blueprint into a gtable, which is what is actually drawn on the screen. The gtable is like the finished house after the building process is complete. If you later build an extension onto your house, that does not magically change the original blueprint. Similarly, modifying a gtable after it is built can't affect the ggplot object.
To get a ggplot to generate a modified final gtable, what you would need to do is include the information for your annotation in the ggplot "blueprint", and write versions of the building functions ggplot_build and ggplot_gtable that know how to incorporate the annotation in your plot.
Fortunately, ggplot_build and ggplot_gtable are generic functions, which means that as long as your annotated ggplot has its own special class, you can write methods for these functions which only apply to your new special class. Now that ggplot2 is written using the S7 object-oriented system, we can use S7 inheritance to make this work. Essentially, your anno_margin function should convert the ggplot object to a new sub-class of ggplot as well as including the annotation information, then your new build methods produce the modified gtable.
There are several caveats here. Firstly, this is probably far more complex than you were expecting. Secondly, the solution I have sketched here works in the console, but it makes use of some of ggplot2's unexported functions, and you couldn't do this inside a package unless you copied these functions from the ggplot2 code base into your own package. Thirdly, adding text grobs directly misses out on the benefits of the theme system, so at present there is no way to change the size, style and colour of your text. The easiest way to do this would be to add size, font, face and colour arguments into anno_margin which ultimately get passed to the builder methods, but ultimately it would be best to add new theme components which can be interrogated during the build process.
In view of all that, I think something like Jon Spring's answer is going to turn out to be more practical, but I thought I would show how it is possible to create a modifiable ggplot object that builds a modified gtable. If nothing else it gives a practical demonstration of the relatively new S7 system in action.
Let's start with the function that will actually modify your gtable. It takes an annotation and a side (t, r, b, l), and adds the annotation to the gtable at the correct side and at the correct angle:
library(S7)
library(ggplot2)
manipulate_gtable <- function(gtable, annotation, side) {
side <- match(side, c("t", "r", "b", "l"))
rot <- c(0, -90, 0, 90)[side]
sz <- grid::unit(1, "lines")
pos <- c(0, -1, -1, 0)[side]
fun <- rep(list(gtable::gtable_add_rows, gtable::gtable_add_cols), 2)[[side]]
gt <- do.call(fun, list(gtable, sz, pos))
trows <- dim(gt)[1]
tcols <- dim(gt)[2]
t_pos <- c(1, 1, trows, 1)[side]
b_pos <- c(1, trows, trows, trows)[side]
l_pos <- c(1, tcols, 1, 1)[side]
r_pos <- c(tcols, tcols, tcols, 1)[side]
gtable::gtable_add_grob(
gt,
grobs = grid::textGrob(annotation, rot = rot),
t = t_pos,
l = l_pos,
b = b_pos,
r = r_pos
)
}
To get ggplot to use this, we must first write a new ggplot subclass that is the same as a ggplot but contains slots for the annotation and the side:
class_ggplot_anno <- new_class("ggplot_anno",
ggplot2::class_ggplot,
constructor = function(plot, annotation, anno_side) {
stopifnot(is.character(annotation))
if(!anno_side %in% c("t", "r", "b", "l")) {
stop("'anno_side must be one of 't', 'r', 'b', or 'l'")
}
new_object(plot, annotation = annotation, anno_side = anno_side)
},
properties = list(annotation = class_character,
anno_side = class_character)
)
Now we can write anno_margin in such a way that when added to a ggplot, it transforms the ggplot into our new annotated ggplot class. As far as I can tell, we need to use the old S3 generic ggplot_add here:
anno_margin <- function(annotation, side = "t") {
structure(list(annotation = annotation, side = side), class = "anno")
}
ggplot_add.anno <- function(object, plot, ...) {
class_ggplot_anno(plot, annotation = object$anno, anno_side = object$side)
}
We now need to propagate the annotation through the build process by declaring an annotated version of the ggplot_built class from scratch:
class_ggplot_built_anno <- new_class("ggplot_built_anno",
parent = S7::S7_object,
constructor = function(blank, data, layout, plot, annotation, anno_side) {
new_object(blank, data = data,
layout = layout, plot = plot,
annotation = annotation,
anno_side = anno_side,
built_plot = ggplot2::class_ggplot_built(data = data,
layout = layout,
plot = plot))
},
properties = list(data = class_list,
layout = ggplot2::class_layout,
plot = ggplot2::class_ggplot,
annotation = class_character,
anno_side = class_character,
built_plot = ggplot2::class_ggplot_built)
)
To get our ggplot_anno built into a ggplot_built_anno, we need a new S7 method for ggplot_build. This is largely just a copy of the ggplot_build method for ggplot objects, so it contains unexported ggplot2 functions which you would need to copy into your own code base to make it work inside a package:
method(ggplot_build, class_ggplot_anno) <- function (plot, ...) {
plot <- ggplot2:::plot_clone(plot)
if (length(plot@layers) == 0) {
plot <- plot + ggplot2::geom_blank()
}
layers <- plot@layers
data <- rep(list(NULL), length(layers))
scales <- plot@scales
data <- ggplot2:::by_layer(function(l, d) l$layer_data(plot@data),
layers, data, "computing layer data")
data <- ggplot2:::by_layer(function(l, d) l$setup_layer(d, plot), layers,
data, "setting up layer")
layout <- ggplot2:::create_layout(plot@facet, plot@coordinates, plot@layout)
data <- layout$setup(data, plot@data, plot@plot_env)
data <- ggplot2:::by_layer(function(l, d) l$compute_aesthetics(d, plot),
layers, data, "computing aesthetics")
plot@labels <- ggplot2:::setup_plot_labels(plot, layers, data)
data <- ggplot2:::.ignore_data(data)
data <- lapply(data, scales$transform_df)
scale_x <- function() scales$get_scales("x")
scale_y <- function() scales$get_scales("y")
layout$train_position(data, scale_x(), scale_y())
data <- layout$map_position(data)
data <- ggplot2:::.expose_data(data)
data <- ggplot2:::by_layer(function(l, d) l$compute_statistic(d, layout),
layers, data, "computing stat")
data <- ggplot2:::by_layer(function(l, d) l$map_statistic(d, plot),
layers, data, "mapping stat to aesthetics")
plot@scales$add_missing(c("x", "y"), plot@plot_env)
data <- ggplot2:::by_layer(function(l, d) l$compute_geom_1(d), layers,
data, "setting up geom")
data <- ggplot2:::by_layer(function(l, d) l$compute_position(d, layout),
layers, data, "computing position")
data <- ggplot2:::.ignore_data(data)
layout$reset_scales()
layout$train_position(data, scale_x(), scale_y())
layout$setup_panel_params()
data <- layout$map_position(data)
layout$setup_panel_guides(plot@guides, plot@layers)
plot@theme <- ggplot2:::plot_theme(plot)
npscales <- scales$non_position_scales()
if (npscales$n() > 0) {
npscales$set_palettes(plot@theme)
lapply(data, npscales$train_df)
plot@guides <- plot@guides$build(npscales, plot@layers,
plot@labels, data, plot@theme)
data <- lapply(data, npscales$map_df)
}
else {
plot@guides <- plot@guides$get_custom()
}
data <- .expose_data(data)
data <- ggplot2:::by_layer(function(l, d) {
l$compute_geom_2(d, theme = plot@theme)
}, layers, data, "setting up geom aesthetics")
data <- ggplot2:::by_layer(function(l, d) l$finish_statistics(d), layers,
data, "finishing layer stat")
data <- layout$finish_data(data)
plot@labels$alt <- ggplot2:::get_alt_text(plot)
build <- class_ggplot_built_anno(S7::S7_object(),
data = data, layout = layout,
plot = plot, annotation = plot@annotation,
anno_side = plot@anno_side)
class(build) <- union(c("ggplot_built_anno", "ggplot::ggplot_built",
"ggplot_built"),
class(build))
build
}
Finally, we need a method of converting our ggplot_built_anno object into a gtable:
method(ggplot_gtable, class_ggplot_built_anno) <- function (plot) {
built <- ggplot2::ggplot_gtable(plot@built_plot)
manipulate_gtable(built, plot@annotation, plot@anno_side)
}
Now we're done, so testing we get:
p <- mpg |>
ggplot(aes(cty, hwy)) +
geom_point() +
anno_margin("Hello world!", side = "t")
p

And we can modify it as we would modify any ggplot whilst preserving the annotation
p + geom_line() + theme_bw()

And it remains an actual ggplot object
ggplot2::is_ggplot(p)
#> [1] TRUE
anno_marginfunction to convert the plot to the new class. You then need a newggplot_buildmethod for "class_anno_ggplot" that is just a copy of theggplot_buildmethod forclass_ggplot, except it outputs an object of class "class_anno_ggplot_built" which again inherits fromclass_ggplot_built. Finally, you need a new method forggplot_gtablewhich adds your text in extra rows and columns in the gtable or any other grob hacking.