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.

$ pip install dash
$ uv 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

Our 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 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()

Alternatively, we could create the same 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()

Our 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 (nothing will happen if you try). 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.

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='Dash scatter plot with Plotly Express'),
    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(
    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 is very minimal in appearance. While we could style it using HTML and CSS, that approach assumes some familiarity with CSS. Dash provides higher-level components that make styling much easier. A popular option in industry, Dash Enterprise (requires a commercial license starting at $35k per year) provides Dash Design Kit, but it’s not an 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)
    match scaling:
        case "Fixed":
            np.random.seed(5)   # to generate the same sequence every time
            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":
            np.random.seed(5)   # to generate the same sequence every time
            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)

Other 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 your own VM – you can build one for free on one of our Clouds systems, or pay to use a commercial cloud,
  2. run a Dash app on your research group’s existing web server on an unused port,
  3. use Plotly Cloud: its free tier allows one running app at a time, or
  4. use Dash Enterprise paid options which is very expensive, so probably not a real option for us.

There are two ways you can work with Plotly Cloud: 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.