Multipanel figures in R

Author

Aggie Turlo

Published

February 4, 2026

Packages required to complete this tutorial:

if (interactive()) {
install.packages('tidyverse')
install.packages('ggplot2')
install.packages('gridExtra')
install.packages('cowplot')
install.packages('ggpubr')
install.packages('grid')
} 

Start with installing and / or loading packages (if already installed).

library(tidyverse)
library(ggplot2)
library(gridExtra)
library(cowplot)
library(ggpubr)
library(grid)

We also need to load our training dataset MSC_proteomics.csv (make sure your working directory is set to a folder that stores this Quarto file and the csv file).

data <- read.csv('MSC_proteomics.csv')

Why assemble figures in R?

There are many ways for assembling multi-panel figures in R. The main advantages for doing it within R (instead of exporting individual figures and assembling them in an image manipulation program) are:

  • full traceability and reproducibility

  • automation (should you ever need to change input data)

  • full control of figure sizes and alignment

  • option to create figures with shared legends / common axes

Most of this tutorial concerns assembling figures produced by ggplot2. However, it may be worth being aware of figure assembly options available in basic R. This could be handy for producing a quick overview of data if you are not too fussed about making it pretty.

Basic R

Example where you may choose to use basic R is to have a quick overview of distributions of multiple variables at once, like protein abundances in MSC_proteomics dataset. You do not mean to publish those figures so limited aesthetics of basic R plots do not really matter here.

# let's check the column names to see where metadata ends and protein variables start
colnames(data)[1:10]
 [1] "ID"         "Tissue"     "Age"        "Sex"        "A0A3Q2I4N1"
 [6] "A0A3Q2GS39" "A0A3Q2I6U4" "O46403"     "A0A3Q2GSM3" "F6WKA8"    
# we can see that protein names start with the 5th column
# plot abundance distribution of the first protein using basic R
hist(data[,5], main = colnames(data)[5], xlab = 'abundance')

Now let’s make histograms for multiple variables. We can do it using the for loop and basic R function par().

# this function divides plotting space into rows and columns
par(mfrow = c(3,3)) # first number in brackets is the column number

# any basic R plots called after calling par() will be distributed in that pattern
# the for loop to plot first 9 protein abundances
for(i in c(5:13)){
  hist(data[,i], main = colnames(data)[i], xlab = 'abundance')
}

Note: If you call more plots than the capacity of the grid (e.g. more than 6 in this example), R studio will output multiple multi-panel figures that you can flip through in the ‘Plots’ tab.

Another handy function is pairs() for creating a matrix of scatterplots to visualise relationships between all numerical variables. Let’s apply this to variable Age and first 3 protein variables.

pairs(data[,c(3,5:7)]) # selecting columns that store age and protein abundances

If you’d like to look only at relationships between age and proteins, you can do this using par with for loop.

par(mfrow = c(3,3))

for(i in c(5:13)){
  plot(data[,i], data[,3], main = colnames(data)[i], 
       xlab = 'abundance', ylab = 'age')
}

To summarise, use basic R figure assembly if:

  • you would like a quick overview of multiple simple (histogram, scatter) plots

  • you do not care about figure asethetics / they are not meant for publication

Ggplot2 generated plots

There are multiple packages that allow assembly of ggplot2 generated plots, including gridExtra, cowplot and ggpubr. They offer different level of control over small details of figure presentation.

gridExtra

This package is closest to the basic R functions for figure assembly but it does offer higher level of control over figure layout. The main difference is that we need to save ggplot2 plots into objects, and then pass these objects to functions. First, let’s make some plots using ggplot2 package.

# the histogram that we plotted with basic R
hist <- data %>% 
  ggplot(aes(x = A0A3Q2I4N1)) + 
  geom_histogram(bins = 6) +
  ggtitle('A0A3Q2I4N1') + xlab('abundance')

hist # we need to call the plot object to display the figure

# histogram of Age variable
hist1 <- data %>% 
  ggplot(aes(x = Age)) + 
  geom_histogram(bins = 6) +
  ggtitle('Age') + xlab('years')

hist1

# scatterplot showing relationship between that protein abundance and age
scatter <- data %>%
  ggplot(aes(x = A0A3Q2I4N1, y = Age)) +
  geom_point()

scatter

# boxplot showing expression of that protein by tissue type
box <- data %>%
  ggplot(aes(x = Tissue, y = A0A3Q2I4N1)) +
  geom_boxplot()

box

Figures can be then assembled using grid.arrange or arrangeGrob function. The main difference between them is that grid.arrange displays multi-panel plot while arrangeGrob allows to save it to an object (to be displayed or exported).

grid.arrange(hist, hist1, scatter, box, 
             ncol = 2, nrow = 2) # arguments take the grid dimensions, similar to par()

# it allows for assymetrical layouts too
layout <- rbind(c(1,3), # layout is defined by data frame 
                c(2,3), # each row/column represents row/column in multi-pael figure
                c(4,3)) # each number represents one figure

# so the layout defines how much space in each row and column is taken by each figure 
layout
     [,1] [,2]
[1,]    1    3
[2,]    2    3
[3,]    4    3
# numbers in layout corresponds to the order in which plots are passed to grid.arrange
grid.arrange(hist, hist1, scatter, box, 
             layout_matrix = layout) # layout is passed to this argument

To be able to export a multi-panel figure (e.g. with ggsave), it needs to be saved to an object using arrangeGrob.

# this function takes the same arguments as grid.arrange
multiP <- arrangeGrob(hist, hist1, scatter, box, 
             layout_matrix = layout)

# to display the figure it needs to be called with plot() function
# plot(multiP)

# exporting with ggsave takes that object as an argument
ggsave('Figure1.tiff', multiP)

gridExtra functions also have additional arguments for figure formatting.

grid.arrange(hist, hist1, scatter, box, 
             layout_matrix = layout,
             widths = c(0.3, 0.6), # change relative widths of columns
             padding = unit(2, 'line'), # increase top margin
             top = textGrob('This is a multi-panel figure', # add common title
                            gp = gpar(fontsize = 16))) # change title size

Let’s try to use these functions to plot multiple histograms, like at the beginning of this tutorial. Instead of the for loop we will use apply function family.

# specify proteins for plotting
prots <- colnames(data)[5:13]

# use lapply to create a list of plots iterating through the values from prots vector
histograms <- lapply(prots, FUN = function(p){
  p <- data %>% 
  ggplot(aes(x = .data[[p]])) + # this is a workaround to pass character string as ggplot argument
  geom_histogram(bins = 6) +
  ggtitle(p) + xlab('abundance')})


# make multi-panel figure using plots from the list
do.call( # apply function to individual objects from a list
  grid.arrange, 
  c(histograms, nrow = 3, ncol = 3)
  )

Unlike par(), if you pass more plots to grid.arrange than specified by ncol and nrow argument, it will crash instead of plotting them on multiple pages. However, you can do that with another function marrangeGrob.

# this function can take a list of plots directly without using do.call 
marrangeGrob(histograms, nrow = 2, ncol = 3)

To summarise, use gridExtra assembly:

  • to get overview of many figures at once or create exportable multi-panel figure

  • if you need a figure suitable for a presentation / report

cowplot

If your aim is to prepare publication-ready figures, you will end up reaching for cowplot (that is also used by ggpubr). This package gives you really detailed control over plot alignment as well as allows to draw labels for figure subpanels. To demonstrate what cowplot is capable of, we will revamp our multi-panel figure.

# the cowplot figure canvas has relative dimensions 0-1 on each axis
ggdraw() + # initialises empty drawing canvas, always needs to be called first
  draw_plot(hist, # specify plot to draw
            x = 0, y = 0.64, # positioning on canvase (left bottom corner)
            width = 0.3, height = 0.3) + # exact dimensions
  draw_plot(hist1, 
            x = 0, y = 0.34, 
            width = 0.3, height = 0.3) + 
  draw_plot(box, 
            x = 0, y = 0, 
            width = 0.3, height = 0.34) +
  draw_plot(scatter, 
            x = 0.35, y = 0, 
            width = 0.6, height = 0.89) +
  draw_plot_label(label  = c('A', 'B', 'C', 'D'), # labels
                  x = c(0, 0, 0, 0.35), # label positioning
                  y = c(0.93, 0.64, 0.4, 0.93)) + # label positioning
  draw_plot_label(label = 'This is a multi-panel figure',
                  x = 0.1, y = 1)

As you see, cowplot goes beyond allocating each plot to a fraction of a column/row - it gives absolute control over dimensions and positioning of sub-panels and any text that you may want to add to the figure.

To summarise, use cowplot assembly if:

  • you work with ggplot2 figures

  • you need a high-quality publication-ready multi-panel figure that you can fully adjust

ggpubr

This is a most automated / convenience package that uses ggplot, cowplot and gridExtra for making comprehensive plots (the trade-off is low level of control over detail).

Multipanel figure assembly with ggarrange uses row / column positioning as in gridExtra but offers some extra modalities.

# let's add colour argument to some of our plots to show one of ggarrange arguments
scatter1 <- data %>%
  ggplot(aes(x = A0A3Q2I4N1, y = Age, colour = Sex)) +
  geom_point()

# let's make an extra plot too
box2 <- data %>%
  ggplot(aes(x = Tissue, y = Age, colour = Sex)) +
  geom_boxplot()

# assymetrical layouts require nested assembly
ggarrange(
  ggarrange(hist, hist1, box, nrow = 3, # first figure block (left column)
            labels = c('A', 'B', 'C') # automated label placement
            #, vjust = 0.4 # labels position can be adjusted but NOT individually
            ), 
  ggarrange(box2, scatter1, # second figure block (right column)
  nrow = 2,
  labels = c('D','E'),
  common.legend = TRUE, # shared legend between two plots
  legend = 'bottom'), # legend positioning
  ncol = 2, # positioning of the blocks 
  widths = c(0.3, 0.6))

Faceting

If you are working with long data format and need to create a figure with limited number of sub-panels (fitting on one page), facet_wrap from ggplot2 package is a useful function.

# first we need to change data format from wide to long
dataL <- data %>% 
  pivot_longer(cols =! c(ID, Tissue, Age, Sex), # specify columns that are NOT pivoted
               names_to = 'protein',
               values_to = 'abundance')

dataL %>%
  filter(protein %in% prots) %>% # choose small number of protein to plot
  ggplot(aes(x = abundance)) +
  geom_histogram(bins = 6) +
  facet_wrap(~protein, # choose variable that you would like each plot to represent
             scale = 'free_x') + # allow each plot to have different scale on x axis
  theme(strip.background = element_rect(fill = 'white')) # changes title background colour

As with all ggplot figures, you can modify all aspects of the plot aesthetics using dedicated arguments (some of them specific to faceted figures like strip.background above).

Useful resources