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:
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.
Install the required packages outside the Rmd file:
| 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 |
A Plotly figure has four important parts:
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] 1Use 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.
plot_ly() figureThe 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_firstThe 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.
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_mappingArea 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_bubbleFor 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.
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.
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_layeredwave <- 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_waveLine 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.
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_airsplitstock_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_stocksPlotly 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.
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_bartitanic_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_barbarmode = "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.
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_histogramp_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_boxp_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_violinMatrix-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.
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_heatmapp_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_surfacePerspective and occlusion can make 3D values difficult to compare. Include a heatmap, contour view, or table when exact comparison is important.
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.
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_ggplotlyg_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_ggfacetlayout() 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_layoutImportant 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", ...)) |
hovertemplate and
customdatahovertemplate 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_hoverWhen 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.
subplot() and CrosstalkCrosstalk 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_linkedframe and stable idsAnimation 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_animationAnimation 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.
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_geoChoropleths 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.
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.
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.
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.
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.
Browser modebar downloads are convenient for readers, but a scripted export is more reproducible because dimensions and versions are recorded.
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| 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:
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.31You 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.