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:

  • _esm holds the front-end JavaScript as an ES module string. It must export a default object with a render function.
  • count = traitlets.Int(0).tag(sync=True) declares the state that stays in sync between Python and the browser. The sync=True tag 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 default in _esm — the module must export { render } or the widget will not mount.
  • Forgetting save_changes() — model.set alone stages a value; without saving, Python never sees it.
  • No sync=True tag — an untagged trait stays on one side and will not update the other.
  • Editing _esm after 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.