rguides

Creating htmlwidgets in R: A Step-by-Step Developer's Guide

The htmlwidgets framework is the bridge between R and JavaScript visualisations. When you call plot_ly(), leaflet(), DT::datatable(), or dygraph(), you are using an htmlwidget: a small R package that ships a JavaScript library and tells R how to feed data into it. If none of the existing widgets fit your needs, you can build your own. This guide walks through creating htmlwidgets in R, from a stub package to a working widget that prints in the console, knits in R Markdown, and reacts in Shiny.

You should be comfortable writing a basic R package, calling devtools::document(), and reading enough JavaScript to copy-paste library examples. You do not need to be a JavaScript expert. That is the whole point of wrapping one.

TL;DR

  • An htmlwidget is a small R package that calls htmlwidgets::createWidget() and ships a JavaScript binding under inst/htmlwidgets/.
  • Four names must match exactly: the R function, the YAML file, the JS file, and the name field inside HTMLWidgets.widget({...}).
  • R serialises the x list to JSON. The JavaScript renderValue(x) function draws from that data and must be safe to call repeatedly.
  • factory runs once per widget instance. renderValue runs every time the data changes. Initialise listeners and allocate objects in factory, not renderValue.
  • Shiny support is two thin helpers, sigmaOutput() and renderSigma(), copied from the official vignette and renamed.

What creating htmlwidgets actually does

An htmlwidget is an R object of class "htmlwidget" produced by htmlwidgets::createWidget(). The object carries a serialised list of data plus a name. When you print() it, htmlwidgets writes an HTML container to the page, injects the widget’s JavaScript dependencies, and calls a renderValue(x) function with the data. The exact same object renders in the RStudio Viewer, in knitr, and in Shiny, because the framework handles every output path.

That portability is the reason packages like plotly, leaflet, and DT all look the same to the user. They each follow this contract. Your widget will get it for free if you do too. For a deeper tour of the framework, read the official htmlwidgets introduction and the advanced topics vignette.

A finished widget behaves like any other R function on the calling side. You build the data, hand it to the constructor, and print() (or just type the name at the prompt) does the rest:

library(leaflet)
m <- leaflet() |>
  addTiles() |>
  addMarkers(lng = -0.118, lat = 51.509, popup = "London")
m  # prints an interactive map in the Viewer pane

That last expression is the framework doing its job: serialising the list, injecting Leaflet’s CSS and JavaScript, and rendering the map in the Viewer pane. The same call inside an R Markdown chunk produces an embedded interactive map; inside a Shiny server function it produces a reactive output. The constructor and the printing method stay identical.

What does the file layout of a widget package look like?

Widget packages are ordinary R packages with three extra files under inst/htmlwidgets/. Using a widget called sigma as the canonical example from the official vignette:

sigma/
  R/
    sigma.R
  inst/
    htmlwidgets/
      sigma.yaml         # dependency declarations
      sigma.js           # JavaScript binding
      lib/
        sigma-1.0.3/     # vendored JS library
          sigma.min.js
          plugins/
            sigma.parsers.gexf.min.js
  DESCRIPTION
  NAMESPACE

DESCRIPTION lists htmlwidgets in Imports, and NAMESPACE exports your user-facing function. The two key files are sigma.yaml and sigma.js, and the base name of each must match the name argument you pass to createWidget(). Mismatches fail silently. The widget renders as an empty <div> and you will spend an hour figuring out why.

If you would rather not type all this by hand, htmlwidgets::scaffoldWidget("sigma") generates the layout for you inside an existing R package. It even vendors a small sample library so you can see the convention in action. The htmlwidgets package on CRAN is the canonical reference, and the source for createWidget lives on GitHub.

How do you declare JavaScript dependencies in YAML?

The YAML file tells htmltools which JavaScript and CSS files to inject. For the sigma widget:

dependencies:
  - name: sigma
    version: 1.0.3
    src: htmlwidgets/lib/sigma-1.0.3
    script:
      - sigma.min.js
      - plugins/sigma.parsers.gexf.min.js

Each entry maps directly to an htmltools::htmlDependency(). The fields you will use most:

  • name and version are required. The version string must be non-empty. htmltools throws "Assertion failed: ..." if you leave it as "".
  • src is a path relative to inst/htmlwidgets/, not relative to the YAML file. The most common bug I see is src: lib/sigma-1.0.3, which resolves to a non-existent inst/htmlwidgets/lib/sigma-1.0.3. Drop the lib/ prefix.
  • script and stylesheet are character vectors of files relative to src.
  • For external CDNs, write src as a named sublist: src: { href: "https://cdn.example.com/foo-1.0/" }. You do not need to vendor the library in that case.

If your widget has no JavaScript dependencies at all (the canonical “hello, world” case), the YAML is one line:

dependencies: []

That single line is enough to register the widget with the framework. htmltools will inject whatever the YAML lists, or nothing at all if the list is empty. The exact behaviour for the empty case is documented under htmltools::htmlDependency.

How do you write the R binding?

The R binding is the function your users will call. One function per widget, named the same as the widget, with @export and @import htmlwidgets in roxygen:

#' @import htmlwidgets
#' @export
sigma <- function(gexf, drawEdges = TRUE, drawNodes = TRUE,
                  width = NULL, height = NULL) {

  data <- paste(readLines(gexf), collapse = "\n")

  settings <- list(
    drawEdges = drawEdges,
    drawNodes = drawNodes
  )

  x <- list(
    data     = data,
    settings = settings
  )

  htmlwidgets::createWidget(
    name   = "sigma",
    x      = x,
    width  = width,
    height = height
  )
}

Two things matter here. First, everything user-controllable goes into x as a named list. That list is serialised to JSON and passed to your JavaScript renderValue. Do not smuggle DOM state in there. Second, width and height default to NULL so the widget fills its container.

If your widget has a fixed natural size, such as a square or a fixed-pixel chart, pass an explicit sizingPolicy to opt out of the default fill behaviour:

htmlwidgets::createWidget(
  name        = "sigma",
  x           = x,
  width       = width,
  height      = height,
  sizingPolicy = htmlwidgets::sizingPolicy(
    browser.fill = TRUE,
    viewer.fill  = TRUE
  )
)

The sizingPolicy here is doing two jobs. It tells the RStudio Viewer and the browser pane to fill their containers. The advanced vignette lists another dozen knobs: viewer.paneHeight, knitr.figure, padding, and more. Most widgets need only the two above, but it is worth knowing the rest exist.

The full createWidget signature, verified against the RDocumentation page for htmlwidgets 1.6.4, is:

htmlwidgets::createWidget(
  name,
  x,
  width         = NULL,
  height        = NULL,
  sizingPolicy  = htmlwidgets::sizingPolicy(),
  package       = name,
  dependencies  = NULL,
  elementId     = NULL,
  preRenderHook = NULL
)

preRenderHook is the one to remember for non-trivial packages. It is an R function called immediately before JSON serialisation, perfect for pre-computing derived data or running a dplyr::mutate() pipeline against the payload. It runs in R, not the browser, so do not try to do DOM work there. The JavaScript for R book shows a full worked example.

How do you write the JavaScript binding?

The JS binding lives in inst/htmlwidgets/<name>.js and registers a widget factory on the global HTMLWidgets object:

HTMLWidgets.widget({

  name: "sigma",
  type: "output",

  factory: function(el, width, height) {
    var sig = new sigma({
      graph: { nodes: [], edges: [] },
      container: el.id
    });
    return {
      renderValue: function(x) {
        sigma.parsers.gexf.parse(x.data, function(graph) {
          sig.graph.clear();
          sig.graph.read(graph);
          sig.refresh();
        });
        sig.settings({
          drawEdges: x.settings.drawEdges,
          drawNodes: x.settings.drawNodes
        });
      },
      resize: function(width, height) {
        sig.refresh();
      }
    };
  }
});

Three methods on the returned object, in order of importance:

  1. renderValue(x) is required. It receives the deserialised data and draws the visualisation. htmlwidgets calls this once on first render and again every time the data changes in Shiny. That means it has to be idempotent. Do not allocate listeners, run animations, or do expensive setup inside it. Do that in factory, which runs once per widget instance.
  2. resize(width, height) is strongly recommended. Called when the container changes size, e.g. when the browser window resizes or Shiny’s outputOptions(suspendWhenHidden = FALSE) triggers a reflow.
  3. The el argument is the host DOM element. Scope any event listeners to it ($(el).on(...) rather than $(document).on(...)) or you will get cross-talk between multiple widget instances on the same page. I learned this the hard way.

A complete minimal example for a “hello, htmlwidgets” widget is the mywidget scaffold that scaffoldWidget("mywidget") produces. Worth running once just to see how little code is required.

How do you add Shiny support?

Every widget that wants to live in a Shiny app needs two helpers: an output function and a render function. The pattern is mechanical and you can copy it from the official vignette, swapping names:

#' @importFrom htmlwidgets shinyWidgetOutput
#' @export
sigmaOutput <- function(outputId, width = "100%", height = "400px") {
  htmlwidgets::shinyWidgetOutput(outputId, "sigma", width, height,
                                 package = "sigma")
}

#' @importFrom htmlwidgets shinyRenderWidget
#' @export
renderSigma <- function(expr, env = parent.frame(), quoted = FALSE) {
  if (!quoted) expr <- substitute(expr)
  htmlwidgets::shinyRenderWidget(expr, sigmaOutput, env, quoted = TRUE)
}

In a Shiny UI, sigmaOutput("network"); in the server, output$network <- renderSigma(sigma(...)). The reactive layer handles data updates by re-running renderValue on the client, which is why renderValue must be safe to call repeatedly. The Mastering Shiny book covers the reactive model in more depth.

A worked example: a tiny toast notifier

To make this concrete, here is a full minimal widget from three short files. The widget just shows a piece of text inside a styled box. It is a clean template to fork.

R/toast.R

#' @import htmlwidgets
#' @export
toast <- function(text, width = NULL, height = NULL) {
  htmlwidgets::createWidget(
    name    = "toast",
    x       = list(text = text),
    width   = width,
    height  = height,
    package = "toast"
  )
}

The R function does three things: it imports the package namespace via roxygen, accepts a plain text argument plus optional CSS dimensions, and assembles a one-key list that becomes the payload. Everything the JavaScript side needs has to live somewhere inside that x list, because htmlwidgets does not pass any other R state across the bridge.

inst/htmlwidgets/toast.yaml

dependencies: []

The YAML is the manifest of JavaScript and CSS files the page should load before the binding runs. For toast there is nothing to load — we draw with inline styles — so the dependency list is empty. The file still has to exist with the same base name as the widget, otherwise the framework refuses to register the binding. This is the cheapest possible YAML you will ever write, and the cheapest possible bug to fix when you inevitably need a real library next time.

inst/htmlwidgets/toast.js

HTMLWidgets.widget({
  name: "toast",
  type: "output",
  factory: function(el, width, height) {
    return {
      renderValue: function(x) {
        el.innerText = x.text;
        el.style.padding = "12px 16px";
        el.style.background = "#fff4e1";
        el.style.border = "1px solid #d6a86c";
        el.style.borderRadius = "6px";
      },
      resize: function(width, height) {}
    };
  }
});

Install with devtools::install(), then call toast("Saved successfully") and you get a styled notification in the Viewer. The same object works in Shiny, R Markdown, and Quarto. Once that clicks, swapping the inline styles for a real CSS file and a real JavaScript library is just a matter of changing the YAML and the renderValue body.

The example also doubles as a good test harness. Drop it into a fresh package, run devtools::document() and devtools::install(), and you can iterate on the JS without touching the R side at all. That separation is half the point of the framework.

Common gotchas

Things that have cost me (or people I work with) real time:

  • Name mismatch. The name argument in createWidget, the YAML base name, the JS file name, and the name field inside HTMLWidgets.widget({...}) must all match exactly. Case-sensitive. Mismatch is silent.
  • renderValue is called repeatedly in Shiny. Anything that should not happen twice per render (DOM listeners, network requests, library initialisation) belongs in factory.
  • State leak between instances. Bind event listeners to el, not to document or window. Two widgets on the same page otherwise share one listener and chaos follows.
  • Wrong src prefix. Paths in src are relative to inst/htmlwidgets/, not to the YAML file. Drop the lib/.
  • Forgetting to reinstall. Editing .yaml or .js and then just re-sourcing the R file does nothing. You need devtools::install() or pkgload::load_all() again to copy the assets.
  • Empty version string. htmltools rejects dependencies with version: "". Pin a real version.

Frequently asked questions

Do I need to learn JavaScript to write an htmlwidget?

No. The whole point of htmlwidgets is to wrap a JavaScript library so that R users never have to touch it. You do need to read the library’s API docs and translate a few examples into the renderValue body, but you do not need to be a JavaScript expert.

Can I wrap a library that uses jQuery or React?

Yes. htmlwidgets ships jQuery globally, so you can use $() inside your binding. For React, render into the host el and call ReactDOM.render(<Component ... />, el) inside renderValue. Just be sure to scope listeners to el so you do not collide with other widgets on the page.

How do I update the widget when a Shiny input changes?

Return the widget from a reactive expression and let Shiny’s normal reactivity handle the rest. Every time the upstream reactive invalidates, htmlwidgets re-serialises x and calls renderValue on the client. That is why renderValue has to be safe to call repeatedly.

What is the difference between the sizingPolicy() defaults and an explicit version?

The defaults make the widget fill its container in the browser and the RStudio Viewer. If your widget has a fixed natural size (an icon, a fixed-pixel chart), pass sizingPolicy(browser.fill = FALSE, viewer.fill = FALSE) and rely on width and height instead.

Where do I publish my widget so others can install it?

Once devtools::check() is clean, submit to CRAN via devtools::submit_cran(). For internal use, install from a private Git repo with remotes::install_github("org/mywidget"). The R packages book covers both paths in full.

Where to go next

The official htmlwidgets vignette is short and worth reading in full at least once. For a larger pattern to imitate, look at the source of plotly, leaflet, or DT on GitHub. Each is a model of how to wrap a complex JavaScript library without leaking abstractions. The JavaScript for R book has a chapter on advanced widget topics including custom JSON serialisation and Crosstalk integration. For working examples of widgets that wrap ggplot2 and React tables, see r-ggiraph and r-reactable-tables. For the surrounding R-package plumbing, the tutorials in the r-package-development series cover inst/ layout, DESCRIPTION/NAMESPACE, and roxygen2 in detail.

A quick way to see the binding machinery under load is to drop the widget into a tiny Shiny app and exercise the reactive path:

library(shiny)
library(toast)

ui <- fluidPage(
  textInput("msg", "Message", "Saved successfully"),
  toastOutput("note")
)

server <- function(input, output, session) {
  output$note <- renderToast(toast(input$msg))
}

shinyApp(ui, server)

Type into the input and watch the renderValue function fire on every keystroke. If the toast updates without flickering and without leaking listeners onto document, you have a widget that will behave correctly when shipped to a dashboard with ten of them on the same page.

Conclusion

Creating htmlwidgets pays off quickly because the same template wraps the next ten libraries you reach for. Once you have one working binding, the R, JavaScript, and Shiny layers stay in sync through the framework rather than through your glue code. Start with scaffoldWidget("mywidget"), fork the toast example above, and ship something useful to your team this week.

See also