shiny-plotly
Render plotly figures in Shiny for Python with plain plotly.js, without the shinywidgets layer.
An independent project, not affiliated with or endorsed by Posit or Plotly.
from shiny_plotly import output_plotly, render_plotly
# UI
output_plotly("sales")
# server
@render_plotly
def sales():
return go.Figure(go.Bar(x=months, y=totals))
That is the whole API surface for the common case. The figure travels as plotly's own JSON over Shiny's websocket; a small output binding draws it with Plotly.newPlot the first time and Plotly.react on every re-render, into one graph div it keeps. No ipywidgets, no kernel comm, no anywidget. Every render replaces the figure, which is how most dashboards already use @render_widget; zoom and pan survive it when the figure sets layout.uirevision.
Why
shinywidgets renders a plotly figure by wrapping it in a FigureWidget and shipping it through the ipywidgets comm protocol. That machinery earns its keep when the app mutates a figure in place (fig.data[0].y = ...) and wants the browser to patch it. Most Shiny apps do not do that; they rebuild the figure inside a reactive function and let Shiny re-render the output. For those apps the widget layer is overhead:
- extra dependencies (
ipywidgets,anywidget,shinywidgets) and their JavaScript bundles on every page; - a second rendering path next to Shiny's own, with its own quirks around sizing and full screen;
- figures held as widget state on the server for the life of the session.
shiny-plotly sends the figure as plotly JSON and draws it with plotly.js directly, through a Shiny output binding. The plotly.js bundle is served straight from the installed plotly wheel, keyed by its version, pre-compressed and with an immutable cache lifetime, so nothing is copied or vendored and a browser fetches it once.
Measured on the same app (a slider and one fillable card with a line chart; bench/), shiny 1.7.0, plotly 6.9.0, shinywidgets 0.8.1, shiny-plotly 0.2.0, headless Chromium, 2026-08-19:
| shinywidgets | shiny-plotly | |
|---|---|---|
Packages added on top of shiny + plotly |
24 (38 MB) | 1 (38 kB) |
| First visit, bytes to the first figure | 10.7 MB (5.3 MB HTTP + 5.4 MB websocket) | 2.6 MB (2.6 MB HTTP + 9 kB websocket) |
| of which plotly.js over HTTP | 0 (in the websocket) | 1.2 MB (brotli; 1.5 MB gzip) |
| Repeat visit (warm browser cache) | 5.4 MB, nearly all websocket | 13 kB |
| Websocket bytes per re-render | 5.4 MB | 10 kB |
| Re-render round trip, median of 50 | 1.1 to 1.4 s | 11 to 14 ms |
Both need plotly.js in the browser. shiny-plotly serves plotly.min.js compressed (4.9 MB raw) with Cache-Control: immutable, so a browser fetches it once per plotly version; shinywidgets sends plotly's widget bundle as part of the FigureWidget state over the websocket, and a re-render creates a new FigureWidget, so that cost is paid on every visit and every re-render. The round-trip numbers come from a loaded laptop and are a range across runs, not a constant. shinywidgets does things this package does not (in-place FigureWidget updates, any ipywidget), which the table does not measure. make bench reproduces it; bench/results.json holds the raw numbers.
Install
uv add shiny-plotly
# or
pip install shiny-plotly
Requires Python 3.10+, shiny>=1.0, plotly>=5.0.
Use
Core
import random
from itertools import accumulate
import plotly.graph_objects as go
from shiny import App, ui
from shiny_plotly import output_plotly, render_plotly
app_ui = ui.page_fillable(
ui.input_slider("n", "Points", 10, 500, 100),
ui.card(
ui.card_header("Fills the card; try full screen"),
output_plotly("walk"),
full_screen=True,
),
)
def server(input, output, session):
@render_plotly
def walk():
rng = random.Random(input.n())
y = list(accumulate(rng.gauss(0, 1) for _ in range(input.n())))
return go.Figure(go.Scatter(y=y, mode="lines"))
app = App(app_ui, server)
Anything that is a plotly.graph_objects.Figure works, including what plotly.express builds (install plotly[express] for that).
Express
import random
from itertools import accumulate
import plotly.graph_objects as go
from shiny.express import input, ui
from shiny_plotly import render_plotly
ui.page_opts(fillable=True)
with ui.sidebar():
ui.input_slider("n", "Points", 10, 500, 100)
with ui.card(full_screen=True):
@render_plotly
def walk():
rng = random.Random(input.n())
y = list(accumulate(rng.gauss(0, 1) for _ in range(input.n())))
return go.Figure(go.Scatter(y=y, mode="lines"))
The decorator creates its own output placeholder in Express, just like @render_widget does.
Options
@render_plotly(
height="300px", # fixed height; default None fills the container
width="100%",
figurewidget_margins=True, # the l16/t32/r16/b16 margins shinywidgets applies
config={"displaylogo": False},
post_script=CLICK_TO_INPUT, # JavaScript run once, when the graph is first drawn
)
def sales(): ...
None from the render function empties the output. The function may be sync or async. It may also return fig.to_dict() instead of a Figure. Anything plotly's own encoder serializes is fine as trace data: numpy arrays, pandas columns, datetimes.
Re-renders, zoom and pan
Each output_plotly holds one plotly graph div. The first figure is drawn with Plotly.newPlot; every later one goes through Plotly.react, which diffs the new figure into the graph that is already there. So the DOM node, the handlers post_script attached and plotly's per-graph state all survive a re-render.
Whether the user's zoom and pan survive is plotly's uirevision rule, the same one shinywidgets users rely on for in-place updates: set layout.uirevision to any value and keep it the same across renders to preserve the view, change it to reset the view, leave it unset to reset on every render.
@render_plotly
def prices():
return px.line(frame(), x="date", y="close").update_layout(uirevision="prices")
Migrating from shinywidgets
| shinywidgets | shiny-plotly |
|---|---|
from shinywidgets import output_widget, render_widget |
from shiny_plotly import output_plotly, render_plotly |
output_widget("id") |
output_plotly("id") |
output_widget("id", height="300px") |
output_plotly("id", height="300px") |
@render_widget |
@render_plotly |
| (FigureWidget margins, applied implicitly) | @render_plotly(figurewidget_margins=True) |
Three things change on purpose:
- Margins. shinywidgets sets tight margins (
l=16, t=32, r=16, b=16) on every FigureWidget; plotly's own defaults are80/100/80/80.shiny-plotlyuses plotly's defaults unless you passfigurewidget_margins=True, which fills in only the sides your figure leaves unset. Set margins explicitly on the figure if you want something else. - In-place mutation. A
FigureWidgetyou keep on the server and mutate (fig.data[0].y = ...,fig.add_trace(...)after render) is exactly what shinywidgets is for.shiny-plotlyhas no channel for that; return a new figure from the render function and let Shiny re-render. If your app depends on in-place widget updates, stay on shinywidgets for those outputs. Both packages can coexist in one app. - Zoom across re-renders. A mutated
FigureWidgetkeeps the user's zoom because nothing replaces the figure. Here a re-render is a new figure, so plotly'suirevisiondecides: setlayout.uirevision(see above) to keep the view.
Sizing
The rules mirror output_widget:
height=None(default): the plot fills its container. Insideui.card(full_screen=True), a fillable page or a sidebar layout it grows and shrinks with the card, from a 400px basis. Outside a fill layout it is 400px tall.height="300px"(on the decorator or onoutput_plotly): the plot is exactly that tall and opts out of filling.
Plotly alone re-measures a graph only on window resize. shiny-plotly ships a small helper script (shiny-plotly.js, loaded with every output) that observes each graph's container with a ResizeObserver, so a card that changes size without a window resize, for example when a sibling output renders below it, or when a sidebar collapses, re-lays the graph out. The same helper purges a graph once it leaves the document, which releases the window listener and layout state plotly would otherwise keep.
Events back to Shiny
post_script runs once, after the first figure is drawn; {plot_id} is replaced with the graph div's id. Re-renders go through Plotly.react into the same graph div, so the handlers stay attached and are never stacked.
CLICK_TO_INPUT = """
document.getElementById('{plot_id}').on('plotly_click', function (ev) {
var p = ev.points[0];
Shiny.setInputValue('clicked', {x: p.x, y: p.y}, {priority: 'event'});
});
"""
@render_plotly(post_script=CLICK_TO_INPUT)
def scatter(): ...
@render.text
def click_info():
if not input.clicked.is_set():
return "Click a point."
pt = input.clicked()
return f"x={pt['x']}, y={pt['y']}"
input.clicked() raises a silent exception while the input has never been set, so check is_set() first when the output should show something before the first click.
Lower level
fig_to_ui(fig, div_id=None, *, height, width, figurewidget_margins, config, post_script)returns aTagListholding the plotly.js dependency, the helper dependency and a<div class="shiny-plotly">that draws the figure withPlotly.newPlot(plotly's ownto_htmlfragment). Use it from a plain@render.uithat composes a figure with other UI, or from any htmltools context. Each render draws a fresh graph; an output that is only a figure is better served byrender_plotly.plotly_js()is theHTMLDependencyfor plotly.js, served from the installedplotlywheel at/lib/plotly-<version>/plotly.min.js. Everyoutput_plotlyand everyfig_to_uifragment carries it, so it is optional; add it to the page UI when the first figure is inserted later (ui.insert_ui, a@render.uithat starts empty) and the bundle should load with the page.shiny_plotly_js()is the helper's dependency. Every output and fragment carries it too.FIGUREWIDGET_MARGINSis the{"l": 16, "t": 32, "r": 16, "b": 16}mapping.
render_plotly needs output_plotly; it is an output binding, not a render.ui, so ui.output_ui(id) does not draw it.
plotly.js on the wire
Shiny serves HTML dependencies from a plain static mount: no compression, no Cache-Control. plotly.min.js is 4.9 MB, so once the first session of a process has rendered a figure, shiny-plotly adds a route in front of that mount for the bundle's exact path (/lib/plotly-<version>/plotly.min.js) that serves it pre-compressed (brotli when the brotli package is installed, gzip otherwise; 1.2 MB or 1.5 MB on the wire) with Cache-Control: public, max-age=31536000, immutable, Vary: Accept-Encoding and an ETag per encoding. The URL is keyed by the plotly version, so a browser fetches each version once. Compression runs once per process, in a background thread; until it has finished the route serves the raw file with the same headers.
uv add "shiny-plotly[brotli]" # optional: brotli instead of gzip
Two things to know. The page load that starts the very first session of a process has already asked for the bundle before the route exists, so that one visitor gets the raw file from Shiny's mount; everyone after gets the compressed one. And if a reverse proxy in front of the app does its own compression and caching, or you want Shiny's static serving untouched for any reason, set SHINY_PLOTLY_NO_COMPRESS=1 in the app's environment.
Examples
uv run --with shiny-plotly shiny run examples/core_app.py
uv run --with shiny-plotly shiny run examples/express_app.py
Development
make sync # uv sync --all-groups
make browsers # playwright install chromium, once
make check # lint, typecheck, unit + e2e tests, browser tests, wheel check
make bench # the shinywidgets comparison above, on this machine
make test runs the unit tests and the in-process Shiny end-to-end tests over a real websocket, including the compressed bundle route. make test-browser drives the package in headless Chromium: fill sizing, resize without a window event, the graph div surviving a re-render, uirevision keeping a dragged zoom, purge once an output leaves the page, full screen, post_script click wiring (once, not stacked), error and None rendering, on-demand loading of plotly.js and the compressed, cached bundle as a fresh visitor sees it. make check-wheel installs the built wheel into a throwaway venv and runs the suite against it, so the published artifact is what was tested.
License
MIT. See LICENSE.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file shiny_plotly-0.2.0.tar.gz.
File metadata
- Download URL: shiny_plotly-0.2.0.tar.gz
- Upload date:
- Size: 26.5 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
uv/0.12.5 {"installer":{"name":"uv","version":"0.12.5","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
31229e2c8c20e70de0468ceb61bbb644805f7f210050871b425975df9780c903
|
|
| MD5 |
c90c6e48eaf9e4536b7f4bbb1cc2138e
|
|
| BLAKE2b-256 |
182b415a0e424dc55fd264e27c5a4815cedc257213b9073a94310d55381b0a44
|
File details
Details for the file shiny_plotly-0.2.0-py3-none-any.whl.
File metadata
- Download URL: shiny_plotly-0.2.0-py3-none-any.whl
- Upload date:
- Size: 18.6 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
uv/0.12.5 {"installer":{"name":"uv","version":"0.12.5","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
d611c2e026f75f6567eb432fc87e4d9e74d800c73be42bcc40b3d47336ca6088
|
|
| MD5 |
8129f3c13a437fc05987bbc2e3329a24
|
|
| BLAKE2b-256 |
0272648890b1f12936deba23099009aa6216c404be115c8a6c27bcc8d9c7164d
|