One general approach is to use grobs (Grid objects) and use a rectGrob
object to be displayed under the axis text. I'll demonstrate with an example shown here.
library(ggplot2)
set.seed(8675309)
df <- data.frame(
x=paste0('Test', 1:10),
y=rnorm(10, 10)
)
ggplot(df, aes(x,y)) +
geom_col(color='black', fill='gray', alpha=0.8) +
scale_y_continuous(expand=expansion(mult=c(0,0.05))) +
coord_cartesian(clip='off') +
labs(x=NULL) +
theme_classic()

Display the Boxes
To create the grob, you can use the grid
package and rectGrob
. Since we want to draw many boxes, we can supply a vector for x
(to draw one at x positions 1 through 10), and then supply the fill colors via a vector sent to fill
. Note that when using grobs, you can supply the various parameters through the gp
argument inside of gpar()
. Unlike a ggplot geom, the grobs are not matched to a data frame, so you'll have to manually specify the way colors/sizes are mapped via vectors as I've done here.
library(grid)
muh_grob <- grid::rectGrob(
x=1:10, y=0, gp=gpar(
color='black', fill=rainbow(10), alpha=0.2))
To use the grob, you can use annotation_custom()
, where you need to specify the min and max values of y and x. You'll have to likely mess around with the numbers to get things to look right. Note the values are in npc, so 0 is left and 1 is all the way on the right in x axis here (discrete values). It's also very important that you include coord_*(clip="off")
. It can be any of the coord_
functions, but you need clipping off or you will not be able to see the grob. I've also applied a margin to the top of the x axis text to move it downward a bit and make room for the box around it.
ggplot(df, aes(x,y)) +
geom_col(color='black', fill='gray', alpha=0.8) +
scale_y_continuous(expand=expansion(mult=c(0,0.05))) +
coord_cartesian(clip='off') +
labs(x=NULL) +
theme_classic() +
theme(
axis.text.x = element_text(margin=margin(t=10)),
) +
annotation_custom(
grob=muh_grob, xmin = 0, xmax = 1, ymin = -0.5, ymax=0.1
)

Multiple Facets
OP shared a plot with two facets that contained these boxes... so how to do that? Well, it's not quite as straightforward to do, since annotation_custom()
is applied the same way to each facet. Each facet shares the same values of x and y, so if you specify a grob is from xmin=0
and xmax=0.5
, this will apply your grob to the left side of each facet.
To get around this, there is a very nice adjustment to the method provided in another answer here, represented below:
library(gridExtra)
annotation_custom2 <-
function (grob, xmin = -Inf, xmax = Inf, ymin = -Inf, ymax = Inf, data)
{
layer(data = data, stat = StatIdentity, position = PositionIdentity,
geom = ggplot2:::GeomCustomAnn,
inherit.aes = TRUE, params = list(grob = grob,
xmin = xmin, xmax = xmax,
ymin = ymin, ymax = ymax))
}
I'll then make a left and right grob, and apply it using that function, which allows us to specify a data
argument to annotation_custom2()
and place the grob on one facet.
muh_left_grob <- rectGrob(
x=1:5, y=0, gp=gpar(color='black', fill='red', alpha=seq(0.7, 0.1, length.out = 5)))
muh_right_grob <- rectGrob(
x=1:5, y=0, gp=gpar(color='black', fill='blue', alpha=seq(0.7, 0.1, length.out = 5)))
ggplot(df, aes(x,y)) +
geom_col(color='black', fill='gray', alpha=0.8) +
scale_y_continuous(expand=expansion(mult=c(0,0.05))) +
coord_cartesian(clip='off') +
labs(x=NULL) +
theme_classic() +
theme(axis.text.x = element_text(margin=margin(t=10))) +
facet_wrap(~my_facet, scales='free_x') +
annotation_custom2(
data=subset(df, my_facet=='A Facet'), grob=muh_left_grob,
xmin=0, xmax=1, ymin=-0.5, ymax=0.1) +
annotation_custom2(
data=subset(df, my_facet=='Another Facet'), grob=muh_right_grob,
xmin=0, xmax=1, ymin=-0.5, ymax=0.1)
