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:
The module is already published on a CDN like esm.sh — no build step needed.
The module is local code in your repo — we’ll set up a minimal esbuild bundler.
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
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 6function 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.
{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
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 8export 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 25import { 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
stevejpurvespostbuild and watch invoke them).
Key flags:
| Flag | Why |
|---|---|
--bundle | Resolves all imports into one file |
--format=esm | Anywidget expects an ES module |
--loader:.css=text | Imports .css files as JavaScript strings |
--minify | Smaller bundle for production |
Run it:
1 2npm 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 5const 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_casekeys 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:
| Parameter | Type | Default | Description |
|---|---|---|---|
data | number[][] | [[0,1],[2,3]] | 2-D numeric matrix to render |
colormap | string | "viridis" | One of viridis, inferno, plasma |
cell_size | number | 32 | Side length of each cell in pixels |
gap | number | 2 | Pixel gap between cells |
show_values | boolean | false | Overlay the numeric value on each cell |
show_controls | boolean | true | Show 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.
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 10function 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 4article/ 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 11src/ 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 93import { 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 callsdocument.querySelector()ordocument.getElementById()— onlyel.querySelector()ordocument.createElement()All event listeners are attached to
elor its descendants, not todocumentorwindow(or if onwindow, removed in cleanup)No mutable state at module scope — all state lives in
render()closuresCSS is injected as a
<style>element intoel, not intodocument.headrender()returns a cleanup function that removes listeners, cancels timers, and disconnects observersEvery
model.get()call has a fallback defaultThe bundle is
--format=esmTwo instances of the widget on the same page work independently
Further Reading¶
The companion stevejpurves
/curvenote -anywidget -tutorial contains all the code from this tutorial as a runnable project.
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.
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.