Two paths to Python dashboards:

 versus  

Marie-Hélène Burle

May 19, 2026


Please find the Plotly Dash section by Alex Razoumov here


Interactive web frameworks for science


Interactive web frameworks for science

2011

D3.js, Leaflet


Interactive web frameworks for science

2011

2012

D3.js, Leaflet

R Shiny (RStudio)


Interactive web frameworks for science

2011

2012

2013

D3.js, Leaflet

R Shiny (RStudio)

Bokeh, Plotly


Interactive web frameworks for science

2011

2012

2013

2017

D3.js, Leaflet

R Shiny (RStudio)

Bokeh, Plotly

Plotly Dash


Interactive web frameworks for science

2011

2012

2013

2017

2018

D3.js, Leaflet

R Shiny (RStudio)

Bokeh, Plotly

Plotly Dash

Observable


Interactive web frameworks for science

2011

2012

2013

2017

2018

2019

D3.js, Leaflet

R Shiny (RStudio)

Bokeh, Plotly

Plotly Dash

Observable

HoloViz Panel, Gradio, Streamlit


Interactive web frameworks for science

2011

2012

2013

2017

2018

2019

2022

D3.js, Leaflet

R Shiny (RStudio)

Bokeh, Plotly

Plotly Dash

Observable

HoloViz Panel, Gradio, Streamlit

Python Shiny (RStudio ➔ Posit), Kitware Trame


Interactive web frameworks for science

2011

2012

2013

2017

2018

2019

2022

2024

D3.js, Leaflet

R Shiny (RStudio)

Bokeh, Plotly

Plotly Dash

Observable

HoloViz Panel, Gradio, Streamlit

Python Shiny (RStudio ➔ Posit), Kitware Trame

Python Shiny Express (initial syntax now called Shiny Core)

Setup for this webinar

Packages

shiny

shinywidgets

netcdf4

plotly

polars

scikit-image

shinylive

Core Shiny functionality

For widget outputs (e.g. Plotly, Leaflet)

Data loading

Plotting

DataFrame to store collected data

Measurement of image property

Deployment of Shiny apps to static sites

Installation options

pip, conda, uv are options, but uv is by far the best method

With uv you can use the backward-compatible uv pip (if you are old-school), but I encourage you to use the superior uv project which is a complete environment and dependency manager (replacing Poetry, pip-tools, and pyenv)

Benefits:

  • creates a pyproject.toml with dependencies and Python version
  • no need to source the virtual env: just use uv run before commands
  • creates a lock file that can be shared for reproducibility

That’s what we will use here

Installation with uv project

Initialize a bare uv project:

uv init --bare

Install packages:

uv add netcdf4 plotly polars scikit-image shiny shinylive shinywidgets

You could then source the virtual env, but we will instead use uv run <command>

Structure of Shiny apps

Example input

Example output

Example layout

Syntax

from shiny import ui, render, App

app_ui = ui.page_fluid(
    ui.input_slider("slider", "Slider", 0, 100, 50),  
    ui.output_text_verbatim("value"),
)

def server(input, output, session):
    @render.text
    def value():
        return f"{input.slider()}"

app = App(app_ui, server)

More verbose, allows for more complex apps

from shiny.express import input, render, ui

ui.input_slider("slider", "Slider", 0, 100, 50)  

@render.text
def value():
    return f"{input.slider()}"

Compact, works in most situations

Example 1: Lasso selection

Goal

Here we match Alex’s connected plot app:

We draw a scatter plot of 100 random values sampled from a uniform distribution

A lasso lets us select a number of values (user input) and the app returns histograms of the selected values along both axes

First, let’s look at the code

Packages

# Generate random data
import numpy as np

# Draw plot
import plotly.express as px
import plotly.graph_objects as go
from plotly.callbacks import Points

# The 2 key packages for the Shiny app
from shiny import App, reactive, ui
from shinywidgets import output_widget, render_widget

Variables

# Set theme
BOOTSWATCH_CERULEAN = "https://cdn.jsdelivr.net/npm/bootswatch@5.3.8/dist/cerulean/bootstrap.min.css"

# Variables for the scatter plot
npoints = 100
xpos = np.random.rand(npoints)
ypos = np.random.rand(npoints)

Helper functions

def make_scatter_widget(on_selection):
    fig = px.scatter(
        x=xpos,
        y=ypos,
        range_x=[0, 1],
        range_y=[0, 1],
        width=600,
        height=600,
    )
    fig.update_traces(marker=dict(size=30, opacity=0.8))
    fig.update_layout(dragmode="lasso")

    widget = go.FigureWidget(fig)
    widget.data[0].on_selection(on_selection)
    return widget

def make_histogram(values, title):
    fig = px.histogram(x=values, nbins=10, width=400, height=280)
    fig.update_traces(xbins=dict(start=0, end=1, size=0.1))
    fig.update_xaxes(title_text=title, range=[0, 1])
    return fig

UI

This is our app HTML as a string

app_ui = ui.page_fluid(
    ui.tags.h2(
        "Lasso selection with connected outputs",
        class_="text-primary text-center fs-3 my-3",
    ),
    ui.div(
        ui.div(output_widget("g1"), class_="col-auto"),
        ui.div(
            output_widget("g2"),
            output_widget("g3"),
            class_="col-auto",
        ),
        class_="row justify-content-center g-3",
    ),
    title="Lasso selection",
    theme=BOOTSWATCH_CERULEAN,
)

Server function

Updates app from user inputs

def server(input, output, session):
    selection = reactive.value(None)

    def on_point_selection(trace, points: Points, state):
        selection.set(points)

    @render_widget
    def g1():
        return make_scatter_widget(on_point_selection)

    @render_widget
    def g2():
        selected = selection.get()
        if selected is None or not selected.point_inds:
            values = []
            title = "Select points to see details"
        else:
            values = list(selected.xs)
            title = "x-distribution"
        return make_histogram(values, title)

    @render_widget
    def g3():
        selected = selection.get()
        if selected is None or not selected.point_inds:
            values = []
            title = "Select points to see details"
        else:
            values = list(selected.ys)
            title = "y-distribution"
        return make_histogram(values, title)

Generate app object

app = App(app_ui, server)

Run app locally

Save the code to a script called app.py and run it with shiny run app.py

If you use uv project, prepend it with uv run

Use --reload for hot reloading upon edits

The file can be in a subdirectory (to have several apps in the same project)

uv run shiny run --reload lasso/app.py

You can now watch the app in a browser at 127.0.0.1:8000

Example 2: 3D isosurface

Goal

Let’s look at the isosurface example presented by Alex

We generate the isosurface of a 3D dataset for an adjustable isovalue

Packages

from pathlib import Path

# Memoisation
from functools import lru_cache

# Load data
from netCDF4 import Dataset

# Draw plot
import plotly.graph_objects as go
from skimage import measure

# Create app
from shiny import App, reactive, ui
from shinywidgets import output_widget, render_widget

Variables

# Slider values
SLIDER_MIN = 0.05
SLIDER_MAX = 1.95
SLIDER_STEP = 0.05
DEFAULT_LEVEL = 0.5

Load data

def load_density() -> object:
    data_path = Path(__file__).with_name("sineEnvelope.nc")
    with Dataset(data_path, "r", format="NETCDF4") as root_group:
        return root_group.variables["density"][:]

RHO = load_density()

Helper functions

def level_key(level: float) -> int:
    return int(round(level / SLIDER_STEP))

# @functools.lru_cache memoizes functions, caching up to maxsize
# Speeds up repetitive, expensive, or I/O-bound tasks
@lru_cache(maxsize=16)
def make_figure_for_level(level_value: float) -> go.Figure:
    vertices, triangles, _, _ = measure.marching_cubes(RHO, level_value)
    return 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],
                colorscale="Viridis",
                showscale=True,
            )
        ],
        layout=dict(
            height=600,
            uirevision="keep-camera",
            scene=dict(aspectmode="data", uirevision="keep-camera"),
            margin=dict(l=0, r=0, t=0, b=0),
        ),
    )

def make_figure(level: float) -> go.Figure:
    level_value = level_key(level) * SLIDER_STEP
    level_value = min(max(level_value, SLIDER_MIN), SLIDER_MAX)
    fig = make_figure_for_level(level_value)
    return go.Figure(fig)

UI

app_ui = ui.page_sidebar(
    ui.sidebar(
        ui.input_slider(
            "level",
            "Isovalue",
            min=SLIDER_MIN,
            max=SLIDER_MAX,
            value=DEFAULT_LEVEL,
            step=SLIDER_STEP,
            ticks=True,
            width="100%",
        ),
        width=220,
    ),
    ui.tags.style(
        """
        .bslib-sidebar-layout {
            height: 100vh;
        }
        .app-title {
            padding-bottom: 0;
            transform: translateY(0.12rem);
        }
        """
    ),
    ui.div(
        ui.div("Isosurface", class_="app-title text-primary text-center fs-3"),
        output_widget("g1"),
        class_="p-2 h-100",
    ),
    fillable=True,
    title="Representation of points that share the same value from a 3D dataset in a unit cube",
    window_title="Isosurface",
)

Server function

def server(input, output, session):
    fig = go.FigureWidget(make_figure(DEFAULT_LEVEL))

    @render_widget
    def g1():
        return fig

    @reactive.effect
    def _update_figure():
        source = make_figure(input.level())
        mesh = source.data[0]

        with fig.batch_update():
            fig.data[0].x = mesh.x
            fig.data[0].y = mesh.y
            fig.data[0].z = mesh.z
            fig.data[0].i = mesh.i
            fig.data[0].j = mesh.j
            fig.data[0].k = mesh.k
            fig.data[0].intensity = mesh.intensity

Generate app object

app = App(app_ui, server)

Run app locally

Same as above

Save code to app.py file

Run:

uv run shiny run --reload 3d_contour/app.py

Watch the app at 127.0.0.1:8000

Example 3: data collection

Goal

Create a dashboard to collect data on training topic requests

Validate and store the data in a course_interest.parquet file

Display data analytics

Packages

from __future__ import annotations
from datetime import UTC, datetime
from pathlib import Path

# For data validation
import re

# Draw plot
import plotly.graph_objects as go

# Save the data in a DataFrame
import polars as pl

# Create app
from shiny import App, reactive, render, ui
from shinywidgets import output_widget, render_widget

Variables

DATA_PATH = Path(__file__).with_name("course_interest.parquet")
EMAIL_PATTERN = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")

REQUEST_SCHEMA = {
    "submitted_at": pl.Utf8,
    "email": pl.Utf8,
    "course": pl.Utf8,
}

Styling

This is a string of CSS styling for our app

APP_CSS = """
:root {
    color-scheme: light;
    --page-bg: #f4f7fb;
    --panel-bg: #ffffff;
    --panel-border: #cfd8e3;
    --accent: #1f5a91;
    --accent-soft: #e8f1fb;
    --text: #17324d;
    --muted: #5a7188;
}

body {
    background: var(--page-bg);
    color: var(--text);
    font-family: Inter, Arial, Helvetica, sans-serif;
}

.app-shell {
    max-width: 1280px;
    margin: 0 auto;
    padding: 1.5rem;
}

.header-panel,
.panel {
    background: var(--panel-bg);
    border: 1px solid var(--panel-border);
    border-radius: 14px;
    box-shadow: 0 10px 24px rgba(20, 43, 66, 0.06);
}

.header-panel {
    padding: 1.4rem 1.6rem;
    margin-bottom: 1.25rem;
}

.eyebrow {
    color: var(--accent);
    font-size: 0.82rem;
    font-weight: 700;
    letter-spacing: 0.08rem;
    text-transform: uppercase;
    margin-bottom: 0.4rem;
}

.header-title {
    margin: 0;
    font-size: 1.8rem;
    font-weight: 700;
}

.header-copy {
    margin: 0.55rem 0 0;
    max-width: 54rem;
    color: var(--muted);
    line-height: 1.55;
}

.layout-grid {
    display: grid;
    gap: 1.25rem;
    grid-template-columns: minmax(320px, 380px) minmax(0, 1fr);
}

.panel {
    padding: 1.25rem;
}

.panel-title {
    margin: 0 0 0.2rem;
    font-size: 1.2rem;
    font-weight: 700;
}

.panel-subtitle {
    margin: 0 0 1rem;
    color: var(--muted);
    font-size: 0.95rem;
}

.form-group {
    margin-bottom: 1rem;
}

.form-control,
.btn,
.shiny-input-container textarea {
    border-radius: 10px;
}

.btn-primary {
    background: var(--accent);
    border-color: var(--accent);
}

.btn-outline-secondary {
    color: var(--accent);
    border-color: #a7bdd3;
}

.button-row {
    display: grid;
    gap: 0.75rem;
    margin-top: 1rem;
}

.status-box {
    margin-top: 1rem;
    padding: 0.85rem 0.95rem;
    border-radius: 10px;
    background: var(--accent-soft);
    border: 1px solid #d3e1f1;
    font-size: 0.93rem;
    color: var(--text);
}

.analytics-grid {
    display: grid;
    gap: 1rem;
    grid-template-columns: minmax(0, 1.5fr) minmax(280px, 0.9fr);
    align-items: start;
}

.stats-grid {
    display: grid;
    gap: 0.85rem;
}

.metric-card-grid {
    display: grid;
    gap: 0.85rem;
}

.stat-card,
.recent-card {
    border: 1px solid var(--panel-border);
    border-radius: 12px;
    background: #fbfdff;
    padding: 0.95rem;
}

.stat-label {
    display: block;
    color: var(--muted);
    font-size: 0.78rem;
    letter-spacing: 0.05rem;
    text-transform: uppercase;
    margin-bottom: 0.35rem;
}

.stat-value {
    font-size: 1.rem;
    font-weight: 700;
}

.recent-list {
    display: grid;
    gap: 0.75rem;
    margin-top: 0.9rem;
}

.recent-title {
    margin: 0;
    font-size: 1rem;
    font-weight: 600;
}

.recent-meta {
    margin-top: 0.3rem;
    color: var(--muted);
    font-size: 0.88rem;
}

.empty-box {
    border: 1px dashed #b9c8d8;
    border-radius: 12px;
    padding: 1rem;
    color: var(--muted);
    background: #fbfdff;
}

@media (max-width: 980px) {
    .layout-grid,
    .analytics-grid {
        grid-template-columns: 1fr;
    }
}
"""

Helper functions

def empty_requests() -> pl.DataFrame:
    return pl.DataFrame(schema=REQUEST_SCHEMA)

def normalize_course_name(course: str) -> str:
    return " ".join(course.split())

def normalize_email(email: str) -> str:
    return email.strip().lower()

def load_requests(path: Path = DATA_PATH) -> pl.DataFrame:
    if not path.exists():
        return empty_requests()
    return pl.read_parquet(path).select(list(REQUEST_SCHEMA))

def write_requests(df: pl.DataFrame, path: Path = DATA_PATH) -> None:
    df.write_parquet(path)

def append_request(email: str, course: str, path: Path = DATA_PATH) -> pl.DataFrame:
    current = load_requests(path)
    submission = pl.DataFrame(
        [
            {
                "submitted_at": datetime.now(UTC).strftime("%Y-%m-%d %H:%M:%S UTC"),
                "email": normalize_email(email),
                "course": normalize_course_name(course),
            }
        ],
        schema=REQUEST_SCHEMA,
    )
    updated = pl.concat([current, submission], how="vertical_relaxed")
    write_requests(updated, path)
    return updated

def validate_submission(email: str, course: str) -> str | None:
    normalized_email = normalize_email(email)
    normalized_course = normalize_course_name(course)
    if not normalized_email:
        return "Please enter an email address."
    if not EMAIL_PATTERN.match(normalized_email):
        return "Please enter a valid email address."
    if not normalized_course:
        return "Please enter the course you would like us to build."
    return None

def summary_metrics(df: pl.DataFrame) -> dict[str, str]:
    if df.height == 0:
        return {
            "total_requests": "0",
            "unique_emails": "0",
            "top_course": "No submissions",
        }

    top_course = (
        df.group_by("course")
        .len()
        .sort(["len", "course"], descending=[True, False])
        .row(0, named=True)["course"]
    )
    unique_emails = df["email"].n_unique()
    return {
        "total_requests": str(df.height),
        "unique_emails": str(unique_emails),
        "top_course": top_course,
    }

def make_course_figure(df: pl.DataFrame) -> go.Figure:
    fig = go.Figure()

    if df.height == 0:
        fig.add_annotation(
            text="No topic requests recorded yet",
            x=0.5,
            y=0.5,
            xref="paper",
            yref="paper",
            showarrow=False,
            font=dict(size=18, color="#5a7188"),
        )
        fig.update_xaxes(visible=False)
        fig.update_yaxes(visible=False)
    else:
        counts = (
            df.group_by("course")
            .len()
            .sort(["len", "course"], descending=[True, False])
            .head(8)
        )
        fig.add_trace(
            go.Bar(
                x=counts["len"].to_list(),
                y=counts["course"].to_list(),
                orientation="h",
                marker=dict(color="#2f6da3", line=dict(color="#214d74", width=1)),
                hovertemplate="%{y}: %{x} request(s)<extra></extra>",
            )
        )
        fig.update_yaxes(
            autorange="reversed",
            automargin=True,
            ticklabelstandoff=14,
        )
        fig.update_xaxes(title="Number of requests", dtick=1, gridcolor="#d8e2ee")

    fig.update_layout(
        template="plotly_white",
        height=420,
        margin=dict(l=40, r=20, t=40, b=20),
        paper_bgcolor="#ffffff",
        plot_bgcolor="#ffffff",
        title_font=dict(size=20, color="#17324d"),
        font=dict(color="#17324d"),
    )
    return fig

UI

app_ui = ui.page_fillable(
    ui.tags.head(ui.tags.style(APP_CSS)),
    ui.div(
        ui.div(
            ui.div("Research computing course planning", class_="eyebrow"),
            ui.h1("Topics requests", class_="header-title"),
            ui.p(
                ui.HTML("""
                   Please make sure to look for courses in <a href="https://explora.alliancecan.ca/" target="_blank">Explora</a> before submitting a request.<br>Note that we can't promise that we will satisfy all requests!
                """),
                class_="header-copy",
            ),
            class_="header-panel",
        ),
        ui.div(
            ui.div(
                ui.h2("Submit request", class_="panel-title"),
                ui.p(
                    "Give us a suggestion of a topic you would like us to cover.",
                    class_="panel-subtitle",
                ),
                ui.input_text(
                    "email",
                    "Canadian academic email address",
                    placeholder="your.name@uni.ca",
                ),
                ui.input_text(
                    "course",
                    "Desired topic",
                    placeholder="Applied time series analysis in Julia",
                ),
                ui.div(
                    ui.input_action_button(
                        "save_request",
                        "Save request",
                        class_="btn btn-primary",
                    ),
                    class_="button-row",
                ),
                class_="panel",
            ),
            ui.div(
                ui.h2("Requested topics", class_="panel-title"),
                ui.div(
                    ui.div(output_widget("course_plot")),
                    ui.div(
                        ui.output_ui("metric_cards"),
                        ui.output_ui("recent_requests"),
                        class_="stats-grid",
                    ),
                    class_="analytics-grid",
                ),
                class_="panel",
            ),
            class_="layout-grid",
        ),
        class_="app-shell",
    ),
    title="Topics requests",
)

Server function

def server(input, output, session):
    requests_df = reactive.value(load_requests())

    @reactive.effect
    @reactive.event(input.save_request)
    def _save_request():
        email = input.email()
        course = input.course()
        error = validate_submission(email, course)
        if error is not None:
            ui.notification_show(error, type="warning", duration=4)
            return

        updated = append_request(email=email, course=course)
        requests_df.set(updated)
        ui.update_text("email", value="")
        ui.update_text("course", value="")
        ui.notification_show(
            f"Saved request for {normalize_course_name(course)}.",
            type="message",
            duration=3,
        )

    @render_widget
    def course_plot():
        return make_course_figure(requests_df.get())

    @render.ui
    def metric_cards():
        metrics = summary_metrics(requests_df.get())
        return ui.div(
            ui.div(
                ui.span("Total requests", class_="stat-label"),
                ui.div(metrics["total_requests"], class_="stat-value"),
                class_="stat-card",
            ),
            ui.div(
                ui.span("Unique email addresses", class_="stat-label"),
                ui.div(metrics["unique_emails"], class_="stat-value"),
                class_="stat-card",
            ),
            ui.div(
                ui.span("Most requested topic", class_="stat-label"),
                ui.div(metrics["top_course"], class_="stat-value"),
                class_="stat-card",
            ),
            class_="metric-card-grid",
        )

    @render.ui
    def recent_requests():
        df = requests_df.get()
        if df.height == 0:
            return ui.div(
                "The most recent submission will appear here after the first request is submitted.",
                class_="empty-box",
            )

        row = df.tail(1).row(0, named=True)
        return ui.div(
            ui.div(
                ui.span("Most recent request", class_="stat-label"),
                ui.div(row["course"], class_="stat-value"),
                ui.div(
                    row["submitted_at"],
                    class_="recent-meta",
                ),
                class_="stat-card",
            ),
        )

App object

app = App(app_ui, server)

The apps in this talk are running with shinylive (see next)

Shinylive is extremely convenient, but it has limitations

One of them is the delay in launching apps. Another is that you can’t collect data

This app is thus for demo only and cannot be used unless deployed in a standard Shiny server

Deployment

Standard deployment


A server runs the app

Users are clients connecting to the server

Options

Your own server (VM in Alliance cloud or commercial cloud, lab server)

Posit Connect Cloud (free tier/paid plans)

Shinylive (with WebAssembly)


A website hosting service serves the files

Users run the app in their browser

Sharing options

Create and share a URL via the playground

Create a GitHub gist and share it at:
https://shinylive.io/py/app/#gist=<your-gist-hash>

Shinylive (with WebAssembly)


A website hosting service serves the files

Users run the app in their browser

Deployment options

Quarto dashboard

Shinylive deployment
(This is the most flexible option)

Shinylive deployment

Put your app.py file in a directory and run shinylive export <your-app-dir> site

If you use uv project, prepend it with uv run:

uv run shinylive export 3d_contour site

Make sure not to have other Python scripts in your app directory or it will mess things up

You can add a requirements.txt file to the app if needed

Deploy to static web hosting services (GitHub pages, Netlify, etc.)

Resources

Python Shiny GitHub repo
Official documentation
Components
Layouts
Templates
Playground
Shinylive
AI Shiny assistant
Shiny for AI
Integration with Quarto

Comparison with Plotly Dash

Dash

Stateless apps and callbacks allow very large horizontal scaling

Dash transmits data between the server and the client using JSON so Dash only accepts lists or dictionaries

Natively accepted plotting library: Plotly (converters extend this to some degree)

Only supports standard deployment

Shiny

Stateful apps without callbacks are reactive and require less code

Supports more data types and libraries (e.g. strings, Polars/pandas DataFrames, DuckDB databases)

Supports more plotting libraries (Plotly, Matplotlib, Seaborn)

Also supports sharing of apps via URLs and gists + deployment to websites