Every manywidgets widget follows the same self-contained structure, so the fastest way to make a new one is to copy an existing widget directory and rename it. A widget is a complete unit: Python class, TypeScript source, built bundle, styles, and its own tests.
1. Copy a widget directory¶
cp -r src/manywidgets/slider src/manywidgets/my_widgetYou get:
src/manywidgets/my_widget/
├── __init__.py # re-export the class
├── widget.py # the BaseWidget subclass (traits + methods)
├── doc.md # the widget's docs (auto-assembled into the docs site)
├── src/index.ts # the render() function
├── style.css # styling via the _css trait
├── dist/widget.js # built by esbuild (gitignored)
└── tests/
├── test_my_widget.py # pytest
└── my_widget.test.ts # vitest + jsdomRename the class in widget.py and __init__.py, update tests/ and doc.md,
then add the class to src/manywidgets/__init__.py and to the ensured-targets /
skip-if-exists lists in pyproject.toml. The build (scripts/build.mjs)
auto-discovers any widget dir with a src/index.ts.
Docs (doc.md) — co-located and auto-assembled¶
A widget owns its docs. scripts/build_widget_docs.py turns each
doc.md into docs/widgets/<name>.ipynb (a gitignored build artifact) and the
MyST site picks it up. In doc.md:
Write prose as Markdown.
Write a runnable example as a
```{code-cell} pythonfence — it becomes an executed cell, so a live widget renders on the page. (Plain```pythonfences stay illustrative.)Put
{api-table}where the trait table should go — it’s generated from the class’s traits, so give each trait ahelp="…"and the table stays correct.
Add the new page to the widgets: toc in docs/myst.yml. Regenerate with
npm run docs:gen.
2. The golden-example pattern¶
widget.pysubclassesBaseWidget(which auto-assignswidget_id) and points_esm/_cssat its own files viaasset(__file__, ...):from .._base import BaseWidget, asset class MyWidget(BaseWidget): _esm = asset(__file__, "dist", "widget.js") _css = asset(__file__, "style.css") value = traitlets.Float(0.0).tag(sync=True)src/index.tsexports{ render }, builds plain DOM (no frameworks), and imports shared helpers from@manywidgets/core.
3. The static-export safety rules¶
manywidgets widgets render both in a live kernel and statically (no kernel)
via the myst-anywidget-static-export plugin. To stay compatible:
Wrap every
model.save_changes()— usesafeSaveChanges(model)from core (there is no kernel statically).One listener per trait. Use
onChanges(model, ["a", "b"], fn)— nevermodel.on("change:a change:b", fn). The static model emitter does not split space-separated event names, so the combined form silently never fires. (This is the single most common static-export bug; see static export.)Style via the
_csstrait (orensureShadowCssfrom core for libraries that inject CSS at runtime) — never append a<link>into the mountel.Vanilla DOM only — no
createRoot(el)that wipes shadow children.Stay buffer-free for core widgets (JSON-serialisable traits). Binary traits need an
nbclientpre-execute step on export.Cross-widget access via
resolveModelfrom core — it resolves root widgets bywidget_idand fans writes out to sub-model proxies, handling the live-kernel and static-export cases.
3b. Container widgets (laying out children)¶
A widget can render other widgets inside its own DOM — that’s how Row,
Column, and Grid work. Two pieces:
Python: hold children as widget references and mark the trait:
from ipywidgets import Widget, widget_serialization children = traitlets.List(trait=traitlets.Instance(Widget)).tag(sync=True, **widget_serialization) _myst_child_traits = traitlets.List(["children"]).tag(sync=True)widget_serializationserialises each child to anIPY_MODEL_<id>ref, so the static export bundles it as a renderable submodel.JS: mount each child with
renderChildfrom core (it picks the statichost.renderChildpath or the livewidget_managerpath for you):import { renderChild } from "@manywidgets/core"; const cleanups = []; for (const ref of model.get("children") || []) { const cell = container.appendChild(document.createElement("div")); cleanups.push(await renderChild(args, ref, cell)); // pass the whole render args } return () => cleanups.forEach((d) => d());
Children keep their own JS, CSS, and links. Requires plugin v0.2.0+ for static
export. Test with the fakeHost() helper from @manywidgets/test-utils.
4. Tests¶
Write both:
tests/test_<name>.py— traits, defaults, methods (pytest).tests/<name>.test.ts—render()behaviour with the sharedfakeModelfrom@manywidgets/test-utils. The fake model mimics the strict static emitter (exact event names only), so a regression to space-separatedon(...)fails the test automatically.
Run them: pytest and npm test.