Jupyter widgets with pure Python rendering via Pyodide
Project description
pywidget
Write Jupyter widgets entirely in Python — no JavaScript required.
pywidget lets you define both the kernel-side state and the browser-side rendering of a Jupyter widget in a single Python class. Your rendering code runs in the browser via Pyodide (CPython compiled to WebAssembly) and syncs state bidirectionally with the kernel through anywidget.
Runs anywhere anywidget runs — Jupyter Lab, Jupyter Notebook, marimo, and more.
Why pywidget?
- One language — define state, rendering, and interaction logic all in Python. No JavaScript, no separate frontend build step.
- Responsive UI — event handlers run locally in the browser via Pyodide. No round-trip to the kernel on every click or keystroke.
- Full Python in the browser — import NumPy, Pandas, scikit-learn, and 250+ other packages directly in your rendering code via Pyodide's scientific stack.
- Real methods, not strings — write
renderandupdateas regular methods on your class with full IDE support (autocomplete, linting, go-to-definition). - Zero build infrastructure —
pip install pywidgetand go. Nonpm, no webpack, nojupyter labextension develop.
flowchart LR
subgraph Kernel ["Kernel (CPython)"]
A["class MyWidget(PyWidget):\n count = Int(0)\n def render(self, el, model): ..."]
end
subgraph Browser ["Browser (Pyodide / WASM)"]
B["def render(el, model):\n el.innerHTML = ...\n model.set('count', 1)"]
end
A -- "traitlet sync\n(WebSocket)" --> B
B -- "model.set() +\nsave_changes()" --> A
Install
pip install pywidget
For use with marimo:
pip install pywidget marimo
Quick example
import traitlets
from pywidget import PyWidget
class CounterWidget(PyWidget):
count = traitlets.Int(0).tag(sync=True)
def render(self, el, model):
count = model.get("count")
el.innerHTML = f"""
<div style="display:flex; align-items:center; gap:12px; font-family:sans-serif;">
<button id="dec">-</button>
<span id="display">{count}</span>
<button id="inc">+</button>
</div>
"""
def on_inc(event):
new = model.get("count") + 1
model.set("count", new)
model.save_changes()
el.querySelector("#display").textContent = str(new)
def on_dec(event):
new = model.get("count") - 1
model.set("count", new)
model.save_changes()
el.querySelector("#display").textContent = str(new)
el.querySelector("#inc").addEventListener("click", create_proxy(on_inc))
el.querySelector("#dec").addEventListener("click", create_proxy(on_dec))
def update(self, el, model):
display = el.querySelector("#display")
if display:
display.textContent = str(model.get("count"))
CounterWidget()
Usage with marimo
In marimo, wrap your widget instance with
mo.ui.anywidget() to integrate with marimo's reactive execution engine:
import marimo as mo
import traitlets
from pywidget import PyWidget
class CounterWidget(PyWidget):
count = traitlets.Int(0).tag(sync=True)
def render(self, el, model):
count = model.get("count")
el.innerHTML = f"""
<div style="display:flex; align-items:center; gap:12px; font-family:sans-serif;">
<button id="dec">-</button>
<span id="display">{count}</span>
<button id="inc">+</button>
</div>
"""
def on_inc(event):
new = model.get("count") + 1
model.set("count", new)
model.save_changes()
el.querySelector("#display").textContent = str(new)
def on_dec(event):
new = model.get("count") - 1
model.set("count", new)
model.save_changes()
el.querySelector("#display").textContent = str(new)
el.querySelector("#inc").addEventListener("click", create_proxy(on_inc))
el.querySelector("#dec").addEventListener("click", create_proxy(on_dec))
widget = mo.ui.anywidget(CounterWidget())
widget
Changes to synced traitlets propagate through marimo's dependency graph, so
downstream cells re-execute automatically. See examples/pywidget_marimo_demo.py
for a full walkthrough.
The render and update methods look like regular Python, but they execute
in the browser inside a Pyodide runtime. At class-creation time pywidget
extracts their source via inspect.getsource(), strips self, and sends the
code to the frontend. The kernel never runs these methods.
The model API
Inside render and update, the model object supports:
| Method | Description |
|---|---|
model.get(name) |
Read a synced traitlet (returns Python-native types) |
model.set(name, value) |
Write a synced traitlet (local until save_changes) |
model.save_changes() |
Push pending set() calls to the kernel |
model.on(event, callback) |
Subscribe to events (e.g. "change:count") |
Browser-side builtins
The rendering namespace automatically includes:
create_proxy(fn)— prevent GC of Python callbacks passed to JS APIs likeaddEventListenerto_js(obj)— explicitly convert a Python object to a JS valuedocument— the browser'sdocumentobjectconsole— the browser'sconsoleobject
Installing packages in the browser
Use _py_packages to install packages via micropip before rendering:
class StatsWidget(PyWidget):
data = traitlets.List(traitlets.Float(), []).tag(sync=True)
_py_packages = ["numpy"]
def render(self, el, model):
import numpy as np
arr = np.array(list(model.get("data")))
el.innerHTML = f"Mean: {arr.mean():.2f}, Std: {arr.std():.2f}"
Any pure-Python wheel or package bundled with Pyodide works here (numpy,
pandas, scipy, and ~200 others).
String-based alternative
If you prefer, you can set _py_render directly instead of defining methods:
class HelloWidget(PyWidget):
_py_render = """
def render(el, model):
el.innerHTML = "<h2>Hello from Pyodide!</h2>"
"""
When _py_render is set explicitly, method extraction is skipped.
Performance
- First render: 3–5 s — Pyodide (~11 MB WASM) downloads once per page. Subsequent page loads use the browser cache (1–2 s).
- Subsequent widgets: instant — all instances share a single Pyodide runtime.
- Interaction latency: near-zero — event handlers run locally in the browser; kernel sync happens asynchronously.
How pywidget relates to the ecosystem
| ipywidgets | anywidget | pywidget | |
|---|---|---|---|
| Custom rendering | No (fixed set of widgets) | Yes, in JavaScript | Yes, in Python |
| Interaction latency | Kernel round-trip | Local in browser | Local in browser |
| Browser runtime | None | None | Pyodide (~11 MB, cached) |
| Jupyter support | Yes | Yes | Yes |
| marimo support | No | Yes | Yes |
pywidget is a thin layer on top of anywidget. PyWidget subclasses
anywidget.AnyWidget and sets _esm to a JS bridge (~170 lines) that loads
Pyodide, runs your Python rendering code in an isolated namespace, and proxies
the anywidget model API. No modifications to anywidget are needed.
License
MIT
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file pywidget-0.1.0.tar.gz.
File metadata
- Download URL: pywidget-0.1.0.tar.gz
- Upload date:
- Size: 16.7 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.13.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
af57adf071e373f65d5ae1f77ed3a6938756d97e9afcce7fd585a02c6930eae7
|
|
| MD5 |
0eec22db177dac5948fd0b6f8472a73c
|
|
| BLAKE2b-256 |
4641259b244a81272298c3f4346ec812706c8ee21489fca2e3de1865eee59f0e
|
File details
Details for the file pywidget-0.1.0-py3-none-any.whl.
File metadata
- Download URL: pywidget-0.1.0-py3-none-any.whl
- Upload date:
- Size: 10.0 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.13.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
4c445e1ff90036adc147f13057edd2c5e84e7e3d4fa0e86ab61a37e41b658c93
|
|
| MD5 |
f90993a6f558d08eb1c8fce8b9f911c7
|
|
| BLAKE2b-256 |
06574f64a97334d88b7813c89d56a203a6565be5280b8a5ede14d2688c751e2f
|