opensci.devopensci.dev

Convert any Javascript Module to an AnyWidget

You already have a working JavaScript visualisation. Maybe it’s a plotting library, a custom renderer, a data explorer — something you built or use already that does exactly what you need. Now you want to embed it as an interactive widget inside a Curvenote article, so you can share and publish online.

This tutorial walks through how to do that using the {anywidget} directive[1].

We’ll cover two scenarios:

  1. The module is already published on a CDN like esm.sh — no build step needed.

  2. The module is local code in your repo — we’ll set up a minimal esbuild bundler.

Clone the companion repo!

Our running example is grid-painter, a small library that renders a 2-D grid of coloured cells from a numeric matrix using scientific colourmaps (viridis, inferno, plasma).

The full source and build system is in the companion repo stevejpurves/curvenote-anywidget-tutorial on GitHub.


What is AnyWidget?

AnyWidget is a specification for interactive widgets that works across multiple platforms; Curvenote, Jupyter, JupyterBook, Marimo, Colab, Streamlit. You write a single ESM module that exports a render function, and the host environment handles instantiation, parameter passing, and lifecycle.

For basic rendering contract is small[2]:

1
2
3
4
5
6
function render({ model, el }) {
  // model — read/write reactive parameters
  // el    — the widget's root DOM element
}

export default { render };

That’s it. The host calls render() once per widget instance, passes a DOM element for you to fill, and provides a model object for reading the parameters declared in the {anywidget} directive.

What about {any:bundle}?

Before we ported our implementation upstream into the MyST Markdown project, it was available in Curvenote first under {any:widget} and {any:bundle} directives, now these are all aliases for {anywidget} which has the broadest support. Please {anywidget} in your work going forward.


Case 1: Wrapping a Published CDN Module (No Build Step)

If your target library is available on https://esm.sh or another ESM CDN, you can write the AnyWidget module as a single file with no bundler at all.

Suppose a library fancy-grid were published on npm. Your widget file would be:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// widget.mjs — no build step, just a file next to your MyST article
import { createGrid } from "https://esm.sh/fancy-grid@1.0.0";

function render({ model, el }) {
  const data = model.get("data") || [[0, 1], [2, 3]];
  const colormap = model.get("colormap") || "viridis";

  const grid = createGrid(el, { data, colormap });

  // Return a cleanup function
  return () => grid.destroy();
}

export default { render };

Then reference it directly in your Curvenote article:

1
2
3
4
5
6
```{anywidget} ./widget.mjs
{
  "data": [[1, 2, 3], [4, 5, 6]],
  "colormap": "viridis"
}
```

Advantages: Zero tooling, no package.json, no node_modules.

Limitations: No CSS bundling (you’d inject styles inline), no tree-shaking, dependent on CDN availability, and every import is a network request at load time.

You can still add css here

Add css (if you have it) at any time by adding the following to your directive

1
2
3
4
5
6
7
```{anywidget} ./widget.mjs
:css: ./styles.css
{
  "data": [[1, 2, 3], [4, 5, 6]],
  "colormap": "viridis"
}
```

For anything else, you’ll want Case 2.


Case 2: Wrapping Local Code with esbuild

This is the common case: you have a JavaScript module in your repo and you want to bundle it into a single file that AnyWidget can load. The companion stevejpurves/curvenote-anywidget-tutorial is a complete working example of this pattern.

The standalone library

Our library is src/grid-painter.js. It knows nothing about AnyWidget — it’s just a function that takes a DOM element and some options:

1
2
3
4
5
6
7
8
export function createGrid(container, opts = {}) {
  const { data, colormap, cellSize, gap, showValues } = opts;
  // ... renders a grid of coloured divs into container ...
  return {
    update(newOpts) { /* re-renders with changed options */ },
    destroy()      { /* removes the grid from the DOM    */ },
  };
}

This separation matters. Keep your visualization logic in its own module with no AnyWidget dependencies. The AnyWidget wrapper is a thin adapter on top.

The AnyWidget wrapper

src/index.js is the bridge between grid-painter and AnyWidget:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import { createGrid, COLORMAPS } from "./grid-painter.js";
import STYLES from "./styles.css";

function render({ model, el }) {
  // 1. Inject styles
  const style = document.createElement("style");
  style.textContent = STYLES;
  el.appendChild(style);

  // 2. Read parameters from the model
  const data       = model.get("data") || [[0, 1], [2, 3]];
  const colormap   = model.get("colormap") || "viridis";
  const cellSize   = model.get("cell_size") || 32;
  const showValues = model.get("show_values") || false;

  // 3. Build DOM and delegate to the library
  const container = document.createElement("div");
  el.appendChild(container);
  const grid = createGrid(container, { data, colormap, cellSize, showValues });

  // 4. Return cleanup
  return () => grid.destroy();
}

export default { render };

Bundling with esbuild

We need esbuild to do two things: resolve the local import of grid-painter.js, and inline the CSS file as a string.

package.json:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
  "name": "grid-painter-widget",
  "version": "0.1.0",
  "private": true,
  "type": "module",
  "scripts": {
    "build": "esbuild src/index.js --bundle --format=esm --outfile=dist/widget.mjs --loader:.css=text --minify",
    "postbuild": "node scripts/sync-article.mjs",
    "watch": "node scripts/watch.mjs"
  },
  "devDependencies": {
    "esbuild": "^0.25.0"
  }
}

Copy scripts/sync-article.mjs and scripts/watch.mjs from the companion stevejpurves/curvenote-anywidget-tutorial repository into your project (postbuild and watch invoke them).

Key flags:

FlagWhy
--bundleResolves all imports into one file
--format=esmAnywidget expects an ES module
--loader:.css=textImports .css files as JavaScript strings
--minifySmaller bundle for production

Run it:

1
2
npm install
npm run build   # → dist/widget.mjs (one self-contained ESM file)

MyST loads the widget from a path next to your article (article/widget.mjs). After npm run build, npm runs postbuild automatically, which copies dist/widget.mjs into article/widget.mjs. During development, npm run watch rebuilds when sources change and runs the same copy after each successful rebuild. If you only need to resync an existing dist/widget.mjs, run npm run postbuild.


Exposing Parameters via the AnyWidget Model

The JSON block inside the MyST directive becomes the widget’s parameters, accessible via model.get(key):

1
2
3
4
5
6
7
8
9
```{anywidget} ./widget.mjs
{
  "data": [[1, 2, 3], [4, 5, 6], [7, 8, 9]],
  "colormap": "inferno",
  "cell_size": 48,
  "show_values": true,
  "show_controls": false
}
```

In your render() function:

1
2
3
4
5
const data         = model.get("data");          // number[][] from JSON
const colormap     = model.get("colormap");      // "inferno"
const cellSize     = model.get("cell_size");      // 48
const showValues   = model.get("show_values");    // true
const showControls = model.get("show_controls");  // false

Design guidelines for parameters

  • Use snake_case keys in the JSON. This is the convention in MyST/Jupyter widget ecosystems and reads naturally in YAML and JSON.

  • Always provide defaults. Authors may omit optional keys — don’t crash on undefined.

  • Keep the parameter surface small. Expose the things a document author needs to control. Internal implementation details (animation frame IDs, intermediate computed state) stay inside render().

  • Validate early. If a colormap name is invalid, fall back to a default rather than rendering nothing.

Parameter reference table

Document your parameters clearly for authors who’ll use the directive:

ParameterTypeDefaultDescription
datanumber[][][[0,1],[2,3]]2-D numeric matrix to render
colormapstring"viridis"One of viridis, inferno, plasma
cell_sizenumber32Side length of each cell in pixels
gapnumber2Pixel gap between cells
show_valuesbooleanfalseOverlay the numeric value on each cell
show_controlsbooleantrueShow interactive colourmap/size controls

Shipping CSS with the Module

Widgets run inside shadow DOMs in Curvenote. This has a critical consequence: stylesheets in the main document do not reach your widget. A <link> tag in <head> or a global stylesheet won’t apply.

The reliable pattern is to import your CSS as a string and inject it as a <style> element into the widget’s own DOM tree:

1
2
3
4
5
6
7
8
9
// esbuild bundles this as a string thanks to --loader:.css=text
import STYLES from "./styles.css";

function render({ model, el }) {
  const style = document.createElement("style");
  style.textContent = STYLES;
  el.appendChild(style);
  // ...
}

This works regardless of whether the host uses open shadow DOM, closed shadow DOM, or no shadow DOM at all.

Don’t do this!

Do not rely on global DOM calls within your wrapper code. If the library / code that you are wrapping does use these then likely this will still work for a single instance of the widget per page, but you might hit issues with multiple widgets on a single page

1
2
3
// ❌ Invisible inside shadow DOM; global listeners miss events inside shadow roots
document.addEventListener("click", (ev) => { /* ... */ });
document.head.appendChild(styleElement);

Also avoid dynamic @import or external stylesheet links in your injected styles — they may be blocked by CSP or add loading delays. Keep your CSS self-contained in the bundle.


### CSS scoping comes free

Because each widget instance lives in its own shadow DOM, your CSS class names
are automatically scoped. You don't need BEM, CSS modules, or unique prefixes.
A simple `.grid-cell { ... }` won't leak to other widgets or to the host page.

---

## Multiple Instances and Shadow DOM Safety

A Curvenote article can contain multiple instances of your widget on the same page.
Each gets its own shadow DOM, its own `el`, and its own call to `render()`.
This is powerful, but you must follow three rules to avoid subtle bugs:

### 1. No `document` queries

The widget's elements live inside a shadow DOM. `document.querySelector()`
cannot see them.

::::{code} javascript
:linenos:
// ❌ Will never find elements inside the shadow DOM
document.getElementById("my-grid");
document.querySelector(".gp-cell");

// ✅ Always scope to el or its children
el.querySelector(".gp-cell");

The only safe uses of document are document.createElement() and document.createElementNS() — those create detached elements, which is fine.

2. No module-level mutable state

Each call to render() must be independent. If you store state in a variable outside render(), all widget instances on the page will share it.

1
2
3
4
5
6
// ❌ Shared across every widget instance
let currentGrid = null;

function render({ model, el }) {
  currentGrid = createGrid(el, ...);
}
1
2
3
4
5
// ✅ State lives inside render() — each instance gets its own closure
function render({ model, el }) {
  const grid = createGrid(el, ...);
  return () => grid.destroy();
}

3. Clean up on destroy

Return a cleanup function from render(). The host calls it when the widget is removed (e.g. when navigating away in a MyST site). Cancel animation frames, remove window-level listeners, abort pending fetches.

1
2
3
4
5
6
7
8
9
10
function render({ model, el }) {
  const grid = createGrid(el, ...);
  const resizeObs = new ResizeObserver(() => grid.update({}));
  resizeObs.observe(el);

  return () => {
    grid.destroy();
    resizeObs.disconnect();
  };
}

If you attach listeners to window (e.g. for resize or keyboard shortcuts), you must remove them in the cleanup function. Otherwise each new widget instance adds another listener and they pile up.


The Directive Shape

The anywidget directive takes a path to an ESM module as its argument, and a JSON (or YAML) body as the parameter block:

1
2
3
4
5
6
```{anywidget} ./widget.mjs
{
  "data": [[1, 2], [3, 4]],
  "colormap": "plasma"
}
```

The path is relative to the .md file containing the directive. The JSON object is passed to the widget and accessible via model.get().

You can have multiple widget directives in the same document — each one creates an independent instance with its own shadow DOM, its own parameters, and its own lifecycle.

Placing the bundle

The simplest setup is to put widget.mjs next to your article’s index.md:

1
2
3
4
article/
  index.md       ← your MyST article
  widget.mjs     ← the built anywidget bundle
  myst.yml       ← MyST project config

For a repo with both source and article:

1
2
3
4
5
6
7
8
9
10
11
src/
  index.js       ← anywidget source
  my-lib.js      ← your library
  styles.css     ← widget styles
dist/
  index.js       ← esbuild output
article/
  index.md
  widget.mjs     ← copied from dist/index.js
  myst.yml
package.json

The build script produces dist/index.js. Copy it to article/widget.mjs (or add a post-build copy step) so that the MyST directive can resolve it.


Putting It All Together

Here’s the complete src/index.js from the example repo, with annotations:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
import { createGrid, COLORMAPS } from "./grid-painter.js";
import STYLES from "./styles.css";

function render({ model, el }) {
  // ── Inject styles into the widget's own DOM tree ──────────────
  // This is the only way to get CSS into a shadow DOM.
  const style = document.createElement("style");
  style.textContent = STYLES;
  el.appendChild(style);

  // ── Read parameters from the anywidget model ──────────────────
  // Each key corresponds to a field in the JSON directive body.
  const data         = model.get("data") || [[0, 1], [2, 3]];
  const colormap     = model.get("colormap") || "viridis";
  const cellSize     = model.get("cell_size") || 32;
  const gap          = model.get("gap") || 2;
  const showValues   = model.get("show_values") || false;
  const showControls = model.get("show_controls") !== false;

  // ── Build DOM structure ───────────────────────────────────────
  // All DOM is created via document.createElement and appended to
  // el — never queried from document.
  const root = document.createElement("div");
  root.className = "gpw-root";
  el.appendChild(root);

  // ── Optional interactive controls ─────────────────────────────
  // These live inside the widget and let the reader adjust the
  // colormap and cell size without changing the directive.
  let currentColormap = colormap;
  let currentCellSize = cellSize;

  if (showControls) {
    const controls = document.createElement("div");
    controls.className = "gpw-controls";

    // Colourmap selector
    const cmLabel = document.createElement("label");
    cmLabel.textContent = "Colourmap:";
    const cmSelect = document.createElement("select");
    for (const name of Object.keys(COLORMAPS)) {
      const opt = document.createElement("option");
      opt.value = name;
      opt.textContent = name;
      if (name === colormap) opt.selected = true;
      cmSelect.appendChild(opt);
    }
    cmSelect.addEventListener("change", () => {
      currentColormap = cmSelect.value;
      grid.update({ colormap: currentColormap });
    });

    // Cell size slider
    const szLabel = document.createElement("label");
    szLabel.textContent = "Cell size:";
    const szSlider = document.createElement("input");
    szSlider.type = "range";
    szSlider.min = "12";
    szSlider.max = "64";
    szSlider.value = String(cellSize);
    const szValue = document.createElement("span");
    szValue.textContent = `${cellSize}px`;
    szSlider.addEventListener("input", () => {
      currentCellSize = Number(szSlider.value);
      szValue.textContent = `${currentCellSize}px`;
      grid.update({ cellSize: currentCellSize });
    });

    controls.append(cmLabel, cmSelect, szLabel, szSlider, szValue);
    root.appendChild(controls);
  }

  // ── Render the grid via the standalone library ────────────────
  const gridContainer = document.createElement("div");
  gridContainer.className = "gpw-grid-container";
  root.appendChild(gridContainer);

  const grid = createGrid(gridContainer, {
    data,
    colormap: currentColormap,
    cellSize: currentCellSize,
    gap,
    showValues,
  });

  // ── Cleanup ───────────────────────────────────────────────────
  // Returned function is called when the widget is destroyed.
  return () => {
    grid.destroy();
  };
}

export default { render };

Checklist

Before shipping your widget, verify:

  • render() never calls document.querySelector() or document.getElementById() — only el.querySelector() or document.createElement()

  • All event listeners are attached to el or its descendants, not to document or window (or if on window, removed in cleanup)

  • No mutable state at module scope — all state lives in render() closures

  • CSS is injected as a <style> element into el, not into document.head

  • render() returns a cleanup function that removes listeners, cancels timers, and disconnects observers

  • Every model.get() call has a fallback default

  • The bundle is --format=esm

  • Two instances of the widget on the same page work independently


Further Reading

License

Copyright © 2026 Purves. This is an open-access article distributed under the terms of the Creative Commons Attribution 4.0 International license, which enables reusers to distribute, remix, adapt, and build upon the material in any medium or format, so long as attribution is given to the creator.

Footnotes
  1. The {anywidget} directive is the anchor point in Curvenote articles for JS based components that conform to the AnyWidget front end module spec.

  2. We are busy working on support for the full AnyWidget spec and that will appear in Curvenote very soon, for now, you have all you need to be able to embed and render practically any self contained visualization or interactive component you can think of.