Interactive apps with Dash

Plotly Dash library (GitHub source) is a framework for making interactive data web applications.

Some of its features:

To install the core Dash library, use your favourite installer, ideally inside a dedicated virtual environment, e.g.

$ uv pip install dash
$ pip install dash
$ conda install -c conda-forge dash
Caution

While you can run Dash inside JupyterHub on the training cluster, I have not succeeded in getting jupyter_mode="inline" to work as intended for displaying interactive Dash apps inside a remote Jupyter notebook. By default, Dash starts a standalone web server, and JupyterHub (even when running on the same compute node) has no awareness of it.

There are manual ways to connect the two, but in practice the browser blocks this setup as mixed content for security reasons, since the notebook and the Dash app are served from separate web servers.

Long story short, I haven’t found a reliable, simple way to run Dash in a remote Jupyter notebook on the training cluster. On top of that, Dash doesn’t offer a clean way to stop the server — you have to kill the underlying Python process, which makes the setup even more awkward.

For these reasons, I will demo it locally on my computer – which is probably how you should develop Dash apps in the first place.

Here is how I installed Dash on my computer, along with some dependencies:

uv pip install dash                        # main Dash library
uv pip install dash-ag-grid                # to display tabular data components
uv pip install dash-bootstrap-components   # community-maintained styling library
uv pip install dash-mantine-components     # community-maintained styling library
uv pip install dash_vtk                    # for VTK
uv pip install scikit-image

Baseline plot without Dash

On my computer I already have a setup that opens every Plotly plot in a new browser tab, using the setup mentioned at the start of this workshop. As a reminder, if you need to redirect your plot, use one of these options:

import plotly.io as pio
pio.renderers.default = 'notebook'
pio.renderers.default = 'browser'

Let’s create a simple scatter plot with Graph Objects:

import plotly.graph_objs as go
import numpy as np

npoints = 100
trace = go.Scatter(x=np.random.rand(npoints),
                   y=np.random.rand(npoints),
                   mode='markers', marker=dict(size=[30]*npoints))
fig = go.Figure(data=[trace])
fig.update_layout(xaxis=dict(range=[0,1]), yaxis=dict(range=[0, 1]))
fig.show()

Alternatively, we could create the same plot with Plotly Express:

import plotly.express as px
import numpy as np

npoints = 100
fig = px.scatter(x=np.random.rand(npoints),
                 y=np.random.rand(npoints),
                 range_x=[0,1], range_y=[0,1])
fig.update_traces(marker=dict(size=30, opacity=0.8))
fig.show()

Baseline plot in Dash

To serve our Graph Objects plot inside Dash, we create a script scatter1.py:

# save this as scatter1.py
import plotly.graph_objs as go
import numpy as np
from dash import Dash, html, dcc   # dcc = Dash Core Components library

npoints = 100
trace = go.Scatter(x=np.random.rand(npoints), y=np.random.rand(npoints),
                   mode='markers', marker=dict(size=[30]*npoints))
fig = go.Figure(data=[trace])
fig.update_layout(xaxis=dict(range=[0,1]), yaxis=dict(range=[0, 1]),
                  width=800, height=800)

app = Dash()          # launch an instance of a Dash app
app.layout = [
    html.Div(children='Dash scatter plot with GO'),   # content container
    dcc.Graph(figure=fig)   # our plot container from Dash Core Components
]
app.run(debug=True)   # run the instance
Note

You must run a Dash script by passing it to the Python interpreter:

python scatter1.py

Because of how Dash is designed, running this script from a Python shell won’t work (the code will run, but nothing will be displayed on the output port). One particularly nice feature of Dash – that would be difficult to replicate in a shell – is hot reloading: whenever you change the script file, Dash automatically re-executes it and refreshes the app.

In this code above html.Div is a wrapper for standard html5 block-level container (content division element). It takes a list of content elements (or a single element) and CSS utility classes for styling. For an example of a list, replace

app.layout = [
    html.Div(children='Dash scatter plot with Graph Objects'),   # content container
    dcc.Graph(figure=fig)
]

with a list containing a title and a paragraph:

app.layout = [
    html.Div(children=[html.H1("Dash scatter plot with Graph Objects"),
                       html.P("This is a paragraph.")]),
    dcc.Graph(figure=fig)
]

Alternatively, you can use Plotly Express functions with Dash:

# save this as scatter2.py
import plotly.express as px
import numpy as np
from dash import Dash, html, dcc, callback, Output, Input

npoints = 100
fig = px.scatter(x=np.random.rand(npoints), y=np.random.rand(npoints),
                 range_x=[0,1], range_y=[0,1], width=800, height=800)
fig.update_traces(marker=dict(size=30, opacity=0.8))

app = Dash()
app.layout = [
    html.Div(children=html.H1("Dash scatter plot with Graph Objects")),
    dcc.Graph(figure=fig)
]
app.run(debug=True)
Note

A single running app can have multiple instances, i.e. multiple users can connect to the same app at the same time, each user with their own interactive view and state.

Make it interactive

Let’s add two new elements to our script:

  1. a slider, to select the number of points, and
  2. a callback function
    • takes value input from the slider component slider1
    • returns figure output to the graph component g1
# save this as scatter3.py
import plotly.express as px
import numpy as np
from dash import Dash, html, dcc, callback, Output, Input

app = Dash()
app.layout = [
    html.Div(children='Scatter plot with controls'),   # HTML component
    dcc.Slider(id='slider1', min=5, max=200, step=5,   # slider component
               value=100,  # default starting value
               marks={i: str(i) for i in range(0,201,50)},
               tooltip={"placement": "bottom", "always_visible": True}
               ),
    dcc.Graph(figure={}, id='g1')                      # graph component
]
@callback(
    # all outputs must be listed first
    Output(component_id='g1', component_property='figure'),
    Input(component_id='slider1', component_property='value')
)
def update_graph(selected):
    print('running the callback function for', selected)
    fig = px.scatter(x=np.random.rand(selected), y=np.random.rand(selected),
                     range_x=[0,1], range_y=[0,1], width=800, height=800)
    fig.update_traces(marker=dict(size=30, opacity=0.8))
    return fig
app.run(debug=True)

In this script, we moved the figure-drawing code into the callback function, since it depends on the input number of points and must be re-executed each time we move slider.

Inside the callback function, the argument name selected is used only inside the function.

Add some style

At the moment, our application has a very minimal appearance. While we could improve it with HTML and CSS, that approach assumes some familiarity with CSS. Dash also provides higher-level components that make styling much easier. A popular industry option is Dash Enterprise, which includes the Dash Design Kit, but it requires a commercial license starting at about $35k per year and is therefore not a practical option for us.

Fortunately, the Dash community has developed two open-source libraries – Dash Bootstrap Components and Dash Mantine Components – that make styling Dash apps much easier. They are easy to install:

$ uv pip install dash-bootstrap-components
$ uv pip install dash_mantine_components

Today, I’ll use Dash Bootstrap Components, though the two libraries offer similar functionality.

# save this as scatter4.py
import plotly.express as px
import numpy as np
from dash import Dash, html, dcc, callback, Output, Input
import dash_bootstrap_components as dbc

external_stylesheets = [dbc.themes.CERULEAN]
app = Dash(__name__, external_stylesheets=external_stylesheets)
app.layout = dbc.Container([
    dbc.Row([html.Div('Horizontal layout',
                      className="text-primary text-center fs-3")]), # colour+centring+size
    dbc.Row([
        dbc.Col([
            dcc.Slider(id='slider1', min=5, max=200, step=5,
                       value=100,
                       vertical=True,
                       marks={i: str(i) for i in range(0,201,50)},
                       tooltip={"placement": "left", "always_visible": True}
                       ),
        ], width="auto", align="center", className="ms-4"),   # ms-4 is a left margin of size 4
        dbc.Col([
            dcc.Graph(figure={}, id='g1')
        ], width="auto"),
    ], justify="center")   # centre both columns in the row
], fluid=True)   # use full screen width, min margin padding
@callback(
    Output(component_id='g1', component_property='figure'),
    Input(component_id='slider1', component_property='value')
)
def update_graph(selected):
    print('running the callback function for', selected)
    fig = px.scatter(x=np.random.rand(selected), y=np.random.rand(selected),
                     range_x=[0,1], range_y=[0,1], width=800, height=800)
    fig.update_traces(marker=dict(size=30, opacity=0.8))
    return fig
app.run(debug=True)

Multiple inputs

Now, let’s add more controls – two extra sliders and radio buttons (only one selection) – for the total of 4 inputs to the callback function. All interactive controls are sitting inside a single column, and are all packaged into their own html.Div with mb-4 margins for nicer visual separation:

# save this as scatter5.py
import plotly.express as px
import numpy as np
from dash import Dash, html, dcc, callback, Output, Input
import dash_bootstrap_components as dbc

external_stylesheets = [dbc.themes.CERULEAN]
app = Dash(__name__, external_stylesheets=external_stylesheets)
app.layout = dbc.Container([
    dbc.Row([html.Div('Multiple inputs', className="text-primary text-center fs-3")]),
    dbc.Row([
        dbc.Col([
            html.Div([
                html.Label("Number:", className="fw-bold mb-2"),
                dcc.Slider(id='slider1', min=1, max=500, step=5,
                           value=100, vertical=False,
                           marks={i: str(i) for i in range(0,501,100)},
                           tooltip={"placement": "bottom", "always_visible": True}
                           ),
            ], className="mb-4"),
            html.Div([
                html.Label("Scaling:", className="fw-bold mb-2"),
                dcc.RadioItems(['Fixed', 'Scaled'], 'Fixed', id='scaling'),
            ], className="mb-4"),
            html.Div([
                html.Label("Fixed size:", className="fw-bold mb-2"),
                dcc.Slider(id='slider2', min=1, max=50, step=1,
                           value=5, vertical=False,
                           marks={i: str(i) for i in range(0,81,20)},
                           tooltip={"placement": "bottom", "always_visible": True}
                           ),
            ], className="mb-4"),
            html.Div([
                html.Label("Opacity:", className="fw-bold mb-2"),
                dcc.Slider(id='slider3', min=0, max=1, step=0.1,
                           value=0.8, vertical=False,
                           marks={i/10: str(i/10) for i in range(0,10,1)},
                           tooltip={"placement": "bottom", "always_visible": True}
                           ),
            ], className="mb-4"),
        ], width=3, align="center", className="ms-4"),
        dbc.Col([
            dcc.Graph(figure={}, id='g1')
        ], width="auto"),
    ], justify="center")   # centre all columns in the row
], fluid=True)
@callback(
    Output(component_id='g1', component_property='figure'),
    Input(component_id='slider1', component_property='value'),
    Input(component_id='scaling', component_property='value'),
    Input(component_id='slider2', component_property='value'),
    Input(component_id='slider3', component_property='value')
)
def update_graph(npoints, scaling, pointSize, opacity):
    print('running the callback function for', npoints, scaling, pointSize, opacity)
    np.random.seed(5)   # to generate the same sequence every time
    match scaling:
        case "Fixed":
            fig = px.scatter(x=np.random.rand(npoints), y=np.random.rand(npoints),
                             range_x=[0,1], range_y=[0,1], width=800, height=800)
            fig.update_traces(marker=dict(size=pointSize, opacity=opacity))
        case "Scaled":
            fig = px.scatter(x=np.random.rand(npoints), y=np.random.rand(npoints),
                             range_x=[0,1], range_y=[0,1], width=800, height=800,
                             size=np.random.rand(npoints), size_max=30)
            fig.update_traces(marker=dict(opacity=opacity))
    return fig
app.run(debug=True)

Multiple independent outputs

Now we go back to a single, vertical slider for the number of points, and add two histogram plots showing the distribution of all points in the scatter plot along the x- and y-axis, respectively. The slider affects all three plots – and this is the only connection between the plots. For example, if you zoom in on an area in the scatter plot, the histogram plots will not change.

# save this as scatter6.py
import plotly.express as px
import numpy as np
from dash import Dash, html, dcc, callback, Output, Input
import dash_bootstrap_components as dbc

external_stylesheets = [dbc.themes.CERULEAN]
app = Dash(__name__, external_stylesheets=external_stylesheets)
app.layout = dbc.Container([
    dbc.Row([html.Div('Multiple independent outputs', className="text-primary text-center fs-3")]),
    dbc.Row([
        dbc.Col([
            dcc.Slider(id='slider1', min=1, max=200, step=5,
                       value=100,
                       vertical=True,
                       marks={i: str(i) for i in range(0,201,50)},
                       tooltip={"placement": "left", "always_visible": True}
                       ),
        ], width="auto", align="center", className="ms-4"),   # ms-4 is a left margin of size 4
        dbc.Col([
            dcc.Graph(figure={}, id='g1')
        ], width="auto"),
        dbc.Col([
            dcc.Graph(figure={}, id='g2'),
            dcc.Graph(figure={}, id='g3')
        ], width="auto"),
    ], justify="center"),   # centre both columns in the row
], fluid=True)
@callback(
    Output(component_id='g1', component_property='figure'),
    Output(component_id='g2', component_property='figure'),
    Output(component_id='g3', component_property='figure'),
    Input(component_id='slider1', component_property='value')
)
def update_graph(selected):
    print('running the callback function for', selected)
    xpos = np.random.rand(selected)
    ypos = np.random.rand(selected)
    fig1 = px.scatter(x=xpos, y=ypos, range_x=[0,1], range_y=[0,1], width=600, height=600)
    fig1.update_traces(marker=dict(size=30, opacity=0.8))
    fig2 = px.histogram(x=xpos, nbins=10, width=400, height=280)
    fig2.update_traces(xbins=dict(start=0, end=1, size=0.1))       # fixed number of bins
    fig2.update_xaxes(title_text='x-distribution', range=[0, 1])   # fixed x-limits
    fig3 = px.histogram(x=ypos, nbins=10, width=400, height=280)
    fig3.update_traces(xbins=dict(start=0, end=1, size=0.1))       # fixed number of bins
    fig3.update_xaxes(title_text='y-distribution', range=[0, 1])   # fixed x-limits
    return fig1, fig2, fig3
app.run(debug=True)

Connected plots

Let’s remove the slider, and add a Lasso selection in the scatter plot and show the x- and y-histograms only for the points selected with Lasso. If there is no selection, add a text “Select points to see details” to the histograms.

The callback function has:

  • two outputs (two histogram plots)
  • one input: special input selectedData from the graph component g1 – we call this argument selectedPoints inside the callback function
# save this as scatter7.py
import plotly.express as px
import numpy as np
from dash import Dash, html, dcc, callback, Output, Input
import dash_bootstrap_components as dbc

external_stylesheets = [dbc.themes.CERULEAN]
app = Dash(__name__, external_stylesheets=external_stylesheets)
npoints = 100
xpos = np.random.rand(npoints)
ypos = np.random.rand(npoints)
fig1 = px.scatter(x=xpos, y=ypos, range_x=[0,1], range_y=[0,1], width=600, height=600)
fig1.update_traces(marker=dict(size=30, opacity=0.8))
fig1.update_layout(dragmode='lasso')
app.layout = dbc.Container([
    dbc.Row([html.Div('Lasso selection with connected outputs', className="text-primary text-center fs-3")]),
    dbc.Row([
        dbc.Col([
            dcc.Graph(figure=fig1, id='g1')
        ], width="auto"),
        dbc.Col([
            dcc.Graph(figure={}, id='g2'),
            dcc.Graph(figure={}, id='g3'),
        ], width="auto"),
    ], justify="center"),   # centre both columns in the row
], fluid=True)
@callback(
    Output(component_id='g2', component_property='figure'),
    Output(component_id='g3', component_property='figure'),
    Input('g1', 'selectedData')
)
def update_graph(selectedPoints):
    print('running the callback function for', selectedPoints)
    if selectedPoints is None:
        xsel, ysel = [], []
        xtitle = "Select points to see details"
        ytitle = xtitle
    else:
        xsel = [p['x'] for p in selectedPoints['points']]   # x of selected points
        ysel = [p['y'] for p in selectedPoints['points']]   # y of selected points
        xtitle, ytitle = "x-distribution", "y-distribution"
    fig2 = px.histogram(x=xsel, nbins=10, width=400, height=280)
    fig2.update_traces(xbins=dict(start=0, end=1, size=0.1))   # fixed number of bins
    fig2.update_xaxes(title_text=xtitle, range=[0, 1])         # fixed x-limits
    fig3 = px.histogram(x=ysel, nbins=10, width=400, height=280)
    fig3.update_traces(xbins=dict(start=0, end=1, size=0.1))   # fixed number of bins
    fig3.update_xaxes(title_text=ytitle, range=[0, 1])         # fixed x-limits
    return fig2, fig3
app.run(debug=True)

3D contours

In this section, we demonstrate two approaches to generating iso-contours from a 3D dataset in a unit cube:

  1. via Plotly’s standard plotly.graph_objects.Mesh3d function and
  2. via Dash VTK component library.

We start with a classical Graph Objects plot ported to Dash:

# save this as contour.py
from dash import Dash, html, dcc, callback, Output, Input
import dash_bootstrap_components as dbc
import plotly.graph_objects as go
from skimage import measure
from netCDF4 import Dataset
from pathlib import Path

filename = str(next((Path.home()/"training").rglob("data/sineEnvelope.nc")))
rootgrp = Dataset(filename, "r", format="NETCDF4")
rho = rootgrp.variables['density'][:]
rootgrp.close()

external_stylesheets = [dbc.themes.CERULEAN]
app = Dash(__name__, external_stylesheets=external_stylesheets)
app.layout = dbc.Container([
    dbc.Row([html.Div('Isosurface', className="text-primary text-center fs-3")]), # colour+centring+size
    dbc.Row([
        dbc.Col([
            dcc.Slider(id='slider1', min=0.05, max=1.95, step=0.05, value=0.5, vertical=True,
                       marks={i/10: str(i/10) for i in range(0, 21, 2)},
                       tooltip={"placement": "left", "always_visible": True}
                       ),
            html.Label("Iso-value", className="text-center w-100 fw-bold mt-2")
        ], width=1, align="center", className="ms-4"),   # ms-4 is a left margin of size 4
        dbc.Col([
            dcc.Graph(figure={}, id='g1')
        ], width="auto"),
    ], justify="center")   # centre both columns in the row
], fluid=True)   # use full screen width, min margin padding
@callback(
    Output(component_id='g1', component_property='figure'),
    Input(component_id='slider1', component_property='value')
)
def update_graph(selected):
    print('running the callback function for', selected)
    # generate marching cubes
    vertices, triangles, normals, values = measure.marching_cubes(rho, selected)   # create an isosurface
    print(format(vertices.shape[0], ","), "vertices and", format(triangles.shape[0], ","), "triangles")
    fig = go.Figure(data=[
        go.Mesh3d(
            x=vertices[:,0], y=vertices[:,1], z=vertices[:,2],
            i=triangles[:, 0], j=triangles[:, 1], k=triangles[:, 2],
            intensity=vertices[:, 2], # color by z-height
            colorscale="Viridis", showscale=True
        )
    ])
    fig.update_layout(width=900, height=800,
                      scene=dict(aspectmode="data",
                                 uirevision="keep-camera"), # preserve rotation, zoom, pan across graph update
                      margin=dict(t=20, b=10, l=10, r=10))   # margins around the figure
    return fig
app.run(debug=True)

Next, we try Dash VTK, which lets you run a custom VTK pipeline inside a Dash server and then pushes the resulting visualization to the client. In this implementation, we compute the polygonal mesh forming the iso-contour surface outside VTK, and then use Dash VTK to render this mesh:

# save this as vtk-contour.py
from dash import Dash, html, dcc, callback, Output, Input
import dash_bootstrap_components as dbc
import dash_vtk
from skimage import measure
from netCDF4 import Dataset
import numpy as np
from pathlib import Path

filename = str(next((Path.home()/"training").rglob("data/sineEnvelope.nc")))
rootgrp = Dataset(filename, "r", format="NETCDF4")
rho = rootgrp.variables['density'][:]
rootgrp.close()

app = Dash(__name__, external_stylesheets=[dbc.themes.CERULEAN])

app.layout = dbc.Container([
    dbc.Row([html.Div('Isosurface with Dash VTK', className="text-primary text-center fs-3")]), # colour+centring+size
    dbc.Row([
        dbc.Col([
            dcc.Slider(
                id='slider1', min=0.05, max=1.95, step=0.05, value=0.5, vertical=True,
                marks={i/10: str(i/10) for i in range(0, 21, 2)},
                tooltip={"placement": "left", "always_visible": True}
            ),
            html.Label("Iso-value", className="text-center w-100 fw-bold mt-2")
        ], width=1, align="center", className="ms-4"),
        dbc.Col([
            html.Div(
                dash_vtk.View(
                    dash_vtk.GeometryRepresentation(
                        dash_vtk.Mesh(id="vtk-mesh", state={})
                    )
                ),
                style={"width": "900px", "height": "800px"}
            )
        ], width="auto"),
    ], justify="center")
], fluid=True)

@callback(
    Output('vtk-mesh', 'state'),
    Input('slider1', 'value')
)
def update_graph(selected):
    print(f'Generating isosurface for: {selected}')
    # generate marching cubes
    vertices, triangles, normals, values = measure.marching_cubes(rho, selected)
    print(format(vertices.shape[0], ","), "vertices and", format(triangles.shape[0], ","), "triangles")
    # Dash VTK expects a specific dictionary format for the mesh; build it manually
    padding = np.ones((triangles.shape[0], 1), dtype=np.int32) * 3
    cells = np.hstack((padding, triangles)).ravel()
    return {
        "mesh": {
            "points": vertices.ravel().astype(np.float32),
            "polys": cells.astype(np.int32),
        }
    }

app.run(debug=True)

Alternatively, you could write a code to compute the iso-surface with VTK library calls.

3D volumetric view

For the same NetCDF dataset, we can generate a volumetric view (giving here a much simpler code without styling):

# save this as vtk-volume.py
from dash import Dash, html
import dash_vtk
from netCDF4 import Dataset
from pathlib import Path

filename = str(next((Path.home()/"training").rglob("data/sineEnvelope.nc")))
rootgrp = Dataset(filename, "r", format="NETCDF4")
rho = rootgrp.variables['density'][:]
rootgrp.close()

app = Dash(__name__)
app.layout = html.Div(
    dash_vtk.View([
        dash_vtk.VolumeRepresentation([
            dash_vtk.VolumeController(), # UI to adjust opacity/color mapping
            dash_vtk.ImageData(
                dash_vtk.PointData([
                    dash_vtk.DataArray(
                        registration="setScalars",
                        values=rho.flatten() # must be a 1D flat array
                    )
                ]),
                dimensions=rho.shape,
                origin=[0, 0, 0],
                spacing=[1, 1, 1]
            )
        ])
    ]),
    style={"height": "100vh", "width": "100%"}
)
app.run(debug=True)

Original (left) and narrow-filter (right) views

Passing UI state to a callback function

In certain cases, a callback function at runtime might need a value from the UI, e.g. text pasted into the text box. You can pass these values via a State input. By itself, typing text inside the text box does not trigger the callback function, but clicking the button does.

# save this as state.py
from dash import Dash, dcc, html
from dash.dependencies import Input, Output, State

app = Dash(__name__)

app.layout = html.Div([
    html.H3("Dash Callback State Example"),
    dcc.Input(id="input-text", type="text", placeholder="Type something...", value=""),
    html.Button(id="submit-button", n_clicks=0, children="Submit"),
    html.Hr(),
    html.Div(id="output-container")
])

@app.callback(
    Output("output-container", "children"),
    Input("submit-button", "n_clicks"),  # n_clicks change triggers the callback
    State("input-text", "value")         # Passes data without triggering
)
def update_output(numClicks, textValue):
    if numClicks == 0:
        return "Enter text and click submit."    
    return f"Button clicked {numClicks} times. The latest text is: '{textValue}'"

app.run(debug=True)
  1. Every time the button is clicked, its n_clicks value changes, which triggers the callback function.
  2. When the function runs, Dash looks up the current value (state) of the text box and passes it into textValue. However, typing inside the text box does not trigger the function on its own.

Dashboard collecting topic requests

In this example, we show a dashboard for collecting training topic requests. It includes two text input fields using dcc.Input, and all submissions are stored in the file courseInterest.parquet. This file persists across dashboard restarts, with new entries appended over time. The dashboard validates both the email and topic fields, including checking the email format; if both appear valid, the submission is accepted. On the right, it also displays request analytics, which I will demo on my laptop:

cd ~/training/dash
python topicRequest.py

I am not showing the source code, as this dashboard was created with an LLM (so you can easily create your own!) in about 10 minutes and is around 560 lines of code, most of it consisting of CSS styling for a polished appearance.

To read the results, I simply run:

uv pip install pandas pyarrow
import pandas as pd
df = pd.read_parquet('courseInterest.parquet')
print(df)

List of controls

The Dash Core Components (dcc) library offers the following interactive controls:

Method Description
dcc.Dropdown select one item
dcc.RadioItems select one item
dcc.Checklist select multiple items
dcc.DatePickerRange calendar to select start / end dates
dcc.DatePickerSingle calendar to select a single date
dcc.Slider slider with one handle
dcc.RangeSlider slider with two handles, to specify a range
dcc.Input text/number input field
dcc.Textarea multi-line text input box
dcc.Upload drag-and-drop area for uploading files
dcc.Tabs switch between different dashboard tabs


Few more “hidden” controls:

Method Description
dcc.Interval hidden component that triggers a callback periodically
dcc.Location tracks the address bar in your web browser
dcc.Store client-side storage of different portions of the URL

Publishing / hosting

A Dash app requires a server – you can’t just save its output to a standalone HTML5 file. If you want to share your Dash app with the world, you have several options:

  1. spin up a Dash app on a server
    1. you can use your own VM, e.g. build one for free on one of our Clouds systems,
    2. you can pay to use a VM in the commercial cloud,
    3. you can run a Dash app on your research group’s existing web server using an unused port,
  2. use Plotly Cloud: its free tier allows one running app at a time, or
  3. use Dash Enterprise paid options which are very expensive, so probably not a real option for us.

With Plotly Cloud, there are two possible workflows: from your running app or via their portal. For the former you’ll need to install Plotly Cloud extension with uv pip install "dash[cloud]" (exact command depends on your Python setup) and then start the app with app.run(debug=True) and use the dev tools UI in the lower right corner. To replace your currently running app (there is a limit of 1) you need to open the app settings, or click Open Settings in Plotly Cloud | General | Delete Dash app, and only then you can publish a new app.

I personally find it easiest to use their web UI and drag and drop my Dash *.py script files. Also note that – when publishing to Plotly Cloud – it appears it won’t run the community styling libraries: I tried Dash Bootstrap Components and saw no formatting.

Comparison to Shiny for Python

Release timeline:

  • 2012 Shiny for R: makes it easy to build interactive web apps directly from R
  • 2013 Plotly launch: interactive plots for the web (compare to static Matplotlib plots)
  • 2017 ✅ Plotly Dash: adds a server component to Plotly
  • 2022 ✅ Shiny for Python
  • 2022 ✅ Kitware Trame v1 (now on v3)

While all three frameworks allow you to build fully interactive web applications entirely in Python without deep web development (HTML/JS) expertise, they come from completely different paradigms, architectural foundations, and primary target domains. Here we compare Plotly Dash and Shiny for Python:

Feature Plotly Dash Shiny for Python
Programming model functional / declarative imperative / reactive
Input dcc.Graph takes Plotly Express and Graph Objects figure, Python dictionaries; there is converter for Matplotlib/Seaborn figures, as well as wrappers for VTK, Leaflet @render.plot Matplotlib/Seaborn figures; @render.widget Plotly figure, PyVista/VTK viewer, Altair chart; @render.data_frame Pandas/Polars dataframes; @render.text Python str; @render.image dictionary with a local image file path
Reactivity style explicit callbacks with specified Input/Output/State transparent reactivity
State management stateless Python backend and a stateful React.js frontend that looks for any Python callbacks that have that component property listed as Input or State stateful (server-side session management)
UI definition static component tree (HTML/Components) dynamic UI (UI as an output)
Execution flow event-driven (triggered by specific IDs) dependency-driven (automatic graph resolution)
Server-side Python always need a backend Dash server backend Shiny server
Client-side Python (browser) not possible at the moment; use Plotly Express with standalone HTML5 for basic interactivity can run Python code via WebAssembly using Shinylive model: the browser becomes the server; some restrictions, including your data size
Deployment options your own server in a VM or parallel to an existing web server, Plotly Cloud (paid with a free tier) your own server, Posit Connect Cloud (paid with a free tier), Shinylive on GitHub Pages, Netlify, or your own website, integration with Quarto dashboards
Best for large-scale apps rapid prototyping and complex data dependencies