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.

wigglystuff + manywidgets

wigglystuff is Vincent Warmerdam’s collection of playful anywidget controls. This notebook is a compatibility probe, not a showcase — it answers two questions:

  1. Do plain wigglystuff widgets survive our static export (MyST + myst-anywidget-static-export, no kernel)? Do they need a wrapper?

  2. Can a wigglystuff widget be linked to a manywidgets widget with no kernel?

Everything below is deliberately dead simple. If a cell renders and reacts on the published site, that mechanism works.

import wigglystuff, manywidgets, anywidget

print("wigglystuff ", wigglystuff.__version__)
print("manywidgets ", manywidgets.__version__)
print("anywidget   ", anywidget.__version__)
wigglystuff  0.5.26
manywidgets  0.2.0a1
anywidget    0.9.21

1. Does a bare wigglystuff widget render statically?

The static-export plugin rewrites any anywidget output whose _esm was captured into the notebook’s metadata.widgets at execution time. wigglystuff widgets are ordinary anywidgets that load their JS from _esm = Path(...) — anywidget inlines that file’s contents into the widget state, so there is nothing manywidgets-specific about them. Prediction: they just work.

One widget per cell, so a failure names itself.

from wigglystuff import Slider2D

xy = Slider2D(x=0.3, y=-0.2, width=320, height=320)
xy
xy.x, xy.y   # read the trait back in a live kernel
(0.3, -0.2)

Knob and CircularSlider are SVG-based and ship a separate _css file — a second thing the plugin has to carry (it injects _css into the widget’s shadow root).

from wigglystuff import Knob

knob = Knob(value=42, min_value=0, max_value=100, label="Knob", size=120)
knob
from wigglystuff import CircularSlider

dial = CircularSlider(value=30, start=0, stop=100, size=200, label="Dial")
dial

TangleSlider is the “drag the number in the sentence” control from Bret Victor’s Tangle — it renders inline in HTML, so it also tests that the plugin’s block-level widget wrapper doesn’t break inline layout.

from wigglystuff import TangleSlider

tangle = TangleSlider(amount=25, min_value=0, max_value=100, step=1, suffix="%")
tangle

Matrix is the most stateful of the simple ones: a list-of-lists trait plus a CSS file, edited by dragging cells.

from wigglystuff import Matrix

mat = Matrix(rows=3, cols=3, min_value=-5, max_value=5, step=0.5)
mat

2. Can wigglystuff and manywidgets widgets be linked?

Our links survive static export because the plugin lifts every ipywidgets LinkModel / DirectionalLinkModel in the notebook’s widget state into a page-level registry, then re-binds it in the browser against whichever widget models registered themselves. That machinery keys off model_idnot off anything manywidgets-specific — so a wigglystuff endpoint should be no different from a manywidgets one.

Three directions to prove, all with jsdlink.

2a. wigglystuff → manywidgets

Drag the knob; the manywidgets Stat and NumberDisplay should follow.

from ipywidgets import jsdlink
from manywidgets import Stat, NumberDisplay, Row, Column

power = Knob(value=60, min_value=0, max_value=100, label="Power", size=110)
readout = NumberDisplay(label="Knob value", format="{:.0f}", duration=200)
stat = Stat(label="Power level", value=60, unit="%")

jsdlink((power, "value"), (readout, "value"))
jsdlink((power, "value"), (stat, "value"))

Row(Column(readout, stat, gap="12px"), gap="24px", align="center")
power   # rendered separately: Row/Column is a manywidgets container (see 2d)

2b. manywidgets → wigglystuff

The reverse direction: a manywidgets Slider drives a wigglystuff CircularSlider and a Knob.

from manywidgets import Slider

driver = Slider(label="Driver", min=0, max=100, step=1, value=35)
followed_dial = CircularSlider(value=35, start=0, stop=100, size=180, label="follows")
followed_knob = Knob(value=35, min_value=0, max_value=100, label="follows", size=110)

jsdlink((driver, "value"), (followed_dial, "value"))
jsdlink((driver, "value"), (followed_knob, "value"))

driver
followed_dial
followed_knob

2c. wigglystuff → wigglystuff

No manywidgets in the loop at all — just the plugin’s link registry between two third-party anywidgets.

pct = TangleSlider(amount=40, min_value=0, max_value=100, step=1, suffix="%")
mirror = Knob(value=40, min_value=0, max_value=100, label="mirrors", size=110)

jsdlink((pct, "amount"), (mirror, "value"))

pct
mirror

2d. Can a manywidgets container host a wigglystuff child?

Row / Column are manywidgets layout widgets that mount their children through the plugin’s host.renderChild hook. Their children trait is a plain widget_serialization list, so in principle any anywidget can be a child — this cell tests whether that holds for a foreign widget.

mixed_knob = Knob(value=70, min_value=0, max_value=100, label="Mix", size=110)
mixed_stat = Stat(label="Mixed layout", value=70, unit="%")
jsdlink((mixed_knob, "value"), (mixed_stat, "value"))

Row(mixed_knob, mixed_stat, gap="24px", align="center")

2e. Two-way linking

jslink (bidirectional) between a manywidgets Slider and a wigglystuff Knob: moving either should move the other.

from ipywidgets import jslink

two_way_slider = Slider(label="Two-way", min=0, max=100, step=1, value=50)
two_way_knob = Knob(value=50, min_value=0, max_value=100, label="Two-way", size=110)

jslink((two_way_slider, "value"), (two_way_knob, "value"))

two_way_slider
two_way_knob

What we learned

Verified against the built static site (just build, served over HTTP, no kernel), by dragging the real controls in a browser:

1. wigglystuff widgets need no wrapper. Every widget above renders, and every one accepts input — the knobs and dials drag, the 2D slider tracks the pointer across its canvas, the inline TangleSlider scrubs, Matrix cells edit. Their _css files land in the right shadow root. Nothing manywidgets-specific was required, which is the expected result: the plugin keys off anywidget, not off manywidgets, and wigglystuff widgets are plain anywidgets whose _esm = Path(...) gets inlined into the notebook’s metadata.widgets at execution time.

2. Links work in every direction. All seven jsdlink/jslink bindings above were lifted into the page-level registry and re-bound in the browser — wigglystuff → manywidgets, manywidgets → wigglystuff, wigglystuff → wigglystuff, and jslink both ways. Dragging the Knob in 2a moves the manywidgets Stat and NumberDisplay with it. The plugin resolves link endpoints by model_id, and it never cared which library a model came from.

3. A manywidgets Row/Column can host a wigglystuff child (2d) — including one that is itself a link endpoint. host.renderChild mounts any anywidget reachable through the container’s children trait.

What to watch for

  • Python-side callbacks are inert, as with any static export. Roughly fifteen wigglystuff widgets (LiveEdit, EnvConfig, HTMLRefreshWidget, the observe-driven chart selectors…) do their real work in Python. Statically their JS still runs and their traits still drive links; only the Python half is dead.

  • Six widgets fetch their JS from a CDN at render timeExcalidraw, EsmWidget, ManimWeb, TangleLatex, ObservablePlot, AltairWidget pull from cdn.jsdelivr.net. They will work on a networked page but the page is no longer self-contained, and they will fail offline.

  • GridDraw and GamepadWidget use space-separated event names (model.on("change:a change:b", …)). Plugin v0.2.0+ splits those, so they are fine here — but that is the one rule an anywidget author can break invisibly, since it only misbehaves without a kernel.

  • Everything else is the usual static-export contract: serve over HTTP (not file://), and re-run just execute after changing any widget code, since the JS is baked into the notebook at execution time.