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.
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.
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
- The output has to work without a running kernel. Widgets render nothing on GitHub, nothing in nbviewer, and nothing in a plain HTML export unless you explicitly save the widget state into the notebook, and even then only the initial view is frozen in place with no callbacks. A reader who opens your .ipynb on GitHub sees an empty rectangle
- You are building an application. There is no routing, no authentication, no session model, no way to serve it other than Voila or a hosted JupyterHub, and the layout system is a thin wrapper over CSS flexbox expressed as traitlets. Streamlit, Panel, and Gradio exist because people kept trying to do this
- Interaction has to feel immediate. Every change is a websocket round trip to the kernel, so a slider bound to anything slower than a few milliseconds of work becomes visibly laggy. You end up setting continuous_update=False, debouncing by hand, or moving the link to jslink so it never reaches Python
- Your users are not on JupyterLab or Notebook 7. VS Code, Colab, PyCharm, Databricks, and Kaggle each ship their own renderer implementation of the widget protocol, and each supports a different subset. A custom widget that works in JupyterLab can silently fail in VS Code, and the failure looks like a blank output rather than an error
- You want responsive maintenance. 721 open issues out of 796 open issues and PRs, the last release 8.1.8 in November 2025, and the last repository push 2026-06-15. The 8.x line has been the current one since 2022 and there is no 9.x on PyPI. It is maintained in the sense that security and compatibility fixes land, not in the sense that your bug will be looked at
- You need reliable custom widgets. Writing one traditionally means a JavaScript package, a bundler, a cookiecutter, and a matching version handshake between your Python package and its front-end half. That toolchain is the reason anywidget exists
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 browserThe 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 doneAlways 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 supportsTwo 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
| Package | Registry | Pick it when |
|---|---|---|
| panel | PyPI | You want the same notebook-first workflow but with real layout templates and a serve command that turns the result into a deployable app |
| streamlit | PyPI | The 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 |
| anywidget | PyPI | You 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 |
| marimo | PyPI | You 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 |