Skip to contents

A vellumwidget widget is interactive on its own (hover, select, brush, pan/zoom) with no server round-trip. Inside Shiny it also reports those interactions back as reactive inputs, so an app can respond to what the user does on the plot: filter a table to the brushed points, show details for the hovered mark, drive another output from the selection.

The two bindings

Render a widget with the standard htmlwidgets pair, exactly like plotly or DT:

library(shiny)
library(vellumplot)
library(vellumwidget)

ui <- fluidPage(
  vellumwidgetOutput("plot", height = "500px"),
  verbatimTextOutput("info")
)

server <- function(input, output) {
  output$plot <- renderVellumwidget({
    vplot(mtcars) |>
      mark_point(
        x = wt, y = mpg,
        tooltip = rownames(mtcars),
        data_id = rownames(mtcars) # <- the key each interaction reports
      ) |>
      as_widget()
  })

  output$info <- renderPrint({
    input$plot_selected # the keys currently selected
  })
}

shinyApp(ui, server)

What the widget reports

The inputs are named <outputId>_<event>, so for vellumwidgetOutput("plot") they are input$plot_selected, input$plot_click, input$plot_hover, input$plot_brush, and input$plot_zoom. Every selection value is expressed in element keys (the data_id you set on the mark), so you map back to your data by that key.

Input Value Kind
input$plot_selected character vector of selected keys (character(0) if none) state
input$plot_click list(key=)key is NULL for an empty-space click event
input$plot_hover the hovered key, or NULL on leave state
input$plot_brush list(keys=, x0=, y0=, x1=, y1=) — keys + region rectangle (device px); a lasso adds lasso=TRUE; a cartesian plot adds data-space bounds x0d,y0d,x1d,y1d + panel event
input$plot_zoom list(x=, y=, w=, h=, zoomed=) — the current view (viewBox, device px) + a zoomed flag; a single-panel cartesian plot adds data=list(x=, y=, panel=) (visible range in data coordinates) state
input$plot_colorfilter c(lo, hi) — value range selected on a continuous colorbar; NULL at the full range state

Data-space fields (x0d/… and zoom$data) appear only when the plot carries a cartesian scale (any vellumplot plot); a raw vellum scene reports device-pixel fields only. They describe the visual axes — x0d/x1d is the horizontal axis, which under coord_flip() is the plot’s y aesthetic. Date/time axes report the numeric epoch (days for Date, seconds for POSIXct) — map back with as.Date() / .POSIXct(). A discrete axis reports fractional band positions, and an axis with a custom scales::transform_*() (beyond identity / log10 / sqrt / reverse) is omitted from the data-space fields.

State vs event. State inputs (_selected, _hover, _zoom) update only when the value changes, so re-selecting the same set (or settling on the same view) does not re-fire; pair them with observe() / reactive(). Event inputs (_click, _brush) fire on every occurrence, even a repeat; pair them with observeEvent().

Reacting to interactions

Because everything is keyed, the common patterns are one-liners. Filter a table to the brushed points:

output$table <- renderTable({
  keys <- input$plot_brush$keys
  if (length(keys)) mtcars[rownames(mtcars) %in% keys, ] else mtcars[0, ]
})

Show the hovered car:

output$detail <- renderText({
  k <- input$plot_hover
  if (is.null(k)) "Hover a point" else paste(k, "-", mtcars[k, "hp"], "hp")
})

Respond to each click:

observeEvent(input$plot_click, {
  key <- input$plot_click$key
  if (!is.null(key)) showNotification(paste("clicked", key))
})

Driving the widget from the server

Reading interactions is one direction; the other is the server changing what the widget shows. vellumwidget_proxy() gives you a handle to a widget that is already on the page and drives it without re-rendering — the SVG is not rebuilt, so the current pan/zoom and the smooth feel are preserved. It works exactly like leaflet::leafletProxy(), DT::dataTableProxy(), or plotly::plotlyProxy().

Call it with the same outputId you gave vellumwidgetOutput(), then pipe the handle through the verbs:

Verb Effect
vw_select(keys) / vw_clear_selection() set / clear the selection
vw_filter(keys) / vw_clear_filter() cross-filter to keys (dim the rest) / remove the filter
vw_zoom(keys) / vw_reset_zoom() frame keys / restore the full view

All keys are the element data_ids — the very same identifiers you get back through input$plot_selected. For example, a selectInput that highlights cars on the plot, and a button that clears everything:

ui <- fluidPage(
  selectInput("pick", "Highlight", choices = rownames(mtcars), multiple = TRUE),
  actionButton("reset", "Reset"),
  vellumwidgetOutput("plot", height = "500px")
)

server <- function(input, output, session) {
  output$plot <- renderVellumwidget({
    vplot(mtcars) |>
      mark_point(x = wt, y = mpg, data_id = rownames(mtcars)) |>
      as_widget()
  })

  # server -> client: highlight and zoom to the picked cars, no re-render
  observeEvent(input$pick, ignoreNULL = FALSE, {
    vellumwidget_proxy("plot") |>
      vw_select(input$pick) |>
      vw_zoom(input$pick) # empty selection resets the view
  })

  observeEvent(input$reset, {
    vellumwidget_proxy("plot") |>
      vw_clear_selection() |>
      vw_reset_zoom()
  })
}

A server-driven vw_select() behaves just like a user click: it projects across a mark’s hover_group, propagates to any linked / crosstalk widgets, and updates input$plot_selected. If you also observe input$plot_selected and call vw_select() from there, guard against the echo (e.g. only act when the value actually differs) to avoid a feedback loop — the usual Shiny proxy caution.

Notes

  • The data_id (mark key) is what everything reports; without it the marks are drawn but carry no identity to report. tooltip is independent; it is what the hover box shows, not what the input carries.
  • The inputs fire only inside a live Shiny session. A static render (knitr, pkgdown, htmltools::save_html()) is byte-for-byte identical and emits nothing.
  • The same keys are what a crosstalk SharedData uses, so a vellumwidget widget can link to DT, plotly, or leaflet client-side without Shiny. See as_widget(crosstalk=). Shiny inputs and crosstalk are complementary: use Shiny when the server needs to react, crosstalk for pure client-side linking.
  • Driving the widget from the server — selection, filter, zoom — is vellumwidget_proxy(), above. Shiny inputs (client → server), crosstalk (client ↔︎ client), and the proxy (server → client) are the three linking directions.

See the interactive widgets tour for the full set of client-side interactions, and the accessibility article for the keyboard and screen-reader model.