Skip to contents

vellumwidget turns a vellum scene, or more usually a vellumplot plot, into a self-contained, client-side interactive HTML widget. There is one verb, as_widget(), and it works the same in the RStudio/Positron viewer, a knitr/Quarto document, and a Shiny app. No Shiny server and no round-trip are required; every interaction runs in the browser.

Interactivity is declared in the grammar: a mark’s data_id, tooltip, and hover_group arguments flow through the scene as per-element metadata that the widget reads. A plot that declares none still renders as a static (but embeddable) SVG.

The widget itself draws nothing. vellum hands it the rendered SVG plus scene_model(), one row per drawn element carrying that element’s data key and its resolved device-pixel box, and everything below is that table plus event handlers. Two things follow. What you interact with is the same scene as the static figure, not a second rendering of your plot. And gestures that need geometry rather than styling — brushing a rectangle, zooming to a region, stepping between marks with the arrow keys — are table lookups rather than new drawing code.

Everything here scales to a few thousand marks on the per-element SVG path. Past that, the widget switches to a raster strategy so a 100k-point scatter stays navigable; see Very large scenes.

library(vellumplot)
df <- data.frame(
  wt = mtcars$wt, mpg = mtcars$mpg,
  model = rownames(mtcars), cyl = factor(mtcars$cyl)
)

Hover: tooltips and highlighting

Map tooltip and data_id and hover a point: it shows a tooltip and dims the others. data_id is the join key; tooltip is what the box says.

vplot(df) |>
  mark_point(x = wt, y = mpg, color = cyl, tooltip = model, data_id = model) |>
  as_widget()

HTML tooltips

Tooltip text is rendered as safe HTML: build a multi-line, formatted string in tooltip = (here with glue) using <b> and <br>. Data values are escaped, and only inert tags (<b>, <i>, <br>, <span>) are honoured, so there is no injection risk. Style the box with tooltip_style.

df$label <- glue::glue("<b>{df$model}</b><br>{df$mpg} mpg · {df$wt}k lbs")
vplot(df) |>
  mark_point(x = wt, y = mpg, tooltip = label, data_id = model) |>
  as_widget(tooltip_style = list(background = "#1d3557", fontsize = "13px"))

Tune its behaviour too: tooltip_delay waits a beat before showing (calmer on a dense scatter), tooltip_follow = FALSE anchors it above the mark instead of tracking the cursor, and tooltip_sticky = TRUE lets you move into the tooltip — useful when the HTML holds a link or button. The box auto-flips below the cursor near the top edge and stays within the widget.

Shared tooltips and a crosshair

On a single scatter, hovering the nearest point is what you want. On a multi-series line or time-series chart you usually want the opposite: every series’ value at the cursor’s x, in one box. Set hover_mode = "x" for that unified hover — every mark sharing the hovered x highlights together and its tooltip joins one combined box. "y" does the same along y; "closest" (the default) keeps the single-nearest behaviour. Turn on crosshair = TRUE to drop a guide rule at the hovered position (vertical in "x" mode, horizontal in "y", a full cross in "closest").

long <- data.frame(
  t = rep(1:12, 2),
  y = c(cumsum(rnorm(12, 1)), cumsum(rnorm(12, 0.5))),
  series = rep(c("A", "B"), each = 12)
)
vplot(long) |>
  mark_line(x = t, y = y, color = series) |>
  mark_point(x = t, y = y, color = series, tooltip = y, data_id = interaction(series, t)) |>
  as_widget(hover_mode = "x", crosshair = TRUE)

Unified hover reads the mark positions straight from the element index — no axis metadata is needed — so it works on any keyed plot and in raster mode. It snaps along its axis, so the shared readout tracks the cursor anywhere in the plot. In Shiny, input$<id>_hover still reports the single nearest mark.

Select and brush

Click a mark to select it (every mark sharing its data_id toggles together); drag a rectangle to brush-select. select_mode = "single" makes a click replace the selection instead of toggling. There is also a freehand lasso — switch to it with the toolbar’s mode button (which cycles brush → lasso → pan) and drag a loop; every mark whose centre falls inside is selected. Hover, click-select, brush, and lasso are all on by default.

vplot(df) |>
  mark_point(x = wt, y = mpg, data_id = model, selected_color = "#e63946") |>
  as_widget()

This works for every keyed mark, not just points. A statistical mark draws several shapes that share one key — an error bar is a bar plus two caps, a boxplot box is a rectangle plus median and whiskers — so hovering, selecting, or brushing any part lights up and toggles the whole mark as a unit (a boxplot’s outliers stay individually addressable). Declare data_id/tooltip on mark_errorbar(), mark_linerange(), or mark_boxplot() the same way.

df2 <- data.frame(g = c("A", "B", "C"), y = c(20, 26, 23), lo = c(17, 24, 21), hi = c(23, 28, 25))
vplot(df2) |>
  mark_errorbar(x = g, ymin = lo, ymax = hi, data_id = g, tooltip = g) |>
  as_widget()

Interactive legends

A discrete color or shape scale draws an interactive legend automatically: hover a swatch to highlight its series, click to select it. Set legend_click = "hide" to make the legend a visibility toggle instead — a single click hides or shows a series, and a double-click isolates it (hides every other series; double-click again to bring them all back). This is the reflexive legend interaction from plotly and friends. "mute" dims the series rather than removing it, keeping the layout steady.

vplot(df) |>
  mark_point(x = wt, y = mpg, color = cyl, data_id = model) |>
  as_widget(legend_click = "hide")

Hovering a swatch still highlights its series under every policy, and a hidden series drops out of hover and tooltip hit-testing. It works from the keyboard too (Tab to a swatch, Enter/Space to toggle) and is independent of any crosstalk cross-filter.

Filtering by a continuous colorbar

When a plot maps a continuous color scale, its colorbar becomes an interactive filter: drag the two handles to a value range and marks outside it fade out (and stop responding to hover/brush); double-click the bar to reset. In Shiny the range arrives as input$<id>_colorfilter. It’s automatic — no argument — whenever there’s a continuous colorbar.

vplot(df) |>
  mark_point(x = wt, y = mpg, color = mpg, data_id = model) |>
  as_widget()

Pan, zoom, and the toolbar

Zoom with the mouse wheel, drag to pan (the toolbar’s mode button cycles brush → lasso → pan). On a touch device, drag pans and a two-finger pinch zooms; with the widget focused, the arrow keys pan, +/- zoom, and 0 resets. The on-hover toolbar adds zoom-to-selection, reset, SVG/PNG download, copy-to-clipboard (where supported), and fullscreen.

Axis-aware zoom

Zoom scales only the plot’s data region and re-ticks the axes for the visible range, holding the frame — axes, titles, legend — in place, the way a charting library zooms (rather than scaling the whole scene like an image, where the tick labels grow and blur). Hover, brush, the crosshair, and the reported data range all follow the zoomed region. This is on by default:

vplot(df) |>
  mark_point(x = wt, y = mpg, tooltip = model) |>
  as_widget()

It applies to a single linear cartesian panel (continuous identity/reverse axes) in SVG mode, and is on by default. Plots with log/date/discrete axes, several panels, or in raster mode silently fall back to the whole-scene zoom, so it is always safe.

Glyph marks (points, circles, hexagons) keep a constant pixel size as you zoom — only their positions re-map, so points stay round and never stretch into ellipses. Bars, error bars, and lines still scale with the data (their extent is data); only their stroke width is held constant.

Overview navigator

For long series, navigator = TRUE adds a strip below the plot showing the whole scene in miniature, with a draggable, resizable window marking the visible x-range. Drag the window to pan, drag a handle to zoom — it stays in sync with the main view (wheel, keyboard, brush, and linked-group changes all move it).

The navigator is an x-range tool, so zoom is one-dimensional: the selected x-range fills the width while the full y-range stays on screen (values never scroll off the top or bottom), the way a time-series range selector behaves. With axis-aware zoom on (the default), the x-axis re-ticks crisply as you scrub; on a plot that falls back to whole-scene zoom the view stretches horizontally instead (so point marks scale anisotropically) — the navigator is aimed at line/area/series charts.

long <- data.frame(t = 1:200, y = cumsum(rnorm(200)))
vplot(long) |>
  mark_line(x = t, y = y) |>
  as_widget(navigator = TRUE)

Export captures the current view, so a zoomed-in region exports as shown. Set the download name and a hi-res PNG scale:

vplot(df) |>
  mark_point(x = wt, y = mpg, data_id = model) |>
  as_widget(export_filename = "mtcars", export_scale = 2)

Accessibility

Accessibility is on by default (a11y = TRUE). The widget announces itself as an interactive chart, and every mark is focusable: Tab into the chart, use the arrow keys to move between marks (each announced through a polite live region), Enter/Space to select, and Escape to leave traversal mode. A visually-hidden data table lists every mark for screen-reader users. The chart’s accessible name and description come from the plot’s title and alt text (which vellumplot sets automatically) or an explicit as_widget(alt =).

See the vellumplot Accessibility article for the full cross-package story (alt text, accessible SVG/PDF, and this widget).

Linked views

Widgets sharing a group link client-side: selecting or brushing in one highlights the same data keys in the others, and panning or zooming one moves the others to the same relative view — with no Shiny and no crosstalk. Selection projects by hover_group when the marks declare it. Linked pan/zoom shares the view as a fraction of each widget’s own extent, so it stays aligned even when the linked plots are different sizes (small multiples). Try zooming one below — the other follows.

p <- vplot(df) |> mark_point(x = wt, y = mpg, data_id = model, hover_group = cyl)
htmltools::tagList(
  as_widget(p, group = "cars", width = 320, height = 240),
  as_widget(vplot(df) |> mark_point(x = wt, y = mpg, data_id = model, hover_group = cyl),
            group = "cars", width = 320, height = 240)
)

For interop with plotly / leaflet / DT and crosstalk’s filter_* controls, pass a crosstalk::SharedData (or a group name) to crosstalk = instead — see Linking views with crosstalk.

Declarative interactivity (in the plot spec)

The interactions above are the widget’s built-in defaults. For interaction that is part of the plot — travelling with the spec, portable, and composable across views — declare it in vellumplot itself. A selection is a named set of elements defined by a gesture; refer to it from condition() (style by membership), filter_by() (show only members), or across views for cross-filtering. No as_widget() flags are involved.

Highlight on hover — colour by group, but dim everything except the hovered point’s group:

library(vellumplot)
vplot(df) |>
  mark_point(x = wt, y = mpg, color = condition("hi", cyl, "grey85")) |>
  select_point("hi", on = "hover") |>
  as_widget()

Cross-filter — brush one view, a linked view narrows to those rows while the source stays full:

sel <- select_interval("brush", on = "xy")
hconcat(
  vplot(df) |> mark_point(x = wt, y = mpg) |> add_selection(sel),
  vplot(df) |> mark_point(x = mpg, y = wt) |> filter_by(sel)
) |>
  as_widget()

This is the intended long-term home for interaction: it is declared once, in the plot, and any capable host enacts it. See the vellumplot reference for select_point() / select_interval() / condition() / filter_by().

Styling

tooltip_style themes the tooltip box (above). Per-mark hover/selected outlines are declared in vellumplot (mark_*(hover_color =, selected_color =)); richer per-element styling by selection membership uses condition() (above).

Shiny

vellumwidgetOutput() / renderVellumwidget() embed a widget in a Shiny app, and the widget reports the user’s selection, clicks, hovers, brush, and current view back to the server as reactive inputs. For a vellumplot plot the brush and the view also report data-space coordinates (not just pixels) — e.g. input$plot_brush$x0d/x1d and input$plot_zoom$data$x — so the server can act on data ranges directly. See the Shiny article and ?vellumwidget-shiny.