The V Lab
AudienceR users who want browser-based interactive graphics
Scope18 sections · 23 interactive figures
FormatRunnable examples with no API key or external data

1 How to use this guide

This guide introduces the Plotly package for R through complete examples that run inside an R Markdown document. Every executed example uses data bundled with R or ggplot2. The finished HTML is self-contained: the code, data, Plotly JavaScript runtime, and figure specifications travel in one file.

Plotly figures support several browser-side actions without a server:

  1. hover to inspect exact values;
  2. drag to zoom or select points;
  3. double-click to reset an axis;
  4. click legend items to hide or show traces;
  5. use the modebar to pan, zoom, autoscale, or download a browser-rendered image.

Plotly is most useful when interaction answers a real question: Which observation is this? What happens in this time window? How do these groups compare? Interaction should reveal information, not compensate for an unclear chart.

1.1 Packages and datasets

Install the required packages outside the Rmd file:

install.packages(c(
  "plotly", "ggplot2", "htmlwidgets", "crosstalk", "plotlyGeoAssets"
))
Dataset Contents Examples in this guide
mtcars Performance and design for 32 cars Scatterplots, bubbles, hover, linking
iris Flower measurements for three species Mappings, boxplots, violins, facets
AirPassengers Monthly airline passengers, 1949–1960 Time series, controls, subplots
EuStockMarkets Four European stock indices Grouped line traces
Titanic Passenger counts in a four-way table Stacked bars
volcano Elevation matrix Heatmap and 3D surface
dim(cars)
#> [1] 32 12
head(cars[, c("model", "mpg", "wt", "hp", "cyl")], 4)

2 Plotly’s figure model

A Plotly figure has four important parts:

  • data: one or more traces, such as scatter, bar, box, heatmap, or surface;
  • layout: titles, axes, legends, annotations, shapes, margins, and camera settings;
  • config: modebar and browser behavior that is outside the analytical figure specification;
  • frames: optional states used by animation.

A trace is a coherent set of marks with one type, styling rule, legend entry, and hover behavior. One chart may contain many traces. R formulas such as x = ~wt tell Plotly to find wt inside the supplied data frame.

model_demo <- plot_ly(
  cars,
  x = ~wt,
  y = ~mpg,
  type = "scatter",
  mode = "markers"
)

built_demo <- plotly_build(model_demo)
names(built_demo$x)
#>  [1] "visdat"      "cur_data"    "attrs"       "layout"      "source"      "config"     
#>  [7] "data"        "highlight"   "shinyEvents" "base_url"
length(built_demo$x$data)
#> [1] 1

Use plotly_build() to inspect the final structure after formulas, mappings, defaults, and trace splitting have been resolved. plotly_json() is useful when debugging the exact JSON sent to Plotly.js.

3 Your first plot_ly() figure

The safest teaching and production style is to specify both type and mode. Here, type = "scatter" selects the scatter trace family and mode = "markers" asks it to draw points rather than lines.

p_first <- plot_ly(
  cars,
  x = ~wt,
  y = ~mpg,
  text = ~model,
  type = "scatter",
  mode = "markers",
  marker = list(size = 10, color = "#0b6b66", opacity = 0.76),
  hovertemplate = paste(
    "<b>%{text}</b>",
    "Weight: %{x:.2f} (1000 lb)",
    "Fuel economy: %{y:.1f} mpg",
    "<extra></extra>",
    sep = "<br>"
  )
) |>
  plotly::layout(
    title = list(text = "Lighter cars generally use less fuel"),
    xaxis = c(list(title = "Weight (1000 lb)"), axis_clean),
    yaxis = c(list(title = "Fuel economy (mpg)"), axis_clean),
    hovermode = "closest"
  ) |>
  finish_plotly()

p_first

Interactive figure 1. Hover over a point for the car name, drag a rectangle to zoom, and double-click to reset.

The final empty <extra></extra> removes Plotly’s secondary trace-name box from the tooltip. A title and axis labels remain necessary even when hover labels are available, because readers should understand the chart before interacting.

4 Scatter traces and visual mappings

4.1 Color and symbol mappings

Arguments such as color = ~Species, symbol = ~Species, size = ~hp, and split = ~group map variables to visual properties and often split data into traces. A literal wrapped in I() is treated as a fixed visual value instead of a data mapping.

p_mapping <- plot_ly(
  iris,
  x = ~Sepal.Length,
  y = ~Petal.Length,
  color = ~Species,
  symbol = ~Species,
  colors = c("#0b6b66", "#a4422f", "#3d7085"),
  symbols = c("circle", "diamond", "square"),
  type = "scatter",
  mode = "markers",
  marker = list(size = 9, opacity = 0.72),
  hovertemplate = paste(
    "Species: %{fullData.name}",
    "Sepal length: %{x:.1f}",
    "Petal length: %{y:.1f}",
    "<extra></extra>",
    sep = "<br>"
  )
) |>
  plotly::layout(
    title = list(text = "Two encodings make species easier to distinguish"),
    xaxis = c(list(title = "Sepal length (cm)"), axis_clean),
    yaxis = c(list(title = "Petal length (cm)"), axis_clean),
    legend = list(orientation = "h", x = 0, y = -0.2)
  ) |>
  finish_plotly()

p_mapping

Interactive figure 2. Species is encoded by both color and symbol, so the grouping does not depend on color alone.

4.2 Bubble size

Area is harder to compare accurately than position. Use bubble size for an approximate third variable, keep a reasonable size range, and retain the exact value in hover text.

p_bubble <- plot_ly(
  cars,
  x = ~wt,
  y = ~mpg,
  size = ~hp,
  sizes = c(9, 38),
  customdata = ~hp,
  color = ~factor(cyl),
  colors = c("#3d7085", "#8a5a10", "#a4422f"),
  text = ~model,
  type = "scatter",
  mode = "markers",
  marker = list(opacity = 0.66, line = list(color = "white", width = 1)),
  hovertemplate = paste(
    "<b>%{text}</b>",
    "Weight: %{x:.2f}",
    "MPG: %{y:.1f}",
    "Horsepower: %{customdata:.0f}",
    "<extra></extra>",
    sep = "<br>"
  )
) |>
  plotly::layout(
    title = list(text = "Bubble size represents horsepower"),
    xaxis = c(list(title = "Weight (1000 lb)"), axis_clean),
    yaxis = c(list(title = "Fuel economy (mpg)"), axis_clean),
    legend = list(title = list(text = "Cylinders"))
  ) |>
  finish_plotly()

p_bubble

Interactive figure 3. Position carries the main comparison; bubble area adds horsepower and color adds cylinder count.

For a fixed color, use color = I(“#0b6b66”) in a high-level mapping argument, or use marker = list(color = “#0b6b66”). Writing color = “#0b6b66” can be interpreted as a scale input rather than the constant you intended.

5 Layering traces with add_*()

add_trace() is the general interface. Helpers such as add_markers(), add_lines(), add_bars(), add_segments(), and add_text() make intent easier to read. Each layer can inherit the initial data and mappings, use different data, or set inherit = FALSE and specify everything itself.

5.1 Observations and a fitted line

fit <- lm(mpg ~ wt, data = cars)
fit_line <- data.frame(wt = seq(min(cars$wt), max(cars$wt), length.out = 100))
fit_line$mpg <- predict(fit, newdata = fit_line)

p_layered <- plot_ly() |>
  add_markers(
    data = cars,
    x = ~wt,
    y = ~mpg,
    text = ~model,
    name = "Cars",
    marker = list(size = 9, color = "#3d7085", opacity = 0.68),
    hovertemplate = "<b>%{text}</b><br>Weight: %{x:.2f}<br>MPG: %{y:.1f}<extra></extra>",
    inherit = FALSE
  ) |>
  add_lines(
    data = fit_line,
    x = ~wt,
    y = ~mpg,
    name = "Linear fit",
    line = list(color = "#a4422f", width = 3),
    hovertemplate = "Weight: %{x:.2f}<br>Fitted MPG: %{y:.1f}<extra></extra>",
    inherit = FALSE
  ) |>
  plotly::layout(
    title = list(text = "Different traces may use different data"),
    xaxis = c(list(title = "Weight (1000 lb)"), axis_clean),
    yaxis = c(list(title = "Fuel economy (mpg)"), axis_clean)
  ) |>
  finish_plotly()

p_layered

Interactive figure 4. Raw observations and model predictions are separate traces with separate hover templates.

5.2 Multiple traces from one data frame

wave <- data.frame(
  x = seq(0, 2 * pi, length.out = 240)
)
wave$sin_x <- sin(wave$x)
wave$cos_x <- cos(wave$x)

p_wave <- plot_ly(wave, x = ~x) |>
  add_lines(y = ~sin_x, name = "sin(x)", line = list(color = "#0b6b66", width = 3)) |>
  add_lines(y = ~cos_x, name = "cos(x)", line = list(color = "#a4422f", width = 3, dash = "dot")) |>
  plotly::layout(
    title = list(text = "Legend items correspond to traces"),
    xaxis = c(list(title = "x"), axis_clean),
    yaxis = c(list(title = "Function value", range = c(-1.1, 1.1)), axis_clean),
    legend = list(orientation = "h", x = 0, y = -0.2)
  ) |>
  finish_plotly()

p_wave

Interactive figure 5. Click a legend item to hide one function; double-click it to isolate that trace.

6 Lines, dates, and time series

Line charts encode an ordered path. Sort rows by the x variable within every group before plotting. Use add_lines() for x-ordered series and add_paths() only when the row order itself represents a trajectory.

6.1 Dates, range selectors, and a range slider

air <- data.frame(
  date = seq(as.Date("1949-01-01"), by = "month", length.out = length(AirPassengers)),
  passengers = as.numeric(AirPassengers)
)

p_air <- plot_ly(
  air,
  x = ~date,
  y = ~passengers,
  type = "scatter",
  mode = "lines",
  line = list(color = "#0b6b66", width = 2),
  hovertemplate = "%{x|%b %Y}<br>%{y:,.0f} thousand passengers<extra></extra>"
) |>
  plotly::layout(
    title = list(text = "Monthly airline passengers, 1949–1960"),
    xaxis = c(
      list(
        title = NULL,
        rangeslider = list(visible = TRUE, thickness = 0.09),
        rangeselector = list(
          buttons = list(
            list(count = 12, label = "1 year", step = "month", stepmode = "backward"),
            list(count = 36, label = "3 years", step = "month", stepmode = "backward"),
            list(step = "all", label = "All")
          )
        )
      ),
      axis_clean
    ),
    yaxis = c(list(title = "Passengers (thousands)"), axis_clean),
    margin = list(t = 70, b = 70)
  ) |>
  finish_plotly()

p_air

Interactive figure 6. Use the buttons or bottom range slider to inspect a shorter time window.

6.2 Multiple series with split

stock_matrix <- as.matrix(EuStockMarkets)
stocks <- data.frame(
  observation = rep(seq_len(nrow(stock_matrix)), times = ncol(stock_matrix)),
  value = as.vector(stock_matrix),
  market = rep(colnames(stock_matrix), each = nrow(stock_matrix))
)

p_stocks <- plot_ly(
  stocks,
  x = ~observation,
  y = ~value,
  split = ~market,
  color = ~market,
  colors = c("#0b6b66", "#a4422f", "#3d7085", "#76618c"),
  type = "scatter",
  mode = "lines",
  line = list(width = 1.4),
  hovertemplate = "%{fullData.name}<br>Observation: %{x}<br>Index: %{y:,.1f}<extra></extra>"
) |>
  plotly::layout(
    title = list(text = "split creates one trace per market"),
    xaxis = c(list(title = "Trading-day observation"), axis_clean),
    yaxis = c(list(title = "Index level"), axis_clean),
    hovermode = "x unified"
  ) |>
  finish_plotly()

p_stocks

Interactive figure 7. Unified hover compares all four indices at the same x position.

7 Bars and categorical comparisons

Plotly bar traces draw values you supply. Unlike geom_bar() in ggplot2, add_bars() does not automatically count rows. Summarize first, then choose a bar mode that matches the question.

7.1 Grouped bars

gear_cyl <- as.data.frame(table(
  Cylinders = factor(cars$cyl),
  Gears = factor(cars$gear)
))

p_grouped_bar <- plot_ly(
  gear_cyl,
  x = ~Cylinders,
  y = ~Freq,
  color = ~Gears,
  colors = c("#0b6b66", "#8a5a10", "#a4422f"),
  type = "bar",
  hovertemplate = "Cylinders: %{x}<br>Cars: %{y}<extra>Gears %{fullData.name}</extra>"
) |>
  plotly::layout(
    title = list(text = "Grouped bars compare absolute counts"),
    xaxis = list(title = "Cylinders"),
    yaxis = c(list(title = "Number of cars", rangemode = "tozero"), axis_clean),
    barmode = "group",
    bargap = 0.18,
    legend = list(title = list(text = "Gears"))
  ) |>
  finish_plotly()

p_grouped_bar

Interactive figure 8. Grouped bars make within-cylinder gear counts easy to compare.

7.2 Stacked bars

titanic_counts <- aggregate(
  Freq ~ Class + Survived,
  data = as.data.frame(Titanic),
  FUN = sum
)

p_stacked_bar <- plot_ly(
  titanic_counts,
  x = ~Class,
  y = ~Freq,
  color = ~Survived,
  colors = c("#a4422f", "#0b6b66"),
  type = "bar",
  hovertemplate = "%{x}<br>Passengers: %{y}<extra>Survived: %{fullData.name}</extra>"
) |>
  plotly::layout(
    title = list(text = "Stacked bars show total size and composition"),
    xaxis = list(title = "Passenger class"),
    yaxis = c(list(title = "Passenger count"), axis_clean),
    barmode = "stack",
    legend = list(title = list(text = "Survived"))
  ) |>
  finish_plotly()

p_stacked_bar

Interactive figure 9. Stacking preserves totals, but only segments sharing a baseline are easy to compare.

barmode = "relative" allows positive and negative bars to extend in opposite directions. Plotly does not automatically turn stacked bars into 100% bars; calculate proportions before drawing them.

8 Distributions and statistical traces

8.1 Histograms and bin choices

p_histogram <- plot_ly(
  faithful,
  x = ~waiting,
  type = "histogram",
  nbinsx = 20,
  marker = list(color = "#3d7085", line = list(color = "white", width = 1)),
  hovertemplate = "Waiting-time bin: %{x}<br>Observations: %{y}<extra></extra>"
) |>
  plotly::layout(
    title = list(text = "Old Faithful waiting times"),
    xaxis = c(list(title = "Waiting time (minutes)"), axis_clean),
    yaxis = c(list(title = "Count"), axis_clean),
    bargap = 0.04
  ) |>
  finish_plotly()

p_histogram

Interactive figure 10. A histogram is interactive, but the analytical conclusion still depends on the bin choice.

8.2 Boxplots and individual points

p_box <- plot_ly(
  iris,
  x = ~Species,
  y = ~Sepal.Width,
  color = ~Species,
  colors = c("#0b6b66", "#a4422f", "#3d7085"),
  type = "box",
  boxpoints = "outliers",
  jitter = 0.25,
  pointpos = 0,
  hovertemplate = "%{fullData.name}<br>Sepal width: %{y:.1f} cm<extra></extra>"
) |>
  plotly::layout(
    title = list(text = "Boxplots summarize grouped distributions"),
    xaxis = list(title = NULL),
    yaxis = c(list(title = "Sepal width (cm)"), axis_clean),
    showlegend = FALSE
  ) |>
  finish_plotly()

p_box

Interactive figure 11. Hover an outlier for its exact value; an outlier is not automatically a data error.

8.3 Violin plots

p_violin <- plot_ly(
  iris,
  x = ~Species,
  y = ~Petal.Length,
  color = ~Species,
  colors = c("#0b6b66", "#a4422f", "#3d7085"),
  type = "violin",
  box = list(visible = TRUE),
  meanline = list(visible = TRUE),
  points = FALSE,
  hoveron = "violins+points",
  hovertemplate = "%{fullData.name}<br>Petal length: %{y:.1f} cm<extra></extra>"
) |>
  plotly::layout(
    title = list(text = "Violins show shape; boxes show robust summaries"),
    xaxis = list(title = NULL),
    yaxis = c(list(title = "Petal length (cm)"), axis_clean),
    showlegend = FALSE
  ) |>
  finish_plotly()

p_violin

Interactive figure 12. Density width is estimated; check sample sizes before interpreting fine bumps.

9 Matrices, heatmaps, and 3D surfaces

Matrix-like traces accept z values arranged by rows and columns. A heatmap usually communicates comparisons more accurately than a 3D surface; use 3D when rotation and shape exploration add genuine value.

9.1 Heatmap

p_heatmap <- plot_ly(
  x = seq_len(ncol(volcano)),
  y = seq_len(nrow(volcano)),
  z = volcano,
  type = "heatmap",
  colors = colorRamp(c("#e9f5f1", "#3d7085", "#8a5a10", "#a4422f")),
  colorbar = list(title = "Elevation"),
  hovertemplate = "Column: %{x}<br>Row: %{y}<br>Elevation: %{z}<extra></extra>"
) |>
  plotly::layout(
    title = list(text = "The volcano matrix as a heatmap"),
    xaxis = list(title = "Grid column", constrain = "domain"),
    yaxis = list(title = "Grid row", scaleanchor = "x")
  ) |>
  finish_plotly()

p_heatmap

Interactive figure 13. Hover reads one matrix cell; equal axis scaling preserves the grid geometry.

9.2 3D surface

p_surface <- plot_ly(
  z = volcano,
  type = "surface",
  colors = colorRamp(c("#e9f5f1", "#3d7085", "#8a5a10", "#a4422f")),
  colorbar = list(title = "Elevation"),
  hovertemplate = "x: %{x}<br>y: %{y}<br>z: %{z}<extra></extra>"
) |>
  plotly::layout(
    title = list(text = "Rotate a 3D surface to explore terrain"),
    scene = list(
      xaxis = list(title = "Grid x"),
      yaxis = list(title = "Grid y"),
      zaxis = list(title = "Elevation"),
      aspectmode = "data",
      camera = list(eye = list(x = 1.45, y = 1.35, z = 0.85))
    ),
    margin = list(l = 10, r = 10, b = 10, t = 70)
  ) |>
  finish_plotly()

p_surface

Interactive figure 14. Drag to rotate, scroll to zoom, and double-click to restore the camera.

Perspective and occlusion can make 3D values difficult to compare. Include a heatmap, contour view, or table when exact comparison is important.

10 Making ggplot2 interactive with ggplotly()

ggplotly() converts an existing ggplot object into Plotly traces. It is the quickest route when a polished static ggplot already exists. The result is a translation, not a pixel-perfect copy: unsupported geoms, theme details, annotations, and guide behavior may change.

10.1 Converting a scatterplot

g_scatter <- ggplot(
  cars,
  aes(
    wt,
    mpg,
    color = factor(cyl),
    text = paste0(
      "<b>", model, "</b>",
      "<br>Weight: ", format(wt, digits = 3),
      "<br>MPG: ", mpg,
      "<br>Horsepower: ", hp
    )
  )
) +
  geom_point(size = 3, alpha = 0.72) +
  scale_color_manual(values = c("4" = "#3d7085", "6" = "#8a5a10", "8" = "#a4422f")) +
  labs(
    title = "A ggplot can become interactive",
    x = "Weight (1000 lb)",
    y = "Fuel economy (mpg)",
    color = "Cylinders"
  ) +
  theme_minimal(base_size = 12) +
  theme(legend.position = "bottom", panel.grid.minor = element_blank())

p_ggplotly <- ggplotly(g_scatter, tooltip = "text") |>
  finish_plotly()

p_ggplotly

Interactive figure 15. The text aesthetic supplies a deliberately limited tooltip instead of exposing every mapped variable.

10.2 Facets and selected tooltips

g_facet <- ggplot(
  iris,
  aes(
    Sepal.Length,
    Petal.Length,
    text = paste0(
      "Species: ", Species,
      "<br>Sepal length: ", Sepal.Length,
      "<br>Petal length: ", Petal.Length
    )
  )
) +
  geom_point(color = "#0b6b66", alpha = 0.7) +
  facet_wrap(~Species, nrow = 1) +
  labs(
    title = "Facets translate into Plotly subplots",
    x = "Sepal length (cm)",
    y = "Petal length (cm)"
  ) +
  theme_minimal(base_size = 11) +
  theme(panel.grid.minor = element_blank())

p_ggfacet <- ggplotly(g_facet, tooltip = "text") |>
  plotly::layout(hovermode = "closest") |>
  finish_plotly()

p_ggfacet

Interactive figure 16. Faceting works well for common geoms, but inspect every converted panel and legend before publishing.

11 Layout, axes, legends, annotations, and controls

layout() receives nested lists that mirror Plotly.js attributes. Use it for analytical presentation. Use config() for modebar and browser behavior. Keeping these roles separate makes reusable functions easier to reason about.

peak_row <- air[which.max(air$passengers), ]

p_layout <- plot_ly(
  air,
  x = ~date,
  y = ~passengers,
  type = "scatter",
  mode = "lines+markers",
  line = list(color = "#3d7085", width = 2),
  marker = list(size = 4),
  hovertemplate = "%{x|%b %Y}<br>%{y:,.0f} thousand<extra></extra>"
) |>
  plotly::layout(
    title = list(
      text = "Air travel grew while seasonal variation widened<br><sup>Monthly international airline passengers</sup>",
      x = 0,
      xanchor = "left"
    ),
    xaxis = c(list(title = NULL, tickformat = "%Y"), axis_clean),
    yaxis = c(list(title = "Passengers (thousands)", rangemode = "tozero"), axis_clean),
    hovermode = "x unified",
    annotations = list(list(
      x = peak_row$date,
      y = peak_row$passengers,
      text = "Series maximum",
      showarrow = TRUE,
      arrowcolor = "#a4422f",
      ax = -55,
      ay = -45
    )),
    shapes = list(list(
      type = "line",
      x0 = min(air$date),
      x1 = max(air$date),
      y0 = mean(air$passengers),
      y1 = mean(air$passengers),
      line = list(color = "#a4422f", dash = "dash", width = 1.5)
    )),
    margin = list(l = 70, r = 25, b = 55, t = 90)
  ) |>
  plotly::config(
    displaylogo = FALSE,
    responsive = TRUE,
    scrollZoom = FALSE,
    modeBarButtonsToRemove = c("lasso2d")
  )

p_layout

Interactive figure 17. Layout adds a subtitle, annotation, mean reference line, unified hover, and deliberate margins.

Important layout patterns include:

Task Attribute
Use a logarithmic axis yaxis = list(type = "log")
Fix the visible range xaxis = list(range = c(min, max))
Keep equal x/y units yaxis = list(scaleanchor = "x")
Move a horizontal legend legend = list(orientation = "h")
Compare traces at one x hovermode = "x unified"
Draw a reference region shapes = list(list(type = "rect", ...))

12 Precise hover with hovertemplate and customdata

hovertemplate gives exact control over content and formatting. Plotly variables use %{...} placeholders and D3-style format strings. Add metadata through text or customdata; do not paste every available column into every tooltip.

hover_matrix <- cbind(cars$hp, cars$qsec)

p_hover <- plot_ly(
  cars,
  x = ~wt,
  y = ~mpg,
  text = ~model,
  customdata = hover_matrix,
  type = "scatter",
  mode = "markers",
  marker = list(size = 10, color = "#0b6b66", opacity = 0.75),
  hovertemplate = paste(
    "<b>%{text}</b>",
    "Weight: %{x:.2f} (1000 lb)",
    "Fuel economy: %{y:.1f} mpg",
    "Horsepower: %{customdata[0]:.0f}",
    "Quarter mile: %{customdata[1]:.2f} s",
    "<extra></extra>",
    sep = "<br>"
  )
) |>
  plotly::layout(
    title = list(text = "customdata keeps metadata attached to each point"),
    xaxis = c(list(title = "Weight (1000 lb)"), axis_clean),
    yaxis = c(list(title = "Fuel economy (mpg)"), axis_clean)
  ) |>
  finish_plotly()

p_hover

Interactive figure 18. customdata[0] and [1] use JavaScript’s zero-based indexing.

When customdata combines columns of different R types, cbind() may coerce everything to character. Keep values numeric when applying numeric format strings, or send a deliberately prepared data structure.

13 Coordinated views with subplot() and Crosstalk

13.1 Stacked panels with a shared x-axis

air$change <- c(NA, diff(air$passengers))
air_change <- air[!is.na(air$change), ]

p_level <- plot_ly(
  air,
  x = ~date,
  y = ~passengers,
  type = "scatter",
  mode = "lines",
  line = list(color = "#0b6b66", width = 2),
  name = "Level",
  hovertemplate = "%{x|%b %Y}<br>Level: %{y:,.0f}<extra></extra>"
)

p_change <- plot_ly(
  air_change,
  x = ~date,
  y = ~change,
  type = "bar",
  marker = list(color = ifelse(air_change$change >= 0, "#3d7085", "#a4422f")),
  name = "Monthly change",
  hovertemplate = "%{x|%b %Y}<br>Change: %{y:+,.0f}<extra></extra>"
)

p_subplot <- subplot(
  p_level,
  p_change,
  nrows = 2,
  heights = c(0.64, 0.36),
  shareX = TRUE,
  titleX = FALSE,
  margin = 0.055
) |>
  plotly::layout(
    title = list(text = "Level and month-to-month change"),
    showlegend = FALSE,
    hovermode = "x unified"
  ) |>
  finish_plotly()

p_subplot

Interactive figure 19. Shared x-axes align the level with its month-to-month change.

subplot() accepts widths, heights, nrows, shareX, and shareY. Small margins save space, but leave enough room for tick labels and titles. Conflicting layouts are merged; inspect the result after composition.

13.2 Client-side linked selection

Crosstalk can link Plotly views in a static HTML file. highlight_key() attaches a stable key; highlight() defines how a browser selection dims and emphasizes observations. No Shiny server is required for this client-side link.

shared_cars <- highlight_key(cars, ~model, group = "cars-link")

p_weight <- plot_ly(
  shared_cars,
  x = ~wt,
  y = ~mpg,
  type = "scatter",
  mode = "markers",
  marker = list(size = 9, color = "#3d7085", opacity = 0.75),
  text = ~model,
  hovertemplate = "%{text}<br>Weight: %{x:.2f}<br>MPG: %{y:.1f}<extra></extra>"
)

p_power <- plot_ly(
  shared_cars,
  x = ~hp,
  y = ~qsec,
  type = "scatter",
  mode = "markers",
  marker = list(size = 9, color = "#8a5a10", opacity = 0.75),
  text = ~model,
  hovertemplate = "%{text}<br>HP: %{x}<br>Quarter mile: %{y:.2f}s<extra></extra>"
)

p_linked <- subplot(p_weight, p_power, margin = 0.08, titleX = TRUE, titleY = TRUE) |>
  plotly::layout(
    title = list(text = "Drag a selection in either panel"),
    dragmode = "select",
    showlegend = FALSE
  ) |>
  highlight(
    on = "plotly_selected",
    persistent = TRUE,
    dynamic = FALSE,
    color = "#a4422f",
    opacityDim = 0.18
  ) |>
  finish_plotly()

p_linked

Interactive figure 20. Select points in one panel to highlight the same car models in both panels; double-click to clear.

14 Animation with frame and stable ids

Animation should show meaningful change over an ordered state. A stable string ids value tells Plotly which object persists across frames, enabling smoother transitions and reducing visual identity swaps.

animation_data <- do.call(
  rbind,
  lapply(seq_len(10), function(frame_no) {
    id <- seq_len(8)
    angle <- 2 * pi * (id - 1) / 8 + (frame_no - 1) * pi / 20
    data.frame(
      id = paste0("point-", id),
      frame = sprintf("%02d", frame_no),
      x = cos(angle),
      y = sin(angle),
      value = id
    )
  })
)

p_animation <- plot_ly(
  animation_data,
  x = ~x,
  y = ~y,
  frame = ~frame,
  ids = ~id,
  color = ~factor(value),
  colors = colorRamp(c("#0b6b66", "#3d7085", "#a4422f")),
  type = "scatter",
  mode = "markers+text",
  text = ~value,
  textposition = "middle center",
  marker = list(size = 34, line = list(color = "white", width = 1)),
  hovertemplate = "Object %{text}<extra></extra>",
  showlegend = FALSE
) |>
  animation_opts(frame = 650, transition = 320, easing = "cubic-in-out", redraw = FALSE) |>
  animation_slider(currentvalue = list(prefix = "Frame: ")) |>
  animation_button(label = "Play") |>
  plotly::layout(
    title = list(text = "Stable IDs preserve identity across frames"),
    xaxis = list(title = NULL, range = c(-1.35, 1.35), visible = FALSE),
    yaxis = list(title = NULL, range = c(-1.35, 1.35), visible = FALSE, scaleanchor = "x"),
    margin = list(l = 20, r = 20, b = 80, t = 70)
  ) |>
  finish_plotly()

p_animation

Interactive figure 21. The play button and slider control frames; each numbered object keeps the same ID.

Animation can attract attention even when it harms comparison. Small multiples or a static before/after view are often better when readers must compare exact states.

15 Geographic figures with plot_geo()

plot_geo() creates geographic traces without a Mapbox token. The example below uses state abbreviations already bundled with R. offline = TRUE asks the companion plotlyGeoAssets package—required to render this guide in full—to provide local map assets.

if (!requireNamespace("plotlyGeoAssets", quietly = TRUE)) {
  stop("Install plotlyGeoAssets to render the offline map example.")
}

states <- data.frame(
  state = state.name,
  code = state.abb,
  income = state.x77[, "Income"],
  population = state.x77[, "Population"]
)

p_geo <- plot_geo(states, offline = TRUE) |>
  add_trace(
    z = ~income,
    locations = ~code,
    locationmode = "USA-states",
    type = "choropleth",
    text = ~state,
    customdata = ~population,
    colors = "Blues",
    colorbar = list(title = "Per-capita income"),
    marker = list(line = list(color = "white", width = 0.6)),
    hovertemplate = paste(
      "<b>%{text}</b>",
      "Per-capita income (1974 dollars): %{z:,.0f}",
      "Population (thousands): %{customdata:,.0f}",
      "<extra></extra>",
      sep = "<br>"
    )
  ) |>
  plotly::layout(
    title = list(text = "State per-capita income in 1974"),
    geo = list(
      scope = "usa",
      projection = list(type = "albers usa"),
      showlakes = TRUE,
      lakecolor = "white",
      bgcolor = "rgba(0,0,0,0)"
    ),
    margin = list(l = 0, r = 0, b = 0, t = 70)
  ) |>
  finish_plotly()

p_geo

Interactive figure 22. This choropleth uses local state codes and map assets; it requires no API key.

Choropleths emphasize large areas, not only values. Use rates rather than raw counts when population exposure differs, choose defensible classing or a continuous scale, and provide a non-map comparison when rank matters.

16 Events and Shiny integration

Static HTML can zoom, hover, select, animate, toggle traces, and perform Crosstalk linking in the browser. It cannot run new R calculations after publication. Server-side event handling requires Shiny (or another application runtime).

The key pieces are:

  • source: names the widget whose events should be read;
  • key: preserves a meaningful observation identifier;
  • event_data(): reads clicks, selections, hover, relayout, or other events in a reactive context;
  • plotlyProxy(): updates an existing widget without rebuilding all UI.
library(shiny)
library(plotly)

cars <- data.frame(model = rownames(mtcars), mtcars, row.names = NULL)

ui <- fluidPage(
  plotlyOutput("cars_plot"),
  verbatimTextOutput("clicked")
)

server <- function(input, output, session) {
  output$cars_plot <- renderPlotly({
    plot_ly(
      cars,
      x = ~wt,
      y = ~mpg,
      key = ~model,
      source = "cars",
      type = "scatter",
      mode = "markers"
    )
  })

  output$clicked <- renderPrint({
    event_data("plotly_click", source = "cars")
  })
}

shinyApp(ui, server)

Common event names include plotly_click, plotly_hover, plotly_selected, plotly_brushing, plotly_relayout, and plotly_legendclick. Register less common events with event_register() when necessary.

Calling event_data() in an ordinary static Rmd does not make the published page reactive. It must run inside a Shiny reactive context. Use Crosstalk for supported client-side linking on static pages.

17 Saving, bundling, and publishing HTML

17.1 Save one widget

htmlwidgets::saveWidget() writes a Plotly widget independently of R Markdown. Its argument is spelled selfcontained; R Markdown YAML uses self_contained.

htmlwidgets::saveWidget(
  p_first,
  file = "car_scatter.html",
  selfcontained = TRUE,
  title = "Interactive car scatterplot"
)

With selfcontained = FALSE, the HTML depends on a companion directory such as car_scatter_files/. Publish both together and preserve their relative paths.

17.2 Reduce the JavaScript bundle

partial_bundle() can replace the full Plotly.js library with a smaller bundle for one widget:

small_widget <- partial_bundle(p_first, type = "auto", local = TRUE)
htmlwidgets::saveWidget(small_widget, "car_scatter_small.html", selfcontained = TRUE)

Do not mix incompatible partial bundles on the same page: the first loaded Plotly.js bundle can be reused by later widgets and may not contain their trace modules. This guide contains scatter, bar, violin, heatmap, surface, animation, and geo traces, so it intentionally uses the complete bundle.

17.3 Save a static image

Use save_image() for reproducible PNG, SVG, PDF, or WebP output. It requires a local Python environment with Kaleido configured through reticulate; it does not require a Plotly cloud account.

plotly::save_image(
  p_first,
  file = "car_scatter.png",
  width = 1400,
  height = 900,
  scale = 1
)

Browser modebar downloads are convenient for readers, but a scripted export is more reproducible because dimensions and versions are recorded.

18 Performance, debugging, and reproducibility

18.1 WebGL for many points

SVG scatter traces provide excellent browser interaction for modest data. For tens of thousands of points, use type = "scattergl" or toWebGL(), reduce tooltip content, and consider sampling or binning before adding more hardware.

large_points <- data.frame(
  x = rnorm(20000),
  y = 0.65 * rnorm(20000) + rnorm(20000, sd = 0.55),
  group = sample(c("A", "B"), 20000, replace = TRUE)
)

p_webgl <- plot_ly(
  large_points,
  x = ~x,
  y = ~y,
  color = ~group,
  colors = c("#3d7085", "#a4422f"),
  type = "scattergl",
  mode = "markers",
  marker = list(size = 4, opacity = 0.32),
  hoverinfo = "skip"
) |>
  plotly::layout(
    title = list(text = "WebGL keeps 20,000 points responsive"),
    xaxis = c(list(title = "x"), axis_clean),
    yaxis = c(list(title = "y"), axis_clean),
    legend = list(title = list(text = "Group"))
  ) |>
  finish_plotly()

p_webgl

Interactive figure 23. WebGL improves rendering throughput, but aggregation may communicate a dense distribution more honestly.

18.2 Debugging checklist

Symptom Likely cause Recommended check or fix
A column name is not found Formula syntax was omitted Use x = ~column with data = ...
A fixed color creates an odd scale Constant and mapping semantics were mixed Use I("#hex") or a nested marker/line list
Lines jump backward Rows are unsorted Sort by x within every group before plotting
Legends repeat Several traces have the same semantic role Set showlegend = FALSE selectively or share a legendgroup
Hover shows too much Default tooltip includes all mappings Supply hovertemplate or ggplotly(..., tooltip = ...)
Numeric hover formatting fails customdata became character Keep the custom-data matrix numeric
A converted ggplot looks different Translation does not support every geom/theme detail Inspect ggplotly() output and build natively when needed
Animation objects swap identity Stable string IDs are missing Supply ids = ~stable_id
event_data() returns nothing on GitHub Pages Static HTML has no R server Run inside Shiny or use Crosstalk for client-side links
Published HTML is broken A _files dependency folder was omitted Use self-contained HTML or publish the companion folder
A later trace disappears after bundling Incompatible partial bundles share the page Use one compatible bundle or full Plotly.js
Browser interaction is slow Too many SVG marks or heavy hover text Use scattergl, sampling, binning, or aggregation

Inspect a difficult figure before guessing:

built <- plotly_build(p_first)
str(built$x$data, max.level = 2)

plotly_json(p_first, pretty = TRUE)

# The full Plotly.js schema is large; query it only when needed.
names(schema())

18.3 A reliable workflow

  1. Define the analytical question and the unit represented by one mark.
  2. Start with the smallest useful trace and correct labels.
  3. Add only the hover fields that help identify or compare observations.
  4. Add traces, subplots, or controls only when they answer a specific question.
  5. Test legend toggles, zoom reset, selections, animation, and mobile width.
  6. Decide whether the artifact is static HTML, a Shiny app, or a static image.
  7. Record package versions and keep data preparation reproducible.

18.4 Version information and official references

cat("R:", R.version.string, "\n")
#> R: R version 4.6.1 (2026-06-24)
cat("plotly:", as.character(packageVersion("plotly")), "\n")
#> plotly: 4.12.1
cat("ggplot2:", as.character(packageVersion("ggplot2")), "\n")
#> ggplot2: 4.0.3
cat("htmlwidgets:", as.character(packageVersion("htmlwidgets")), "\n")
#> htmlwidgets: 1.6.4
cat("crosstalk:", as.character(packageVersion("crosstalk")), "\n")
#> crosstalk: 1.2.2
cat("knitr:", as.character(packageVersion("knitr")), "\n")
#> knitr: 1.51
cat("rmarkdown:", as.character(packageVersion("rmarkdown")), "\n")
#> rmarkdown: 2.31

You now have a complete path from a first plot_ly() scatterplot to layered traces, converted ggplots, controlled hover, subplots, linked selections, animation, maps, publishing, Shiny events, and WebGL performance. The best interactive figure remains one whose question and comparison are clear before the reader touches it.