Interactive volcano plots in R with canvasXpress

R
canvasXpress
bioinformatics
visualization
Build a publication-ready, interactive volcano plot from a differential-expression table — and save it as a portable spec you can reproduce anywhere.
Author

Isaac Neuhaus

Published

September 18, 2026

A volcano plot is the workhorse of differential-expression analysis: log fold change on x, statistical significance on y, and the interesting genes pushed out to the top corners. In this post we build one in R with canvasXpress — interactive (zoom, hover, filter), and, importantly, reproducible: the whole figure is a single portable specification you can store, validate and re-render from R, Python or JavaScript.

Install

install.packages("canvasXpress")

A differential-expression table

For a self-contained example we simulate 1,000 genes. Swap this block for your own results table (e.g. the output of limma::topTable() or DESeq2::results()) — you need a log fold change and a significance value per gene.

library(canvasXpress)

set.seed(1)
n     <- 1000
genes <- paste0("Gene", seq_len(n))

# Simulate a realistic volcano: most genes have no real effect, a minority are
# differentially expressed. Significance follows effect size / standard error, which
# is what gives a volcano its shape.
de     <- runif(n) < 0.12                                   # ~12% truly changed
sgn    <- sample(c(-1, 1), n, replace = TRUE)
effect <- ifelse(de, sgn * (0.8 + 0.9 * runif(n)), 0)       # true log2FC
se     <- 0.22 + 0.5 * runif(n)                             # per-gene standard error
logFC  <- round(effect + rnorm(n, 0, se), 3)               # observed log2FC
pval   <- 2 * pnorm(-abs(logFC / se))                       # z-test p-value
negLogP <- round(pmin(-log10(pval), 16), 3)

# Classify each gene by effect size + significance
group <- ifelse(logFC >  1 & negLogP > 1.3, "Increased",
         ifelse(logFC < -1 & negLogP > 1.3, "Decreased", "NoChange"))

canvasXpress expects a numeric matrix/data frame of values (genes in rows) plus an optional variable annotation table carrying per-gene attributes — here the regulation call and the fold change we’ll use for point size.

y <- data.frame(
  "logFC"     = logFC,
  "-log-pVal" = negLogP,
  row.names   = genes,
  check.names = FALSE
)

varAnnot <- data.frame(
  Group = group,
  FC    = round(2^abs(logFC), 2),
  row.names   = genes,
  check.names = FALSE
)

The plot

canvasXpress(
  data     = y,
  varAnnot = varAnnot,
  graphType = "Scatter2D",
  xAxis    = list("logFC"),
  yAxis    = list("-log-pVal"),
  colorBy  = "Group",
  colorKey = list(Group = list(
    Increased = "rgba(197,27,38,0.75)",
    Decreased = "rgba(33,102,172,0.75)",
    NoChange  = "rgba(150,150,150,0.35)"
  )),
  sizeBy   = "FC",
  sizes    = c(3, 6, 9, 12, 15),
  title    = "Publication-ready volcano plot",
  xAxisTitle = "log2 fold change",
  yAxisTitle = "-log10 p-value",
  showDecorations = TRUE,
  decorations = list(line = list(
    list(color = "rgba(120,120,120,0.8)", width = 1, x =  1),
    list(color = "rgba(120,120,120,0.8)", width = 1, x = -1),
    list(color = "rgba(120,120,120,0.8)", width = 1, y = 1.301)
  )),
  width  = 650,
  height = 500
)

The chart is interactive: hover a point for its gene and values, drag to zoom, or use the toolbar to filter by regulation group. The dashed guides mark the ±1 log2FC and the p = 0.05 cutoffs.

Why “reproducible”

Everything above serializes to one JSON specification — the data and the config. That spec is portable: the identical figure renders from the Python (canvasxpress) and JavaScript packages, and it can be validated against a published schema and re-rendered later, so a figure in a paper can be regenerated and audited rather than just looked at.

Where to go next