Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

pywidget in MyST

Pure Python widgets running in the browser via Pyodide

pywidget logo

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)