How to Build a Custom Interactive Widget with anywidget
Updated
Build an anywidget by installing the package, subclassing anywidget.AnyWidget, supplying a front-end render function through the _esm attribute, and declaring synchronized state with traitlets. The official example on anywidget.dev does exactly this with a counter: a Python class holds count, and the browser-side render function reads and writes that value through the model. This approach fits you if you want a reusable widget that works across notebook and app frameworks without rewriting the front end for each one.
Install anywidget
pip install anywidget
The site shows this as the starting command. No configuration or build step appears in the example.
Define the widget class
Create a module (the example uses mywidget/__init__.py) that subclasses AnyWidget and declares synchronized traits:
import anywidget
import traitlets
class CounterWidget(anywidget.AnyWidget):
_esm = """
function render({ model, el }) {
let count = () => model.get("count");
let btn = document.createElement("button");
btn.innerHTML = `count is ${count()}`;
btn.addEventListener("click", () => {
model.set("count", count() + 1);
model.save_changes();
});
model.on("change:count", () => {
btn.innerHTML = `count is ${count()}`;
});
el.appendChild(btn);
}
export default { render };
"""
count = traitlets.Int(0).tag(sync=True)
Two pieces matter here:
_esmholds the front-end JavaScript as an ES module string. It must export a default object with arenderfunction.count = traitlets.Int(0).tag(sync=True)declares the state that stays in sync between Python and the browser. Thesync=Truetag is what makes the value travel across the boundary.
Understand the render contract
The render function receives an object with model and el:
| Piece | Role |
|---|---|
model.get("count") |
Read the current synchronized value |
model.set("count", ...) |
Stage a new value |
model.save_changes() |
Commit the change back to Python |
model.on("change:count", ...) |
React when the value changes elsewhere |
el |
The DOM element to attach your UI to |
In the counter, clicking the button increments the value, saves it, and the change:count listener updates the button label. This is the full round trip: browser action → model update → Python state → back to the browser.
Use it in a notebook
from mywidget import CounterWidget
counter = CounterWidget()
counter
Displaying the instance renders the widget. You can then inspect or set state from Python:
counter.count
Because the trait is synchronized, reading counter.count reflects clicks made in the browser, and setting it from Python updates the button.
Common sticking points
- Missing
export defaultin_esm— the module must export{ render }or the widget will not mount. - Forgetting
save_changes()—model.setalone stages a value; without saving, Python never sees it. - No
sync=Truetag — an untagged trait stays on one side and will not update the other. - Editing
_esmafter instantiation — the front end is defined at class level, so change the class and re-instantiate.
Why this pattern is portable
The site frames anywidget as "Beyond Frameworks, a Widget Standard," describing a talk from SciPy 2024 about setting a standard for portable widgets while keeping ecosystem compatibility. Practically, that means the same class and _esm you write here is intended to travel across environments rather than being tied to one framework's widget API. If portability across notebook and app contexts is your goal, this is the reason to choose anywidget over a framework-specific widget base class.