mrkeyoor.com_
Thu 06 Aug 15:39 UTC
PyPIUtilsupdated 06 Aug 2026

ipywidgets

ipywidgets puts interactive controls (sliders, dropdowns, buttons, text boxes, file pickers) into Jupyter notebook output cells and wires them to Python variables. Each widget is really two objects: a Python instance holding traitlets state, and a JavaScript view in the browser, kept in sync over the kernel's comm channel. So when you drag a slider, the browser sends the new value to the kernel, the Python object's .value attribute updates, and any callback you registered with .observe runs there. You get one function, interact, that inspects a function's signature and builds controls for you, and underneath it a full widget library with layout containers, linking helpers, and an Output widget that captures print statements and plots. It needs a live kernel on the other end, which is the single fact that determines whether it fits your problem.

Verdict

The right tool for putting a knob on an exploratory notebook, and unavoidable anyway since most Jupyter visualisation libraries are built on it. Do not carry it past that point: anything that needs to work without a live kernel, or that you would call an app, wants Panel, Streamlit, or Marimo instead.

API stability4/5The 8.0 release in 2022 was the last breaking one and the changes were narrow: FileUpload.value became a tuple of dicts instead of a dict keyed by filename, description_tooltip was replaced by tooltip, and Tab and Accordion gained a titles trait instead of set_title. Everything since has been patch releases on 8.1.x, so code written three years ago still runs. The catch is that stability here partly reflects a slow pace rather than a settled design
Docs4/5readthedocs covers every widget with its traits, has real pages on layout and styling, events, linking, asynchronous callbacks, and embedding, plus a migration guide for 7 to 8. The examples are notebooks you can run. What is thin is the failure mode documentation: the troubleshooting page does not go far enough on 'model not found', and per-frontend support differences across VS Code, Colab, and JupyterLab are largely left to issue threads
Maintenance3/5Under the Jupyter organisation with corporate contributors, so it is not going away, and both the 8.x and 7.8.x lines still get releases. But 8.1.8 shipped 2025-11-01, the repository was last pushed 2026-06-15, and there are 721 open issues out of 796 open issues and PRs. The pattern is compatibility and security maintenance rather than development, and issues about frontend-specific breakage tend to sit
Ecosystem5/5About 10.2M downloads a week and the base layer for a large part of the Jupyter visualisation stack: ipympl, plotly FigureWidget, bqplot, ipyleaflet, ipycanvas, pythreejs, ipydatagrid, and Voila for turning a notebook into a page. anywidget has also made custom widgets tractable. The protocol is implemented by VS Code, Colab, Databricks, and Kaggle, though each with its own gaps

Use it if

  • You are exploring data in a notebook and want to change a parameter and see the result without editing and rerunning a cell, which widgets.interact gives you in one line
  • You are handing a notebook to a colleague who should adjust inputs but not read your code, and a couple of dropdowns plus a button is a fair interface for that
  • You need a control bound to real Python state during a long-running session: a model already loaded in memory, an open database connection, a fitted estimator you do not want to rebuild per request
  • You are building on top of it rather than with it: matplotlib's ipympl backend, plotly's FigureWidget, bqplot, ipyleaflet, pythreejs, and most notebook visualisation libraries are ipywidgets extensions, so it is already in your environment
  • You want a small internal tool that lives in JupyterLab where your team already works, with no deployment story to arrange
Skip it if

Setup reality

pip install ipywidgets pulls comm, ipython, traitlets, plus two front-end packages: widgetsnbextension (for Notebook) and jupyterlab_widgets (for JupyterLab), both pinned with compatible-release specifiers. That pinning is the improvement version 8 made, and it removes most of the version-skew misery the 7.x line was famous for. What is left still bites. Mixing conda and pip in the same environment can leave two copies of the front-end assets and produce the classic 'Error displaying widget: model not found', which means the browser received a widget id whose JavaScript model it does not have. Installing ipywidgets while JupyterLab is running does nothing until you fully restart the server, not just the kernel, and a hard browser reload is often needed on top. If you use %pip install ipywidgets from inside a notebook you still have to restart the server. Widget state does not survive a kernel restart: the Python objects are gone but the browser still shows the old views, and clicking them does nothing until you rerun the cells. Notebooks with widgets diff badly in git because the output cells carry model ids that change every run, so strip outputs before committing. And if the notebook needs to be readable later, use Save Notebook Widget State in the Jupyter menu before exporting, which embeds a frozen snapshot at the cost of a much larger file.

Patterns

Create a widget, read and write its valuefirst-widget

import ipywidgets as widgets
from IPython.display import display

slider = widgets.IntSlider(
    value=5, min=0, max=100, step=1,
    description="Threshold:",
    continuous_update=False,
)
display(slider)

slider.value          # read whatever the user last set
slider.value = 42     # writing from Python moves the control in the browser

The Python object and the browser view are two halves of the same widget, so assigning to .value updates the display and dragging the control updates the attribute. continuous_update=False is worth setting on almost every slider: the default fires on every pixel of drag, and if a callback is attached that is one kernel round trip per pixel. In a notebook the last expression of a cell displays automatically, so the explicit display call is only needed mid-cell or inside a loop.

React to a change with observeobserve-changes

import ipywidgets as widgets

slider = widgets.IntSlider(value=5, min=0, max=100)
label = widgets.Label()

def on_change(change):
    # change = {'name': 'value', 'old': 5, 'new': 7,
    #           'owner': <IntSlider>, 'type': 'change'}
    label.value = f"squared: {change['new'] ** 2}"

slider.observe(on_change, names="value")
widgets.VBox([slider, label])

slider.unobserve(on_change, names="value")   # detach when you are done

Always pass names="value". Without it the handler fires for every trait change including internal ones such as _view_count, and you will see it run when nothing visible happened. Exceptions raised inside an observe callback are swallowed by the comm machinery and never reach the cell, so a handler that crashes just appears to do nothing: wrap the body in try/except and print, or use an Output widget. Re-running the cell registers the handler again, so a callback firing twice usually means the cell ran twice.

Generate controls from a function signatureinteract

import ipywidgets as widgets

@widgets.interact(n=(1, 100), label="hello", upper=False)
def show(n, label, upper):
    text = label.upper() if upper else label
    print(text * n)

# only recompute when the button is pressed
@widgets.interact_manual(size=(100, 10_000, 100))
def fit(size):
    return train_model(size)

# keep a value out of the UI
widgets.interact(plot, df=widgets.fixed(big_dataframe), column=["a", "b", "c"])

The abbreviations are positional-ish magic: a tuple becomes a slider, a list becomes a dropdown, a bool becomes a checkbox, a string becomes a text box. That is convenient and also the reason interact stops being useful quickly, since you cannot set a description, a step, or continuous_update through it. Use interact_manual for anything expensive, or the whole point is lost as the function reruns on every keystroke. fixed() passes a value through without building a control for it.

A button, and where its print statements gobutton-and-output

import ipywidgets as widgets

button = widgets.Button(description="Run", button_style="primary", icon="play")
out = widgets.Output()

def on_click(b):
    with out:
        out.clear_output(wait=True)   # wait=True avoids the flicker
        print("working...")
        result = do_work()
        display(result)               # dataframes and figures render properly

button.on_click(on_click)
widgets.VBox([button, out])

Without an Output widget, print inside a callback goes to whichever cell is currently executing, which is usually none, so the text vanishes. The Output widget is the only reliable place for callback output, and display() inside it renders rich objects properly while print does not. clear_output(wait=True) holds the old content until the new content is ready, which stops the layout jumping. Exceptions inside on_click are also swallowed unless you are inside the Output context, which does show tracebacks.

Arranging widgetslayout-containers

import ipywidgets as widgets

row = widgets.HBox([widgets.Button(description="a"), widgets.Button(description="b")])
col = widgets.VBox([row, widgets.IntSlider()])

tabs = widgets.Tab(children=[col, widgets.Textarea()], titles=("Controls", "Notes"))
acc = widgets.Accordion(children=[widgets.IntSlider()], titles=("Advanced",))

grid = widgets.GridspecLayout(3, 3, height="300px")
grid[0, :] = widgets.Button(description="header", layout=widgets.Layout(width="auto"))
grid[1:, 0] = widgets.VBox([widgets.Checkbox(description="x")])

app = widgets.AppLayout(header=tabs, left_sidebar=grid, center=widgets.Output())

titles as a constructor argument is version 8; on 7.x you called set_title(index, text) after construction, which is what most older examples show. GridspecLayout supports slice assignment and is the least painful option once you are past two rows. Everything below the container level is CSS: widgets do not size themselves to their content, so a Button in a grid cell needs layout=Layout(width='auto') or it keeps its default width and overflows.

link, dlink, and the versions that skip the kernellink-widgets

import ipywidgets as widgets

slider = widgets.IntSlider()
box = widgets.IntText()

link = widgets.link((slider, "value"), (box, "value"))    # two-way, via Python
widgets.dlink((slider, "value"), (box, "value"))          # one-way, via Python

widgets.jslink((slider, "value"), (box, "value"))         # two-way, browser only
widgets.jsdlink((slider, "value"), (label, "value"))      # one-way, browser only

link.unlink()

jslink and jsdlink run entirely in the browser, so the connection keeps working while the kernel is busy and costs no round trip, which makes them the right choice for anything purely cosmetic such as mirroring a slider into a readout. The trade is that the Python-side .value does not update until something else syncs it. link and dlink go through the kernel and do keep Python in sync. Keep a reference to the returned object: if it is garbage collected the link stops working.

Stop a slider from hammering the kernelexpensive-callbacks

import asyncio, ipywidgets as widgets

slider = widgets.FloatSlider(continuous_update=False)   # first line of defence

_task = None

def debounced(change):
    global _task
    if _task:
        _task.cancel()

    async def run():
        await asyncio.sleep(0.3)
        with out:
            out.clear_output(wait=True)
            display(expensive(change["new"]))

    _task = asyncio.ensure_future(run())

slider.observe(debounced, names="value")

# batch several controls behind one button instead
ui = widgets.VBox([a, b, c])
out2 = widgets.interactive_output(recompute, {"a": a, "b": b, "c": c})

continuous_update=False alone fixes most of it: the widget then only sends a value when the user releases the handle. For anything past a second of work, debounce with an asyncio task as above, since the kernel processes comm messages on the event loop and a synchronous sleep would block the whole notebook. interactive_output is the underrated one: it separates the controls from the output area so you can lay them out yourself, unlike interact which stacks them for you.

layout for the box, style for the widgetstyling

import ipywidgets as widgets

btn = widgets.Button(
    description="Long label that gets cut off",
    layout=widgets.Layout(width="300px", height="40px", margin="0 0 8px 0"),
    style={"button_color": "#0f766e", "font_weight": "bold"},
)

slider = widgets.FloatSlider(description="A very long description")
slider.style.description_width = "initial"   # stop the label truncating
slider.style.handle_color = "crimson"

list(slider.style.keys)   # which style traits this widget actually supports

Two different objects with different jobs. layout is CSS on the container: width, height, margin, border, display, flex, grid_area. style is widget-specific and every class supports a different set, so button_color exists on ButtonStyle and nowhere else; check widget.style.keys rather than guessing. description_width='initial' is the fix for the most common complaint, which is descriptions truncated to about 100 pixels with an ellipsis.

FileUpload, which changed shape in version 8file-upload

import io, ipywidgets as widgets
import pandas as pd

upload = widgets.FileUpload(accept=".csv", multiple=False)
out = widgets.Output()

def on_upload(change):
    with out:
        out.clear_output()
        for f in upload.value:          # v8: a tuple of dicts
            name = f["name"]
            data = f["content"].tobytes()
            display(pd.read_csv(io.BytesIO(data)).head())

upload.observe(on_upload, names="value")
widgets.VBox([upload, out])

In 7.x value was a dict keyed by filename and content was bytes; in 8.x it is a tuple of dicts and content is a memoryview, so nearly every FileUpload example online is wrong for the version you have installed. Call .tobytes() before handing it to anything expecting bytes. The whole file travels over the websocket into kernel memory, so this is not a way to move large files, and the widget does not clear itself after a successful upload: set upload.value = () yourself if you want a second upload to be distinguishable.

Make widgets visible to someone without a kernelexport-static

from ipywidgets.embed import embed_minimal_html, embed_data
import ipywidgets as widgets

slider = widgets.IntSlider(value=42)

embed_minimal_html("export.html", views=[slider], title="Snapshot")

# just the JSON, to drop into your own page
state = embed_data(views=[slider])
state["manager_state"], state["view_specs"]

This freezes the current state and ships the front-end views, so the controls move but nothing computes: there is no Python behind them. The generated file loads the widget JavaScript from a CDN by default, so it needs network access when opened. For a notebook, the equivalent is Save Notebook Widget State in the Widgets menu before exporting to HTML, and forgetting it is why exported notebooks show empty output areas. If you need the callbacks to actually run, you need a server, which means Voila.

Waiting for a widget without blocking the kernelasyncio-callbacks

import asyncio, ipywidgets as widgets

def wait_for_change(widget, name):
    future = asyncio.Future()
    def cb(change):
        widget.unobserve(cb, name)
        future.set_result(change["new"])
    widget.observe(cb, name)
    return future

slider = widgets.IntSlider()
display(slider)

async def loop():
    for _ in range(5):
        value = await wait_for_change(slider, "value")
        print("got", value)

asyncio.ensure_future(loop())

A blocking while loop waiting on slider.value never sees an update, because the kernel needs to process incoming comm messages on the same event loop and your loop is holding it. This future-per-change idiom, which is straight out of the official async docs, is the supported way to write a step-by-step interaction. Note that ensure_future returns immediately, so the cell finishes while the coroutine keeps running in the background; keep a reference or it can be collected.

When the output cell is empty or says model not foundtroubleshoot-blank-widgets

# 1. confirm the three pieces agree
pip list | grep -Ei "ipywidgets|widgetsnbextension|jupyterlab.widgets"

# 2. one package manager, one environment
pip install --upgrade ipywidgets      # OR conda, not both

# 3. restart the SERVER, not just the kernel, then hard-reload the browser

# 4. JupyterLab 3 needs the labextension explicitly
jupyter labextension list

# 5. verify the plumbing from a cell
import ipywidgets as widgets
print(widgets.__version__)
widgets.IntSlider()

'Error displaying widget: model not found' means the browser got a widget id whose JavaScript model it cannot resolve. In practice that is one of four things: front-end assets from a different version than the Python package, a mix of conda and pip installs in one environment, a server that was not restarted after installing, or a saved notebook whose outputs reference models from a kernel that no longer exists. Version 8 pins widgetsnbextension and jupyterlab_widgets as dependencies, so a clean pip install into a fresh environment fixes most cases. Restarting the kernel alone almost never does.

Alternatives

PackageRegistryPick it when
panelPyPIYou want the same notebook-first workflow but with real layout templates and a serve command that turns the result into a deployable app
streamlitPyPIThe deliverable is a web app rather than a notebook, and rerunning the whole script on every interaction is an acceptable trade for not managing callbacks
anywidgetPyPIYou are writing a custom widget and want a plain ES module and some CSS instead of a JavaScript package, a bundler, and a two-sided version handshake
marimoPyPIYou want a reactive notebook where cells rerun automatically from dependencies, the file is plain Python, and UI elements are part of the runtime rather than a synchronised browser model