Two paths to Python dashboards:

 versus  

Author

Marie-Hélène Burle

Content from the webinar slides for easier browsing.

Please find the Plotly Dash section by Alex Razoumov here.

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

The server function updates the app with 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).

Example:

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

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

Resulting app

Click to open the app (Be prepared to wait a bit.)

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 the code to app.py file.
  • Run:
uv run shiny run --reload 3d_contour/app.py
  • Watch the app at 127.0.0.1:8000.

Resulting app

Click to open the app (Be prepared to wait a bit.)

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. If you find it more tidy, you can put it in a separate file and source it in your script.

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)

Resulting app

Click to open the app (Be prepared to wait a bit.)

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

Shinylive

A website hosting service serves the files. Users run the app in their browser thanks to WebAssembly.

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>.

Deployment options

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.).

Comparison with Plotly Dash

Dash Shiny
Stateless apps and callbacks allow very large horizontal scaling. Stateful apps without callbacks are reactive and require less code.
Dash transmits data between the server and the client using JSON so Dash only accepts lists or dictionaries. Supports more data types and libraries (e.g. strings, Polars/pandas DataFrames, DuckDB databases).
Natively accepted plotting library: Plotly (converters extend this to some degree). Supports more plotting libraries (Plotly, Matplotlib, Seaborn).
Only supports standard deployment. Also supports sharing of apps via URLs or gists and deployment to websites.

Resources

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