pywidget in MyST
Pure Python widgets running in the browser via Pyodide
pywidget lets you write Jupyter widgets entirely in Python — no JavaScript
required. The render() and update() methods run client-side in
Pyodide (CPython compiled to WebAssembly), so the same
widget class works in JupyterLab, Jupyter Notebook, marimo, VS Code, and — as
this page demonstrates — as a fully static site with no kernel at all.
Example 1: Hello World¶
A minimal widget that renders static HTML from Python.
def render(el, model):
el.innerHTML = """
<div style="padding: 20px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 12px; color: white; font-family: sans-serif;">
<h2 style="margin: 0 0 8px 0;">Hello from Pyodide!</h2>
<p style="margin: 0; opacity: 0.9;">
This HTML was rendered by Python running in your browser via WebAssembly.
</p>
</div>
"""Example 2: Interactive Counter¶
A button whose count is stored in the widget model. model.on('change:count', …)
subscribes to model changes so the label always reflects the current state —
the same reactivity pattern used by anywidget in JavaScript.
def render(el, model):
from js import document
btn = document.createElement('button')
btn.style.cssText = (
'padding: 10px 24px; font-size: 16px; cursor: pointer;'
' border: 2px solid #6366f1; border-radius: 8px;'
' background: #eef2ff; color: #4338ca; font-family: sans-serif;'
)
def refresh():
btn.textContent = f'Count: {model.get("count")}'
def on_click(event):
model.set('count', model.get('count') + 1)
model.save_changes()
model.on('change:count', create_proxy(lambda *_: refresh()))
btn.addEventListener('click', create_proxy(on_click))
refresh()
el.style.padding = '16px'
el.appendChild(btn)Example 3: Python Computation¶
Using Python’s standard library to compute and display results.
def render(el, model):
import math
import sys
rows = ''
for n in range(1, 11):
rows += (
f'<tr><td>{n}</td><td>{n**2}</td>'
f'<td>{math.factorial(n):,}</td><td>{math.sqrt(n):.4f}</td></tr>'
)
el.innerHTML = f"""
<div style="font-family: sans-serif; padding: 16px;">
<p style="color: #666; margin-bottom: 12px;">
Computed by Python {sys.version.split()[0]} (Pyodide) in your browser
</p>
<table style="border-collapse: collapse; width: 100%;">
<thead>
<tr style="background: #f1f5f9;">
<th style="padding: 8px 12px; border: 1px solid #e2e8f0;">n</th>
<th style="padding: 8px 12px; border: 1px solid #e2e8f0;">n²</th>
<th style="padding: 8px 12px; border: 1px solid #e2e8f0;">n!</th>
<th style="padding: 8px 12px; border: 1px solid #e2e8f0;">√n</th>
</tr>
</thead>
<tbody>{rows}</tbody>
</table>
</div>
"""Using External Libraries¶
Pyodide supports 250+ packages from the scientific Python ecosystem via
micropip. List the packages you need in
_py_packages and they will be installed before your render() runs.
Example 4: Mandelbrot Set (numpy + Pillow)¶
Vectorized fractal computation with numpy, rendered to PNG with Pillow. Click anywhere on the image to re-center; use the buttons to zoom.
def render(el, model):
import base64
import io
import numpy as np
from PIL import Image
cx = model.get("center_x")
cy = model.get("center_y")
z = model.get("zoom")
max_it = model.get("max_iter")
w = model.get("width")
h = model.get("height")
def compute(cx, cy, z, w, h, max_it):
r = 3.0 / z
aspect = w / h
real = np.linspace(cx - r * aspect / 2, cx + r * aspect / 2, w)
imag = np.linspace(cy - r / 2, cy + r / 2, h)
c = real[np.newaxis, :] + 1j * imag[:, np.newaxis]
z_arr = np.zeros_like(c)
iters = np.zeros(c.shape, dtype=np.int32)
mask = np.ones(c.shape, dtype=bool)
for i in range(max_it):
z_arr[mask] = z_arr[mask] ** 2 + c[mask]
escaped = mask & (np.abs(z_arr) > 2.0)
iters[escaped] = i + 1
mask &= ~escaped
return iters
iters = compute(cx, cy, z, w, h, max_it)
norm = iters.astype(np.float64) / max_it
rgb = np.stack([
(np.sin(norm * np.pi * 2) * 127 + 128).astype(np.uint8),
(np.sin(norm * np.pi * 2 + 2.094) * 127 + 128).astype(np.uint8),
(np.sin(norm * np.pi * 2 + 4.189) * 127 + 128).astype(np.uint8),
], axis=-1)
rgb[iters == 0] = 0
buf = io.BytesIO()
Image.fromarray(rgb, "RGB").save(buf, format="PNG")
b64 = base64.b64encode(buf.getvalue()).decode("ascii")
btn_style = "padding:6px 14px; font-size:14px; cursor:pointer; border:1px solid #ccc; border-radius:4px; background:#f8f8f8;"
el.innerHTML = f"""
<div style="font-family:sans-serif; display:inline-block;">
<img id="fractal" src="data:image/png;base64,{b64}"
width="{w}" height="{h}"
style="cursor:crosshair; display:block; border:1px solid #ddd;" />
<div style="display:flex; align-items:center; gap:10px; margin-top:8px; flex-wrap:wrap;">
<button id="zoom-in" style="{btn_style}">Zoom In</button>
<button id="zoom-out" style="{btn_style}">Zoom Out</button>
<button id="reset" style="{btn_style}">Reset</button>
<label style="display:flex; align-items:center; gap:6px; font-size:14px;">
Iterations:
<input id="iter-slider" type="range" min="10" max="500" step="10"
value="{max_it}" style="width:120px;" />
<span id="iter-label">{max_it}</span>
</label>
</div>
<div style="margin-top:4px; font-size:12px; color:#888;">
Center: ({cx:.6f}, {cy:.6f}) | Zoom: {z:.1f}x
</div>
</div>
"""
def on_click(event):
cur_cx, cur_cy = model.get("center_x"), model.get("center_y")
cur_z = model.get("zoom")
cur_w, cur_h = model.get("width"), model.get("height")
r = 3.0 / cur_z
aspect = cur_w / cur_h
model.set("center_x", float((cur_cx - r * aspect / 2) + (event.offsetX / cur_w) * r * aspect))
model.set("center_y", float((cur_cy - r / 2) + (event.offsetY / cur_h) * r))
model.save_changes()
def on_zoom_in(event):
model.set("zoom", model.get("zoom") * 2)
model.save_changes()
def on_zoom_out(event):
model.set("zoom", max(model.get("zoom") / 2, 0.25))
model.save_changes()
def on_reset(event):
model.set("center_x", -0.5)
model.set("center_y", 0.0)
model.set("zoom", 1.0)
model.set("max_iter", 100)
model.save_changes()
el.querySelector("#iter-slider").value = "100"
el.querySelector("#iter-label").textContent = "100"
def on_iter_change(event):
new_val = int(float(event.target.value))
el.querySelector("#iter-label").textContent = str(new_val)
model.set("max_iter", new_val)
model.save_changes()
el.querySelector("#fractal").addEventListener("click", create_proxy(on_click))
el.querySelector("#zoom-in").addEventListener("click", create_proxy(on_zoom_in))
el.querySelector("#zoom-out").addEventListener("click", create_proxy(on_zoom_out))
el.querySelector("#reset").addEventListener("click", create_proxy(on_reset))
el.querySelector("#iter-slider").addEventListener("change", create_proxy(on_iter_change))
def update(el, model):
render(el, model)Example 5: K-Means Clustering (numpy + scikit-learn)¶
Click the canvas to add points. K-Means runs in the browser via Pyodide — no server, no kernel. Adjust k with the buttons.
def render(el, model):
import json
import numpy as np
points = json.loads(model.get("points_json"))
n_clusters = model.get("n_clusters")
COLORS = ["#4e79a7", "#f28e2b", "#e15759", "#76b7b2",
"#59a14f", "#edc948", "#b07aa1", "#ff9da7"]
labels = centers = inertia = None
if len(points) >= n_clusters:
from sklearn.cluster import KMeans
X = np.array([[p["x"], p["y"]] for p in points])
km = KMeans(n_clusters=n_clusters, n_init=10, random_state=42)
km.fit(X)
labels = km.labels_.tolist()
centers = km.cluster_centers_.tolist()
inertia = float(km.inertia_)
# ... build SVG with colored circles and diamond centroids,
# wire up click-to-add-point and cluster-count buttons ...
def update(el, model):
render(el, model)