More Styling 💅

Lecture overview

Learning goals

Block 1 — Styles & themes

  1. Understand how Shiny builds on Bootstrap 5
  2. Swap entire app themes with ui.Theme / Bootswatch presets
  3. Prefer Bootstrap utilities; use inline CSS for targeted tweaks
  4. Build a styled dashboard header

Block 2 — Layout & UX

  1. Arrange outputs with ui.card() and layout_columns(col_widths=)
  2. Use ui.accordion() to collapse groups of controls
  3. Display reactive KPIs with ui.value_box()

Block 3 — Table polish

  1. Apply conditional row styling to highlight data signals

Block 4 — Advanced reactivity

  1. Make input components reactive (linked filters)
  2. Export filtered data to CSV with data() / data_view()
  3. Reset table selections programmatically

Topics

Block 1 — Styles & themes

  • Bootstrap 5 foundation · Bootswatch theme presets
  • page_fillable vs page_fluid
  • Bootstrap utilities vs inline CSS · styled header

Block 2 — Layout & UX

  • Cards + layout_columns(col_widths=) · Accordion
  • Value boxes with reactive comparison

Block 3 — Table polish & synthesis

  • Conditional row / cell styling
  • Full styled dashboard · Layout tips

Block 4 — Advanced reactivity

  • Column picker · Linked filters
  • CSV export (data() vs data_view()) · Selection reset · Editable tables

Review: 01b-dashboard-design design principles

Block 1 — Styles & themes

Bootstrap 5 foundation · ui.Theme presets · Bootstrap utilities · page_fillable · styled header

Shiny and Bootstrap

Shiny’s UI layer is built on top of Bootstrap 5 — it provides a 12-column responsive grid and a library of ready-made utility classes.

You can (but if you don’t have to - don’t!) use any Bootstrap 5 class via the class_= argument on any ui.tags.* element.

Using standard themes >> using Bootstrap-based app CSS >> manually assigning classes >> inline CSS

Utility class groups — attach via class_= on any ui.div(), ui.p(), ui.tags.*

Group Key classes Example
Spacing mt-3 px-2 mb-0 class_="mt-3 px-2"
Typography text-muted fw-bold small fs-3 class_="text-muted small"
Color bg-primary text-white bg-light class_="bg-primary text-white"
Flex d-flex justify-content-between align-items-center class_="d-flex justify-content-between"
Borders border rounded rounded-3 class_="border rounded p-2"
Badges badge bg-success bg-danger ui.tags.span("New", class_="badge bg-success")

Responsive 12-column grid

layout_columns(col_widths=) maps directly to Bootstrap columns — widths must sum to 12:

ui.layout_columns(
    ui.card(...),   # 5 cols
    ui.card(...),   # 7 cols
    col_widths=[5, 7],
)

Pass a dict for breakpoints — stacks on mobile, side-by-side on desktop:

col_widths={"sm": [12, 12], "lg": [5, 7]}

Theme presets with ui.Theme

We can swap the entire look in one line of code: Shiny ships with Bootswatch presets:

# In a standalone app (not shinylive):
from shiny import App, ui

app_ui = ui.page_sidebar(
    ui.sidebar("Controls here"),
    ui.card(ui.p("Content")),
    theme=ui.Theme("cosmo"),       # try: "flatly", "darkly", "minty", "lux"
)

Available presets include: shiny (default) · bootstrap · cerulean · cosmo · darkly · flatly · journal · litera · lumen · lux · minty · pulse · sandstone · simplex · sketchy · slate · solar · spacelab · superhero · united · yeti

You can also customise variables:

my_theme = (
    ui.Theme("flatly")
    .add_defaults(primary="#6f42c1")
    .add_rules("h1 { letter-spacing: 0.05em; }")
)

ui.Theme requires pip install libsass (or pip install "shiny[theme]") and is compiled at first load. It does not work inside shinylive.

Design principle (from 01b): Start from established style and UX conventions — themes let you adopt a consistent look quickly, then customize.

page_fillable vs page_fluid

ui.page_fluid()

  • Content has a fixed max-width and scrolls vertically
  • Good for text-heavy apps or when output heights are predictable
  • Default behaviour

ui.page_fillable()

  • Content fills the browser window vertically
  • Cards and outputs stretch to use available space
  • Best for dashboards with charts / tables
  • Set fill=False on individual items to opt out
# Filling layout — cards expand to fill the screen
app_ui = ui.page_fillable(
    ui.layout_columns(
        ui.card(ui.output_data_frame("tbl")),   # fills
        ui.card(output_widget("chart")),          # fills
        col_widths=[5, 7],
    ),
)

Rule of thumb: use page_fillable for dashboards, page_fluid for reports and forms.

Docs: ui.page_fillable · ui.page_fluid

Design principle (from 01b): A consistent grid layout improves scanning — fillable pages keep cards aligned across the viewport.

Inline CSS on UI components

Prefer Bootstrap classes — they respect the active ui.Theme() and need no extra CSS. Use style= only for what Bootstrap can’t express.

Bootstrap first (preferred)

# Themed header — bg-primary updates automatically
# when you swap ui.Theme("flatly") → ui.Theme("darkly")
ui.div(
    ui.h4("Cars Explorer", class_="mb-0"),
    class_="bg-primary text-white p-3 d-flex align-items-center",
)

# Status badge in a card header
ui.card_header(
    "MPG trend",
    ui.tags.span("live", class_="badge bg-success ms-2"),
)

Inline CSS — targeted tweaks only

# Custom brand hex — no Bootstrap equivalent
ui.div(
    ui.h4("Cars Explorer", class_="mb-0 text-white"),
    class_="p-3 d-flex align-items-center",
    style="background-color: #1e40af;",
)

# Exact sizing Bootstrap's scale can't express
ui.div(output_widget("chart"), style="min-height: 320px;")

Use style= when: custom brand colour, exact pixel value, or a CSS property with no Bootstrap utility.

Creating a styled dashboard header

bg-primary picks up the active theme colour — swap ui.Theme("flatly")ui.Theme("darkly") and the header updates automatically. Add a subtitle and badge to build a polished header entirely from Bootstrap.

from shiny import App, ui

# Theme-aware header — bg-primary adapts to any ui.Theme preset
header = ui.div(
    ui.div(
        ui.h1("Cars Explorer", class_="mb-0 fs-3"),
        ui.p("Fuel economy dataset · 1970–1982", class_="mb-0 opacity-75 small"),
    ),
    ui.tags.span("v1.0", class_="badge bg-light text-dark"),
    class_="bg-primary text-white p-4 d-flex justify-content-between align-items-center",
)

This is the pattern used in both the Block 1 synthesis and the full dashboard later.

Classes used — all from Bootstrap

Class Effect Docs
bg-primary theme background colour (updates with ui.Theme) Background
text-white white text Colors
p-4 1.5 rem padding on all sides Spacing
mb-0 / mb-3 margin-bottom 0 / 1 rem Spacing
fs-3 font-size 1.75 rem Font size
small 80% font size Typography
opacity-75 75% opacity Opacity
d-flex display: flex Flex
justify-content-between push children to opposite ends Flex
align-items-center centre children vertically Flex
badge inline pill/chip component Badges

d-flex justify-content-between lets you add a badge or button to the right side without extra CSS — e.g. ui.tags.span("v1.2", class_="badge bg-light text-dark").

Block 1 synthesis: styles & themes

What this app demonstrates

  • page_fillable — cards stretch to fill the viewport
  • Header: bg-primary, text-white, d-flex, justify-content-between + badge — no style=
  • Bootstrap utility classes for spacing, colour, and typography — no external CSS
  • style= used only for values Bootstrap can’t express: a brand hex and exact font metrics

Rule: if Bootstrap has a class for it, use the class. style= is for the ~10%: brand colours and exact pixel metrics.

#| '!! shinylive warning !!': |
#|   shinylive does not work in self-contained HTML documents.
#|   Please set `embed-resources: false` in your metadata.
#| standalone: true
#| components: [viewer]
#| viewerHeight: 500

from shiny import App, ui

# Block 1 synthesis — Bootstrap 5 + page_fillable + inline CSS + styled header
# Demonstrates all Block 1 UI patterns in one coherent app (no data dependencies).

# Header: Bootstrap classes only — bg-primary is theme-aware.
# d-flex + justify-content-between pushes badge to the right without custom CSS.
header = ui.div(
    ui.div(
        ui.h1("Dashboard Explorer", class_="mb-0 fs-3"),
        ui.p("Bootstrap 5 · Shiny UI patterns", class_="mb-0 opacity-75 small"),
    ),
    ui.tags.span("v1.0", class_="badge bg-light text-dark"),
    class_="bg-primary text-white p-4 mb-0 d-flex justify-content-between align-items-center",
)

app_ui = ui.page_fillable(
    header,
    ui.layout_columns(
        # --- Card 1: Bootstrap utility classes ---
        ui.card(
            ui.card_header("Bootstrap utility classes"),
            ui.div(
                ui.p("Spacing, colour, and flex — no custom CSS needed:", class_="text-muted small mb-3"),
                # Status badge in a flex row
                ui.div(
                    ui.span("Status", class_="fw-semibold"),
                    ui.tags.span("Active", class_="badge bg-success ms-2"),
                    class_="d-flex align-items-center mb-2",
                ),
                # Muted helper text
                ui.p("Secondary info", class_="text-muted small mb-2"),
                # Light bordered panel — border + rounded + bg-light, all Bootstrap
                ui.div("Bordered panel", class_="border rounded p-2 bg-light text-center text-muted small"),
            ),
        ),
        # --- Card 2: Inline CSS — brand colour only ---
        ui.card(
            ui.card_header("Inline CSS — targeted tweaks"),
            ui.div(
                ui.p("Use style= only for values Bootstrap can't express:", class_="text-muted small mb-3"),
                # Custom brand colour has no Bootstrap equivalent → style=
                ui.div(
                    "Brand accent panel",
                    class_="text-white p-3 rounded mb-2 fw-semibold",
                    style="background-color: #6f42c1;",
                ),
                # Fine-grained font metrics outside Bootstrap's scale
                ui.p(
                    "Fine print — 0.72 rem, tracked",
                    style="font-size: 0.72rem; letter-spacing: 0.05em; color: #6B7280;",
                ),
            ),
        ),
        # --- Card 3: page_fillable note ---
        ui.card(
            ui.card_header("page_fillable"),
            ui.div(
                ui.p("Cards fill the viewport vertically.", class_="text-muted small mb-3"),
                ui.div(
                    ui.p("Each card stretches to use available space.", class_="mb-1"),
                    ui.p("Set fill=False on an item to opt out.", class_="mb-0 text-muted small"),
                    class_="border-start border-primary border-3 ps-3",
                ),
            ),
        ),
        col_widths=[4, 4, 4],
    ),
)


def server(input, output, session):
    pass


app = App(app_ui, server)

Block 1 synthesis: styles & themes

#| '!! shinylive warning !!': |
#|   shinylive does not work in self-contained HTML documents.
#|   Please set `embed-resources: false` in your metadata.
#| standalone: true
#| components: [editor, viewer]
#| viewerHeight: 500

from shiny import App, ui

# Block 1 synthesis — Bootstrap 5 + page_fillable + inline CSS + styled header
# Demonstrates all Block 1 UI patterns in one coherent app (no data dependencies).

# Header: Bootstrap classes only — bg-primary is theme-aware.
# d-flex + justify-content-between pushes badge to the right without custom CSS.
header = ui.div(
    ui.div(
        ui.h1("Dashboard Explorer", class_="mb-0 fs-3"),
        ui.p("Bootstrap 5 · Shiny UI patterns", class_="mb-0 opacity-75 small"),
    ),
    ui.tags.span("v1.0", class_="badge bg-light text-dark"),
    class_="bg-primary text-white p-4 mb-0 d-flex justify-content-between align-items-center",
)

app_ui = ui.page_fillable(
    header,
    ui.layout_columns(
        # --- Card 1: Bootstrap utility classes ---
        ui.card(
            ui.card_header("Bootstrap utility classes"),
            ui.div(
                ui.p("Spacing, colour, and flex — no custom CSS needed:", class_="text-muted small mb-3"),
                # Status badge in a flex row
                ui.div(
                    ui.span("Status", class_="fw-semibold"),
                    ui.tags.span("Active", class_="badge bg-success ms-2"),
                    class_="d-flex align-items-center mb-2",
                ),
                # Muted helper text
                ui.p("Secondary info", class_="text-muted small mb-2"),
                # Light bordered panel — border + rounded + bg-light, all Bootstrap
                ui.div("Bordered panel", class_="border rounded p-2 bg-light text-center text-muted small"),
            ),
        ),
        # --- Card 2: Inline CSS — brand colour only ---
        ui.card(
            ui.card_header("Inline CSS — targeted tweaks"),
            ui.div(
                ui.p("Use style= only for values Bootstrap can't express:", class_="text-muted small mb-3"),
                # Custom brand colour has no Bootstrap equivalent → style=
                ui.div(
                    "Brand accent panel",
                    class_="text-white p-3 rounded mb-2 fw-semibold",
                    style="background-color: #6f42c1;",
                ),
                # Fine-grained font metrics outside Bootstrap's scale
                ui.p(
                    "Fine print — 0.72 rem, tracked",
                    style="font-size: 0.72rem; letter-spacing: 0.05em; color: #6B7280;",
                ),
            ),
        ),
        # --- Card 3: page_fillable note ---
        ui.card(
            ui.card_header("page_fillable"),
            ui.div(
                ui.p("Cards fill the viewport vertically.", class_="text-muted small mb-3"),
                ui.div(
                    ui.p("Each card stretches to use available space.", class_="mb-1"),
                    ui.p("Set fill=False on an item to opt out.", class_="mb-0 text-muted small"),
                    class_="border-start border-primary border-3 ps-3",
                ),
            ),
        ),
        col_widths=[4, 4, 4],
    ),
)


def server(input, output, session):
    pass


app = App(app_ui, server)

Block 2 — Layout & UX

ui.card() · ui.accordion() · Value boxes with reactive comparison

Cards as layout containers

ui.card() visually groups content. full_screen=True adds an expand button.

ui.layout_columns(col_widths=[...]) uses a 12-column grid — widths must sum to 12.

col_widths Result
[4, 4, 4] three equal columns
[3, 9] narrow sidebar + wide main
[6, 6, 12] two cols row 1, full row 2
#| '!! shinylive warning !!': |
#|   shinylive does not work in self-contained HTML documents.
#|   Please set `embed-resources: false` in your metadata.
#| standalone: true
#| components: [editor, viewer]
#| viewerHeight: 500

from shiny import App, ui

app_ui = ui.page_fillable(
    ui.layout_columns(
        ui.card(
            ui.card_header("Summary statistics"),
            ui.p("Table goes here."),
            full_screen=True,
        ),
        ui.card(
            ui.card_header("Trend chart"),
            ui.p("Chart goes here."),
            full_screen=True,
        ),
        ui.card(
            ui.card_header("Distribution"),
            ui.p("Another chart."),
            full_screen=True,
        ),
        col_widths=[4, 4, 4],
    ),
)

def server(input, output, session):
    pass

app = App(app_ui, server)

Design principle (from 01b): Design your app in a grid layoutMiller’s law suggests 5–9 panels per view.

Responsive: pass a dict for breakpoints — col_widths={"sm": [12, 12, 12], "lg": [4, 4, 4]} stacks on mobile, 3-col on desktop.

Docs: ui.card · ui.layout_columns

Accordion: collapsible sections

ui.accordion() groups controls into collapsible panels.

Useful when you have many controls and want to avoid an overwhelming sidebar:

#| '!! shinylive warning !!': |
#|   shinylive does not work in self-contained HTML documents.
#|   Please set `embed-resources: false` in your metadata.
#| standalone: true
#| components: [editor, viewer]
#| viewerHeight: 500

from shiny import App, ui

sidebar = ui.sidebar(
    ui.accordion(
        ui.accordion_panel(
            "Data filters",
            ui.input_select("origin", "Origin", ["All", "USA", "Europe", "Japan"]),
            ui.input_slider("mpg", "Min MPG", 0, 50, 10),
        ),
        ui.accordion_panel(
            "Chart options",
            ui.input_select("color", "Color by", ["Origin", "Cylinders"]),
            ui.input_switch("log", "Log scale", False),
        ),
        ui.accordion_panel(
            "About",
            ui.p("Cars dataset explorer. Select rows to highlight."),
        ),
        open="Data filters",   # only this panel open initially
    ),
    width=260,
)

app_ui = ui.page_fillable(
    ui.layout_sidebar(
        sidebar,
        ui.card(
            ui.card_header("Main content"),
            ui.p("Outputs go here."),
        ),
    ),
)

def server(input, output, session):
    pass

app = App(app_ui, server)

Design principle (from 01b): Use accordions to respect Miller’s law (7±2 chunks) — collapse advanced filters so users aren’t overwhelmed on first load.

Docs: ui.accordion · ui.accordion_panel

Set open=True to open all, open=False to collapse all, or pass a panel title string.

Value boxes

ui.value_box() highlights a single KPI.

theme is static, to make it react to data, return the whole value_box() from @render.ui:

ui.output_ui("mpg_box")   # UI: placeholder only

@render.ui                 # Server: returns full box
def mpg_box():
    cmp = compare(val, baseline, higher_is_better=True)
    return ui.value_box("Mean MPG", f"{val:.1f}",
                        theme=cmp["theme"])
Argument Role
title Label
value Big number
*args Badge, etc.
showcase Icon
theme Colour

We factored out compare() helper that returns one of five states: significantly/slightly above, stable (< 1% Δ), slightly/significantly below, and maps each to a colour.

higher_is_better=False flips semantics: lower HP is greener than higher HP in a fuel-economy context.

Design rule: show deltas for trends, not absolutes: “+5.6 MPG (+24%)” beats “29.1” in most cases.

#| '!! shinylive warning !!': |
#|   shinylive does not work in self-contained HTML documents.
#|   Please set `embed-resources: false` in your metadata.
#| standalone: true
#| components: [viewer]
#| viewerHeight: 500
#| packages: [vega_datasets, faicons]

import pandas as pd
from vega_datasets import data as vega_data
from shiny import App, ui, render, reactive
from faicons import icon_svg

cars = pd.DataFrame(vega_data.cars())
origins = sorted(cars["Origin"].unique())
BASELINE = {
    "mpg": cars["Miles_per_Gallon"].mean(),
    "hp": cars["Horsepower"].mean(),
    "accel": cars["Acceleration"].mean(),
}


def compare(current, baseline, higher_is_better=True):
    """
    Classify current vs baseline — five states:
      significantly above / slightly above / stable / slightly below / significantly below
    Thresholds:  change < 1%: stable, 1–5%: slight, > 5%: significant
    """
    # guard: can't compute a meaningful delta
    if baseline == 0 or pd.isna(current):
        return dict(icon="circle-minus", theme="secondary", badge="no data", label="no data")

    # percentage change relative to baseline; sign tells direction
    pct = (current - baseline) / abs(baseline) * 100

    # "good" depends on context: higher MPG is good, lower HP is good for efficiency
    is_good = (pct > 0) if higher_is_better else (pct < 0)
    abs_pct = abs(pct)

    # badge string shown below the value: e.g. "+5.6 (+24.3%) vs overall avg"
    sign = "+" if pct >= 0 else ""
    badge = f"{sign}{current - baseline:.1f} ({sign}{pct:.1f}%) vs overall avg"

    # under 1% change — treat as noise, no colour signal
    if abs_pct < 1:
        return dict(icon="arrow-right", theme="secondary", badge="≈ stable vs overall avg", label="stable")

    # direction: matching FA icon
    icon = "arrow-trend-up" if pct > 0 else "arrow-trend-down"

    # colour: good changes are green (success ≥5%, teal <5%),
    #         bad changes are red (danger ≥5%, warning <5%)
    theme = (
        "success" if (is_good and abs_pct >= 5) else
        "teal"    if is_good                    else
        "danger"  if abs_pct >= 5               else
        "warning"
    )

    quantifier = "significantly" if abs_pct >= 5 else "slightly"
    return dict(icon=icon, theme=theme, badge=badge,
                label=f"{quantifier} {'above' if pct > 0 else 'below'} avg")


def kpi_showcase(cmp):
    """FA icon sized for the value-box showcase panel — inherits theme colour."""
    # fill defaults to currentColor, so the icon matches the box's text colour
    # fill_opacity softens it slightly so it doesn't overpower the value
    return icon_svg(cmp["icon"], height="2.5em", fill_opacity="0.85")


def kpi_caption(cmp):
    """Delta badge + five-state label rendered below the value."""
    return ui.tags.div(
        # bold first line: absolute + relative delta, e.g. "+5.6 (+24.3%) vs overall avg"
        ui.HTML(f'<strong style="opacity:0.9">{cmp["badge"]}</strong>'),
        # dimmer second line: human-readable state, e.g. "significantly above avg"
        ui.div(cmp.get("label", ""), style="opacity:0.7;font-size:0.8rem;margin-top:2px"),
    )


app_ui = ui.page_fluid(
    ui.h5("Cars segment vs overall average"),
    ui.input_select("origin", "Origin", choices=["All"] + origins, selected="Japan"),
    ui.layout_column_wrap(
        ui.output_ui("mpg_box"),
        ui.output_ui("hp_box"),
        ui.output_ui("accel_box"),
        fill=False,
        width=1 / 3,
    ),
)


def server(input, output, session):
    @reactive.calc
    def seg():
        df = cars if input.origin() == "All" else cars[cars["Origin"] == input.origin()]
        return df.dropna(subset=["Miles_per_Gallon", "Horsepower", "Acceleration"])

    @render.ui  # higher MPG = better fuel economy ↑ green
    def mpg_box():
        val = seg()["Miles_per_Gallon"].mean()
        cmp = compare(val, BASELINE["mpg"], higher_is_better=True)
        return ui.value_box(
            "Mean MPG", f"{val:.1f}", kpi_caption(cmp),
            showcase=kpi_showcase(cmp),
            theme=cmp["theme"],
        )

    @render.ui  # lower HP = better efficiency ↓ green
    def hp_box():
        val = seg()["Horsepower"].mean()
        cmp = compare(val, BASELINE["hp"], higher_is_better=False)
        return ui.value_box(
            "Mean Horsepower", f"{val:.0f}", kpi_caption(cmp),
            showcase=kpi_showcase(cmp),
            theme=cmp["theme"],
        )

    @render.ui  # lower seconds = faster ↓ green; tends to be near-neutral across origins
    def accel_box():
        val = seg()["Acceleration"].mean()
        cmp = compare(val, BASELINE["accel"], higher_is_better=False)
        return ui.value_box(
            "Mean Acceleration (s)", f"{val:.1f}", kpi_caption(cmp),
            showcase=kpi_showcase(cmp),
            theme=cmp["theme"],
        )


app = App(app_ui, server)

Value boxes: comparison example

#| '!! shinylive warning !!': |
#|   shinylive does not work in self-contained HTML documents.
#|   Please set `embed-resources: false` in your metadata.
#| standalone: true
#| components: [editor, viewer]
#| viewerHeight: 500
#| packages: [vega_datasets, faicons]

import pandas as pd
from vega_datasets import data as vega_data
from shiny import App, ui, render, reactive
from faicons import icon_svg

cars = pd.DataFrame(vega_data.cars())
origins = sorted(cars["Origin"].unique())
BASELINE = {
    "mpg": cars["Miles_per_Gallon"].mean(),
    "hp": cars["Horsepower"].mean(),
    "accel": cars["Acceleration"].mean(),
}


def compare(current, baseline, higher_is_better=True):
    """
    Classify current vs baseline — five states:
      significantly above / slightly above / stable / slightly below / significantly below
    Thresholds:  change < 1%: stable, 1–5%: slight, > 5%: significant
    """
    # guard: can't compute a meaningful delta
    if baseline == 0 or pd.isna(current):
        return dict(icon="circle-minus", theme="secondary", badge="no data", label="no data")

    # percentage change relative to baseline; sign tells direction
    pct = (current - baseline) / abs(baseline) * 100

    # "good" depends on context: higher MPG is good, lower HP is good for efficiency
    is_good = (pct > 0) if higher_is_better else (pct < 0)
    abs_pct = abs(pct)

    # badge string shown below the value: e.g. "+5.6 (+24.3%) vs overall avg"
    sign = "+" if pct >= 0 else ""
    badge = f"{sign}{current - baseline:.1f} ({sign}{pct:.1f}%) vs overall avg"

    # under 1% change — treat as noise, no colour signal
    if abs_pct < 1:
        return dict(icon="arrow-right", theme="secondary", badge="≈ stable vs overall avg", label="stable")

    # direction: matching FA icon
    icon = "arrow-trend-up" if pct > 0 else "arrow-trend-down"

    # colour: good changes are green (success ≥5%, teal <5%),
    #         bad changes are red (danger ≥5%, warning <5%)
    theme = (
        "success" if (is_good and abs_pct >= 5) else
        "teal"    if is_good                    else
        "danger"  if abs_pct >= 5               else
        "warning"
    )

    quantifier = "significantly" if abs_pct >= 5 else "slightly"
    return dict(icon=icon, theme=theme, badge=badge,
                label=f"{quantifier} {'above' if pct > 0 else 'below'} avg")


def kpi_showcase(cmp):
    """FA icon sized for the value-box showcase panel — inherits theme colour."""
    # fill defaults to currentColor, so the icon matches the box's text colour
    # fill_opacity softens it slightly so it doesn't overpower the value
    return icon_svg(cmp["icon"], height="2.5em", fill_opacity="0.85")


def kpi_caption(cmp):
    """Delta badge + five-state label rendered below the value."""
    return ui.tags.div(
        # bold first line: absolute + relative delta, e.g. "+5.6 (+24.3%) vs overall avg"
        ui.HTML(f'<strong style="opacity:0.9">{cmp["badge"]}</strong>'),
        # dimmer second line: human-readable state, e.g. "significantly above avg"
        ui.div(cmp.get("label", ""), style="opacity:0.7;font-size:0.8rem;margin-top:2px"),
    )


app_ui = ui.page_fluid(
    ui.h5("Cars segment vs overall average"),
    ui.input_select("origin", "Origin", choices=["All"] + origins, selected="Japan"),
    ui.layout_column_wrap(
        ui.output_ui("mpg_box"),
        ui.output_ui("hp_box"),
        ui.output_ui("accel_box"),
        fill=False,
        width=1 / 3,
    ),
)


def server(input, output, session):
    @reactive.calc
    def seg():
        df = cars if input.origin() == "All" else cars[cars["Origin"] == input.origin()]
        return df.dropna(subset=["Miles_per_Gallon", "Horsepower", "Acceleration"])

    @render.ui  # higher MPG = better fuel economy ↑ green
    def mpg_box():
        val = seg()["Miles_per_Gallon"].mean()
        cmp = compare(val, BASELINE["mpg"], higher_is_better=True)
        return ui.value_box(
            "Mean MPG", f"{val:.1f}", kpi_caption(cmp),
            showcase=kpi_showcase(cmp),
            theme=cmp["theme"],
        )

    @render.ui  # lower HP = better efficiency ↓ green
    def hp_box():
        val = seg()["Horsepower"].mean()
        cmp = compare(val, BASELINE["hp"], higher_is_better=False)
        return ui.value_box(
            "Mean Horsepower", f"{val:.0f}", kpi_caption(cmp),
            showcase=kpi_showcase(cmp),
            theme=cmp["theme"],
        )

    @render.ui  # lower seconds = faster ↓ green; tends to be near-neutral across origins
    def accel_box():
        val = seg()["Acceleration"].mean()
        cmp = compare(val, BASELINE["accel"], higher_is_better=False)
        return ui.value_box(
            "Mean Acceleration (s)", f"{val:.1f}", kpi_caption(cmp),
            showcase=kpi_showcase(cmp),
            theme=cmp["theme"],
        )


app = App(app_ui, server)

Docs: ui.value_box · faicons: use icon_svg("name") for the showcase= argument; fill="currentColor" (default) inherits the box theme colour automatically.

Responsive layouts — pass a dict to col_widths to change layout per breakpoint: col_widths={"sm": [12, 12, 12], "lg": [4, 4, 4]} stacks on mobile, 3-col on desktop.

Block 3 — Table polish & synthesis

Conditional row styling · Full styled dashboard

Conditional row styling

render.DataGrid(styles=[...]) applies CSS to specific rows or cells. Prefer row-level highlights — they read as a coherent signal. DataGrid doesn’t use Bootstrap’s .table element, so this is one case where style= with a direct colour value is the right tool.

styles = [
    # Row-level (preferred): inline colour — DataGrid doesn't inherit Bootstrap table classes
    {"rows": [0, 2, 5], "style": {"background-color": "#D4E6F1"}},  # blue  — high MPG
    {"rows": [3],        "style": {"background-color": "#F5CBA7"}},  # orange — low MPG
    {"rows": [7],        "style": {"background-color": "#FCF3CF"}},  # yellow — borderline

    # Cell-level (use sparingly — for precise, targeted signals)
    # {"rows": [1], "cols": [1], "style": {"font-weight": "bold"}},
    # {"cols": [2], "style": {"font-style": "italic"}},  # whole column
]

Row and column indices are 0-based integers.

Design principle: Use colour purposefully — one signal (green = good, red = bad) per table. Avoid styling multiple unrelated columns; it creates visual noise.

Docs: render.DataGridstyles= parameter

Conditional row styling: live example

#| '!! shinylive warning !!': |
#|   shinylive does not work in self-contained HTML documents.
#|   Please set `embed-resources: false` in your metadata.
#| standalone: true
#| components: [editor, viewer]
#| viewerHeight: 500
#| packages: [vega_datasets]

import pandas as pd
from vega_datasets import data as vega_data
from shiny import App, ui, render

# Sample rows spanning the full MPG range so all highlight bands appear
_all = pd.DataFrame(vega_data.cars())[["Name", "Miles_per_Gallon", "Horsepower", "Origin"]]
cars = pd.concat([
    _all[_all["Miles_per_Gallon"] >= 30].head(3),   # high MPG
    _all[_all["Miles_per_Gallon"].between(16, 25)].head(4),  # mid MPG (no highlight)
    _all[_all["Miles_per_Gallon"].between(25, 30)].head(2),  # borderline
    _all[_all["Miles_per_Gallon"] <= 15].head(3),   # low MPG
]).reset_index(drop=True)

# Colorblind-friendly palette (pastel Okabe-Ito)
COLOR_HIGH = "#D4E6F1"    # blue  — good (high MPG)
COLOR_LOW = "#F5CBA7"     # orange — bad (low MPG)
COLOR_BORDER = "#FCF3CF"  # yellow — borderline


def make_styles(df):
    styles = []
    for i, row in df.iterrows():
        mpg = row["Miles_per_Gallon"]
        if pd.isna(mpg):
            continue
        if mpg >= 30:
            styles.append({"rows": [i], "style": {"background-color": COLOR_HIGH}})
        elif mpg <= 15:
            styles.append({"rows": [i], "style": {"background-color": COLOR_LOW}})
        elif mpg >= 25:
            styles.append({"rows": [i], "style": {"background-color": COLOR_BORDER}})
    return styles


app_ui = ui.page_fluid(
    ui.h4("Row highlight: blue ≥ 30 MPG, yellow 25–30, orange ≤ 15 MPG"),
    ui.output_data_frame("tbl"),
)


def server(input, output, session):
    @render.data_frame
    def tbl():
        return render.DataGrid(cars, styles=make_styles(cars), width="100%")


app = App(app_ui, server)

Putting it together

Styled header + dynamic value boxes + sidebar + cards + linked table/chart:

flowchart LR
  U((👤)) -.select origin.-> A
  A[/input_origin/] --> F{{filtered}}
  F --> NB([n_rows_box])
  F --> MB([mpg_box])
  F --> HB([hp_box])
  F --> T([tbl])
  U2((👤)) -.select rows.-> T
  T -.row select.-> S([scatter])
  F --> S

  style U fill:#E8F5E9,stroke:#4CAF50,stroke-width:2px
  style U2 fill:#E8F5E9,stroke:#4CAF50,stroke-width:2px
  style A fill:#FFF3E0,stroke:#FF9800
  style F fill:#F3E5F5,stroke:#9C27B0
  style NB fill:#E3F2FD,stroke:#2196F3
  style MB fill:#E3F2FD,stroke:#2196F3
  style HB fill:#E3F2FD,stroke:#2196F3
  style T fill:#E3F2FD,stroke:#2196F3
  style S fill:#E3F2FD,stroke:#2196F3

@reactive.calc filtered() is the shared hub — all five outputs depend on it.

Value boxes use @render.ui so the entire box (colour, icon, badge) reacts to data via the compare() helper — the same pattern from the Value boxes slide.

Row selection tbl.data_view(selected=True) feeds a second Altair layer on scatter.

#| '!! shinylive warning !!': |
#|   shinylive does not work in self-contained HTML documents.
#|   Please set `embed-resources: false` in your metadata.
#| standalone: true
#| components: [viewer]
#| viewerHeight: 500
#| packages: [vega_datasets, altair, shinywidgets, faicons]

import pandas as pd
import altair as alt
from vega_datasets import data as vega_data
from shiny import App, ui, render, reactive
from shinywidgets import output_widget, render_altair
from faicons import icon_svg

cars = pd.DataFrame(vega_data.cars())
origins = sorted(cars["Origin"].unique())
display_cols = ["Name", "Miles_per_Gallon", "Cylinders", "Horsepower", "Origin"]
BASELINE = {
    "mpg": cars["Miles_per_Gallon"].mean(),
    "hp": cars["Horsepower"].mean(),
}


def compare(current, baseline, higher_is_better=True):
    if baseline == 0 or pd.isna(current):
        return dict(icon="circle-minus", theme="secondary", badge="no data")
    pct = (current - baseline) / abs(baseline) * 100
    is_good = (pct > 0) if higher_is_better else (pct < 0)
    abs_pct = abs(pct)
    sign = "+" if pct >= 0 else ""
    badge = f"{sign}{pct:.1f}% vs avg"
    if abs_pct < 1:
        return dict(icon="arrow-right", theme="secondary", badge="≈ avg")
    icon = "arrow-trend-up" if pct > 0 else "arrow-trend-down"
    theme = (
        "success" if (is_good and abs_pct >= 5) else
        "teal"    if is_good                    else
        "danger"  if abs_pct >= 5               else
        "warning"
    )
    return dict(icon=icon, theme=theme, badge=badge)


# Header: bg-primary is theme-aware; d-flex pushes badge to the right
header = ui.div(
    ui.div(
        ui.h2("Cars Explorer", class_="mb-0 fs-4"),
        ui.p("Fuel economy dataset · 1970–1982", class_="mb-0 opacity-75 small"),
    ),
    ui.tags.span("v1.0", class_="badge bg-light text-dark"),
    class_="bg-primary text-white p-3 d-flex justify-content-between align-items-center",
)

sidebar = ui.sidebar(
    ui.h6("Filters"),
    ui.input_select("origin", "Origin", choices=["All"] + origins),
    ui.hr(),
    ui.p("Select rows in the table to highlight in the chart.",
         style="font-size:0.8rem; color:#6B7280;"),
    width=200,
)

app_ui = ui.page_fluid(
    header,
    ui.layout_sidebar(
        sidebar,
        ui.layout_column_wrap(
            ui.output_ui("n_rows_box"),
            ui.output_ui("mpg_box"),
            ui.output_ui("hp_box"),
            fill=False,
            width=1 / 3,
        ),
        ui.layout_columns(
            ui.card(
                ui.card_header("Data"),
                ui.output_data_frame("tbl"),
                full_screen=True,
            ),
            ui.card(
                ui.card_header("MPG vs Horsepower"),
                output_widget("scatter"),
                full_screen=True,
            ),
            col_widths=[5, 7],
        ),
    ),
)


def server(input, output, session):
    @reactive.calc
    def filtered():
        df = cars.copy()
        if input.origin() != "All":
            df = df[df["Origin"] == input.origin()]
        return df[display_cols].reset_index(drop=True)

    @render.ui
    def n_rows_box():
        n = len(filtered())
        total = len(cars)
        return ui.value_box(
            "Total rows", str(n), f"of {total}",
            showcase=icon_svg("table", height="2em"),
            theme="primary",
        )

    @render.ui
    def mpg_box():
        val = filtered()["Miles_per_Gallon"].mean()
        cmp = compare(val, BASELINE["mpg"], higher_is_better=True)
        return ui.value_box(
            "Mean MPG", f"{val:.1f}", cmp["badge"],
            showcase=icon_svg(cmp["icon"], height="2em"),
            theme=cmp["theme"],
        )

    @render.ui
    def hp_box():
        val = filtered()["Horsepower"].mean()
        cmp = compare(val, BASELINE["hp"], higher_is_better=False)
        return ui.value_box(
            "Mean HP", f"{val:.0f}", cmp["badge"],
            showcase=icon_svg(cmp["icon"], height="2em"),
            theme=cmp["theme"],
        )

    @render.data_frame
    def tbl():
        return render.DataGrid(filtered(), selection_mode="rows", height="350px")

    @render_altair
    def scatter():
        df = filtered()
        selected = tbl.data_view(selected=True)
        base = alt.Chart(df).mark_circle(color="#D1D5DB", size=60).encode(
            x="Miles_per_Gallon:Q", y="Horsepower:Q",
            tooltip=display_cols,
        )
        if selected is None or selected.empty:
            return base.properties(width="container", height=320)
        hi = alt.Chart(selected).mark_circle(color="#3B82F6", size=90).encode(
            x="Miles_per_Gallon:Q", y="Horsepower:Q",
        )
        return (base + hi).properties(width="container", height=320)


app = App(app_ui, server)

Putting it together: full styled dashboard (editor)

#| '!! shinylive warning !!': |
#|   shinylive does not work in self-contained HTML documents.
#|   Please set `embed-resources: false` in your metadata.
#| standalone: true
#| components: [editor, viewer]
#| viewerHeight: 500
#| packages: [vega_datasets, altair, shinywidgets, faicons]

import pandas as pd
import altair as alt
from vega_datasets import data as vega_data
from shiny import App, ui, render, reactive
from shinywidgets import output_widget, render_altair
from faicons import icon_svg

cars = pd.DataFrame(vega_data.cars())
origins = sorted(cars["Origin"].unique())
display_cols = ["Name", "Miles_per_Gallon", "Cylinders", "Horsepower", "Origin"]
BASELINE = {
    "mpg": cars["Miles_per_Gallon"].mean(),
    "hp": cars["Horsepower"].mean(),
}


def compare(current, baseline, higher_is_better=True):
    if baseline == 0 or pd.isna(current):
        return dict(icon="circle-minus", theme="secondary", badge="no data")
    pct = (current - baseline) / abs(baseline) * 100
    is_good = (pct > 0) if higher_is_better else (pct < 0)
    abs_pct = abs(pct)
    sign = "+" if pct >= 0 else ""
    badge = f"{sign}{pct:.1f}% vs avg"
    if abs_pct < 1:
        return dict(icon="arrow-right", theme="secondary", badge="≈ avg")
    icon = "arrow-trend-up" if pct > 0 else "arrow-trend-down"
    theme = (
        "success" if (is_good and abs_pct >= 5) else
        "teal"    if is_good                    else
        "danger"  if abs_pct >= 5               else
        "warning"
    )
    return dict(icon=icon, theme=theme, badge=badge)


# Header: bg-primary is theme-aware; d-flex pushes badge to the right
header = ui.div(
    ui.div(
        ui.h2("Cars Explorer", class_="mb-0 fs-4"),
        ui.p("Fuel economy dataset · 1970–1982", class_="mb-0 opacity-75 small"),
    ),
    ui.tags.span("v1.0", class_="badge bg-light text-dark"),
    class_="bg-primary text-white p-3 d-flex justify-content-between align-items-center",
)

sidebar = ui.sidebar(
    ui.h6("Filters"),
    ui.input_select("origin", "Origin", choices=["All"] + origins),
    ui.hr(),
    ui.p("Select rows in the table to highlight in the chart.",
         style="font-size:0.8rem; color:#6B7280;"),
    width=200,
)

app_ui = ui.page_fluid(
    header,
    ui.layout_sidebar(
        sidebar,
        ui.layout_column_wrap(
            ui.output_ui("n_rows_box"),
            ui.output_ui("mpg_box"),
            ui.output_ui("hp_box"),
            fill=False,
            width=1 / 3,
        ),
        ui.layout_columns(
            ui.card(
                ui.card_header("Data"),
                ui.output_data_frame("tbl"),
                full_screen=True,
            ),
            ui.card(
                ui.card_header("MPG vs Horsepower"),
                output_widget("scatter"),
                full_screen=True,
            ),
            col_widths=[5, 7],
        ),
    ),
)


def server(input, output, session):
    @reactive.calc
    def filtered():
        df = cars.copy()
        if input.origin() != "All":
            df = df[df["Origin"] == input.origin()]
        return df[display_cols].reset_index(drop=True)

    @render.ui
    def n_rows_box():
        n = len(filtered())
        total = len(cars)
        return ui.value_box(
            "Total rows", str(n), f"of {total}",
            showcase=icon_svg("table", height="2em"),
            theme="primary",
        )

    @render.ui
    def mpg_box():
        val = filtered()["Miles_per_Gallon"].mean()
        cmp = compare(val, BASELINE["mpg"], higher_is_better=True)
        return ui.value_box(
            "Mean MPG", f"{val:.1f}", cmp["badge"],
            showcase=icon_svg(cmp["icon"], height="2em"),
            theme=cmp["theme"],
        )

    @render.ui
    def hp_box():
        val = filtered()["Horsepower"].mean()
        cmp = compare(val, BASELINE["hp"], higher_is_better=False)
        return ui.value_box(
            "Mean HP", f"{val:.0f}", cmp["badge"],
            showcase=icon_svg(cmp["icon"], height="2em"),
            theme=cmp["theme"],
        )

    @render.data_frame
    def tbl():
        return render.DataGrid(filtered(), selection_mode="rows", height="350px")

    @render_altair
    def scatter():
        df = filtered()
        selected = tbl.data_view(selected=True)
        base = alt.Chart(df).mark_circle(color="#D1D5DB", size=60).encode(
            x="Miles_per_Gallon:Q", y="Horsepower:Q",
            tooltip=display_cols,
        )
        if selected is None or selected.empty:
            return base.properties(width="container", height=320)
        hi = alt.Chart(selected).mark_circle(color="#3B82F6", size=90).encode(
            x="Miles_per_Gallon:Q", y="Horsepower:Q",
        )
        return (base + hi).properties(width="container", height=320)


app = App(app_ui, server)

Design principle (from 01b): This example combines concise KPIs, logical layout, and context — use it as a template when assembling your own dashboard.

Tips for clean layouts

Use cards + value boxes

  • Wrap every output in ui.card() for visual separation
  • ui.card_header() labels each panel
  • full_screen=True lets users expand charts
  • Value boxes highlight KPIs at the top

Sidebar + accordion for controls

  • Put global filters in the sidebar
  • Use ui.accordion() to group & collapse related controls
  • ui.hr() or space between unrelated widgets

Consistent spacing

  • Use padding and margin on ui.div() wrappers
  • layout_columns(col_widths=) handles the grid
  • One accent colour + neutrals is usually enough
  • Reserve styles= on DataGrid for meaningful signals, not decoration

Design principle (from 01b): Match user expectations and use color purposefully — let layout do the heavy lifting, then use color sparingly for emphasis.

Block 4 — Advanced reactivity

Linked filters · Column picker · CSV export · Selection reset · Editable tables

Reactive input components: linked filters

flowchart LR
  U((👤)) -.select origin.-> A
  A[/input_origin/] --> F{{filtered_by_origin}}
  F -->|"@render.ui"| S[/input_mpg/]
  F --> T([tbl])
  S --> T

  style U fill:#E8F5E9,stroke:#4CAF50,stroke-width:2px
  style A fill:#FFF3E0,stroke:#FF9800
  style F fill:#F3E5F5,stroke:#9C27B0
  style S fill:#FFF3E0,stroke:#FF9800
  style T fill:#E3F2FD,stroke:#2196F3

@render.ui returns an entirely new input widget each time origin changes — range min and max reflect only the currently visible data.

How it works

  • filtered_by_origin() is a @reactive.calc — recomputes when origin changes
  • @render.ui returns a brand-new input_slider each time — range, label, and default value all update
  • req(input.mpg) in tbl prevents the table from rendering before the slider exists
  • The slider id stays the same ("mpg"), so downstream code reads it with input.mpg() as usual
  • Use @render.ui when the valid range, choices, or widget type must change — not just the value
#| '!! shinylive warning !!': |
#|   shinylive does not work in self-contained HTML documents.
#|   Please set `embed-resources: false` in your metadata.
#| standalone: true
#| components: [viewer]
#| viewerHeight: 500
#| packages: [vega_datasets]

import pandas as pd
from vega_datasets import data as vega_data
from shiny import App, ui, render, reactive, req

cars = pd.DataFrame(vega_data.cars())
origins = sorted(cars["Origin"].unique())
display_cols = ["Name", "Miles_per_Gallon", "Cylinders", "Horsepower", "Origin"]

app_ui = ui.page_fluid(
    ui.h4("Linked filters: origin → MPG slider range"),
    ui.layout_columns(
        ui.input_select("origin", "Origin", choices=["All"] + origins),
        ui.output_ui("mpg_slider"),   # placeholder — slider is built reactively
        col_widths=[6, 6],
    ),
    ui.output_data_frame("tbl"),
)


def server(input, output, session):
    @reactive.calc
    def filtered_by_origin():
        df = cars.copy()
        if input.origin() != "All":
            df = df[df["Origin"] == input.origin()]
        return df

    @render.ui
    def mpg_slider():
        # Slider range is computed from the currently visible data — not the whole dataset.
        # When origin changes, this slider re-renders with an updated range.
        mpg_vals = filtered_by_origin()["Miles_per_Gallon"].dropna()
        if mpg_vals.empty:
            return ui.p("No MPG data.", class_="text-muted small")
        lo, hi = int(mpg_vals.min()), int(mpg_vals.max())
        return ui.input_slider("mpg", f"Min MPG ({lo}–{hi})", min=lo, max=hi, value=lo)

    @render.data_frame
    def tbl():
        req(input.mpg)   # wait for the dynamic slider to exist before reading it
        df = filtered_by_origin().dropna(subset=["Miles_per_Gallon"])
        df = df[df["Miles_per_Gallon"] >= input.mpg()]
        return render.DataGrid(df[display_cols].reset_index(drop=True), height="350px")


app = App(app_ui, server)

Reactive input components: linked filters

#| '!! shinylive warning !!': |
#|   shinylive does not work in self-contained HTML documents.
#|   Please set `embed-resources: false` in your metadata.
#| standalone: true
#| components: [editor, viewer]
#| viewerHeight: 500
#| packages: [vega_datasets]

import pandas as pd
from vega_datasets import data as vega_data
from shiny import App, ui, render, reactive, req

cars = pd.DataFrame(vega_data.cars())
origins = sorted(cars["Origin"].unique())
display_cols = ["Name", "Miles_per_Gallon", "Cylinders", "Horsepower", "Origin"]

app_ui = ui.page_fluid(
    ui.h4("Linked filters: origin → MPG slider range"),
    ui.layout_columns(
        ui.input_select("origin", "Origin", choices=["All"] + origins),
        ui.output_ui("mpg_slider"),   # placeholder — slider is built reactively
        col_widths=[6, 6],
    ),
    ui.output_data_frame("tbl"),
)


def server(input, output, session):
    @reactive.calc
    def filtered_by_origin():
        df = cars.copy()
        if input.origin() != "All":
            df = df[df["Origin"] == input.origin()]
        return df

    @render.ui
    def mpg_slider():
        # Slider range is computed from the currently visible data — not the whole dataset.
        # When origin changes, this slider re-renders with an updated range.
        mpg_vals = filtered_by_origin()["Miles_per_Gallon"].dropna()
        if mpg_vals.empty:
            return ui.p("No MPG data.", class_="text-muted small")
        lo, hi = int(mpg_vals.min()), int(mpg_vals.max())
        return ui.input_slider("mpg", f"Min MPG ({lo}–{hi})", min=lo, max=hi, value=lo)

    @render.data_frame
    def tbl():
        req(input.mpg)   # wait for the dynamic slider to exist before reading it
        df = filtered_by_origin().dropna(subset=["Miles_per_Gallon"])
        df = df[df["Miles_per_Gallon"] >= input.mpg()]
        return render.DataGrid(df[display_cols].reset_index(drop=True), height="350px")


app = App(app_ui, server)

Alternative: @reactive.effect + ui.update_slider()

Instead of replacing the widget, update it in place with @reactive.effect + ui.update_*():

flowchart LR
  U((👤)) -.select origin.-> A
  A[/input_origin/] --> E["@reactive.effect"]
  E ==>|"ui.update_slider()"| S[/input_slider/]
  S --> R([render output])

  style U fill:#E8F5E9,stroke:#4CAF50,stroke-width:2px
  style A fill:#FFF3E0,stroke:#FF9800
  style E fill:#FCE4EC,stroke:#E91E63
  style S fill:#FFF3E0,stroke:#FF9800
  style R fill:#E3F2FD,stroke:#2196F3

reactive dep side-effect push user action

How @reactive.effect works

  • Runs automatically whenever any reactive value it reads changes — here, when origin changes, @reactive.effect re-executes
  • Unlike @render.*, it does not produce an output — it is a “fire-and-forget” function that performs side effects only (e.g., calling ui.update_slider() to send new min/max/value to an existing widget)
  • ui.update_slider() sends a message to the existing widget — no rebuild, no flicker
  • Use @render.ui instead when the widget type or set of choices must change, not just the value
#| '!! shinylive warning !!': |
#|   shinylive does not work in self-contained HTML documents.
#|   Please set `embed-resources: false` in your metadata.
#| standalone: true
#| components: [viewer]
#| viewerHeight: 500
#| packages: [vega_datasets]

import pandas as pd
from vega_datasets import data as vega_data
from shiny import App, ui, render, reactive, req

cars = pd.DataFrame(vega_data.cars())
origins = sorted(cars["Origin"].unique())
display_cols = ["Name", "Miles_per_Gallon", "Cylinders", "Horsepower", "Origin"]

# The slider is declared statically in the UI — not inside @render.ui.
app_ui = ui.page_fluid(
    ui.h4("Linked filters: update_slider approach"),
    ui.layout_columns(
        ui.input_select("origin", "Origin", choices=["All"] + origins),
        ui.input_slider("mpg", "Min MPG", min=0, max=50, value=0),
        col_widths=[6, 6],
    ),
    ui.output_data_frame("tbl"),
)


def server(input, output, session):
    @reactive.calc
    def filtered_by_origin():
        df = cars.copy()
        if input.origin() != "All":
            df = df[df["Origin"] == input.origin()]
        return df

    @reactive.effect
    def _update_mpg():
        mpg_vals = filtered_by_origin()["Miles_per_Gallon"].dropna()
        if mpg_vals.empty:
            return
        lo, hi = int(mpg_vals.min()), int(mpg_vals.max())
        ui.update_slider("mpg", label=f"Min MPG ({lo}–{hi})",
                         min=lo, max=hi, value=lo)

    @render.data_frame
    def tbl():
        df = filtered_by_origin().dropna(subset=["Miles_per_Gallon"])
        df = df[df["Miles_per_Gallon"] >= input.mpg()]
        return render.DataGrid(df[display_cols].reset_index(drop=True), height="350px")


app = App(app_ui, server)

Alternative: @reactive.effect + ui.update_slider()

#| '!! shinylive warning !!': |
#|   shinylive does not work in self-contained HTML documents.
#|   Please set `embed-resources: false` in your metadata.
#| standalone: true
#| components: [editor, viewer]
#| viewerHeight: 500
#| packages: [vega_datasets]

import pandas as pd
from vega_datasets import data as vega_data
from shiny import App, ui, render, reactive, req

cars = pd.DataFrame(vega_data.cars())
origins = sorted(cars["Origin"].unique())
display_cols = ["Name", "Miles_per_Gallon", "Cylinders", "Horsepower", "Origin"]

# The slider is declared statically in the UI — not inside @render.ui.
app_ui = ui.page_fluid(
    ui.h4("Linked filters: update_slider approach"),
    ui.layout_columns(
        ui.input_select("origin", "Origin", choices=["All"] + origins),
        ui.input_slider("mpg", "Min MPG", min=0, max=50, value=0),
        col_widths=[6, 6],
    ),
    ui.output_data_frame("tbl"),
)


def server(input, output, session):
    @reactive.calc
    def filtered_by_origin():
        df = cars.copy()
        if input.origin() != "All":
            df = df[df["Origin"] == input.origin()]
        return df

    @reactive.effect
    def _update_mpg():
        mpg_vals = filtered_by_origin()["Miles_per_Gallon"].dropna()
        if mpg_vals.empty:
            return
        lo, hi = int(mpg_vals.min()), int(mpg_vals.max())
        ui.update_slider("mpg", label=f"Min MPG ({lo}–{hi})",
                         min=lo, max=hi, value=lo)

    @render.data_frame
    def tbl():
        df = filtered_by_origin().dropna(subset=["Miles_per_Gallon"])
        df = df[df["Miles_per_Gallon"] >= input.mpg()]
        return render.DataGrid(df[display_cols].reset_index(drop=True), height="350px")


app = App(app_ui, server)

When do you need req()?

req() = “stop here until this value exists”

@render.data_frame
def tbl():
    req(input.mpg())        # ← wait until slider exists
    return filtered_data()

req(input.mpg()) silently halts the render if input.mpg() is None, "", or False — no error, just a blank output until the value arrives.

When you need it

  • When @render.ui creates a widget dynamically, the widget doesn’t exist during the first render cycle. Any other render that reads that widget’s value (e.g., input.mpg()) will get None and crash — req(input.mpg()) tells Shiny “skip this render silently until the value appears”
  • When any input starts empty (e.g., input_text with no default)

When you don’t

  • Static widgets with defaults (input_slider, input_select with selected=) — value is always available
  • After @reactive.effect + ui.update_*() — the widget is static, so it always exists

Docs: req()

Selecting columns to show

Shiny doesn’t support column click-selection — use ui.input_checkbox_group() for explicit column picking.

The column choices here are fixed (they don’t depend on the data), so a plain static widget is enough. @render.ui is only needed when the set of choices must update dynamically.

from shiny import App, ui, render
import pandas as pd
from vega_datasets import data as vega_data

cars = pd.DataFrame(vega_data.cars())
all_cols = ["Name", "Miles_per_Gallon", "Cylinders", "Horsepower", "Origin"]

app_ui = ui.page_fluid(
    ui.input_checkbox_group("cols", "Columns",
                            choices=all_cols, selected=all_cols[:3], inline=True),
    ui.output_data_frame("tbl"),
)

def server(input, output, session):
    @render.data_frame
    def tbl():
        cols = list(input.cols()) or all_cols  # guard: never pass empty list
        return render.DataGrid(cars[cols], width="100%", height="350px")

The or all_cols guard is important — if the user unchecks every box, input.cols() returns () and cars[[]] raises an error.

Docs: ui.input_checkbox_group

Reminder: data_view()

Once rendered, the @render.data_frame result exposes methods to read the table’s current state:

Method Returns
tbl.data_view() All rows after sort + filter
tbl.data_view(selected=True) Only selected rows
tbl.data() Original underlying data
@reactive.calc
def selected_rows():
    df = tbl.data_view(selected=True)
    req(not df.empty)
    return df

data_view(selected=True) returns selected rows with any sorting/filtering the user has applied — it is the data as the user sees it.

Export filtered data to CSV

tbl.data() always returns the full underlying dataset — ignores any active filters, sorts, or selections. tbl.data_view() returns what the user currently sees — respects the column filters and sort order. (Covered in 06a.)

flowchart LR
  U((👤)) -.select origin.-> A
  A[/input_origin/] --> T([tbl])
  U2((👤)) -.click.-> D1
  U3((👤)) -.click.-> D2
  T -->|".data()"| D1([download_all])
  T -->|".data_view()"| D2([download_view])

  style U fill:#E8F5E9,stroke:#4CAF50,stroke-width:2px
  style U2 fill:#E8F5E9,stroke:#4CAF50,stroke-width:2px
  style U3 fill:#E8F5E9,stroke:#4CAF50,stroke-width:2px
  style A fill:#FFF3E0,stroke:#FF9800
  style T fill:#E3F2FD,stroke:#2196F3
  style D1 fill:#E3F2FD,stroke:#2196F3
  style D2 fill:#E3F2FD,stroke:#2196F3

@render.download(filename="cars_all.csv")
def download_all():
    yield tbl.data().to_csv(index=False)

@render.download(filename="cars_filtered.csv")
def download_view():
    # also: tbl.data_view(selected=True)
    #       to export only selected rows
    yield tbl.data_view().to_csv(index=False)
#| '!! shinylive warning !!': |
#|   shinylive does not work in self-contained HTML documents.
#|   Please set `embed-resources: false` in your metadata.
#| standalone: true
#| components: [viewer]
#| viewerHeight: 500
#| packages: [vega_datasets]

import pandas as pd
from vega_datasets import data as vega_data
from shiny import App, ui, render

# Demonstrates the difference between tbl.data() and tbl.data_view():
#   tbl.data()         — always the full underlying DataFrame
#   tbl.data_view()    — what the user currently sees (honours column filters + sort)

cars = pd.DataFrame(vega_data.cars())
origins = sorted(cars["Origin"].unique())
display_cols = ["Name", "Miles_per_Gallon", "Horsepower", "Origin"]

app_ui = ui.page_fluid(
    ui.h4("Download: original data vs filtered view"),
    ui.input_select("origin", "Origin", choices=["All"] + origins),
    ui.layout_columns(
        ui.download_button("download_all",  "⬇ All data  (tbl.data())",          class_="btn-secondary"),
        ui.download_button("download_view", "⬇ Filtered view  (tbl.data_view())", class_="btn-primary"),
        col_widths=[6, 6],
    ),
    ui.output_text("counts"),
    ui.br(),
    ui.output_data_frame("tbl"),
)


def server(input, output, session):
    @render.data_frame
    def tbl():
        df = cars if input.origin() == "All" else cars[cars["Origin"] == input.origin()]
        return render.DataGrid(df[display_cols].reset_index(drop=True), height="300px")

    @render.text
    def counts():
        return (
            f"tbl.data(): {len(tbl.data())} rows (always full dataset)   |   "
            f"tbl.data_view(): {len(tbl.data_view())} rows (current filter)"
        )

    @render.download(filename="cars_all.csv")
    def download_all():
        # tbl.data() ignores any active filters or sort — returns full original data
        yield tbl.data().to_csv(index=False)

    @render.download(filename="cars_filtered.csv")
    def download_view():
        # tbl.data_view() respects column filters and sort order
        # Add selected=True to export only selected rows
        yield tbl.data_view().to_csv(index=False)


app = App(app_ui, server)

Export filtered data to CSV

#| '!! shinylive warning !!': |
#|   shinylive does not work in self-contained HTML documents.
#|   Please set `embed-resources: false` in your metadata.
#| standalone: true
#| components: [editor, viewer]
#| viewerHeight: 500
#| packages: [vega_datasets]

import pandas as pd
from vega_datasets import data as vega_data
from shiny import App, ui, render

# Demonstrates the difference between tbl.data() and tbl.data_view():
#   tbl.data()         — always the full underlying DataFrame
#   tbl.data_view()    — what the user currently sees (honours column filters + sort)

cars = pd.DataFrame(vega_data.cars())
origins = sorted(cars["Origin"].unique())
display_cols = ["Name", "Miles_per_Gallon", "Horsepower", "Origin"]

app_ui = ui.page_fluid(
    ui.h4("Download: original data vs filtered view"),
    ui.input_select("origin", "Origin", choices=["All"] + origins),
    ui.layout_columns(
        ui.download_button("download_all",  "⬇ All data  (tbl.data())",          class_="btn-secondary"),
        ui.download_button("download_view", "⬇ Filtered view  (tbl.data_view())", class_="btn-primary"),
        col_widths=[6, 6],
    ),
    ui.output_text("counts"),
    ui.br(),
    ui.output_data_frame("tbl"),
)


def server(input, output, session):
    @render.data_frame
    def tbl():
        df = cars if input.origin() == "All" else cars[cars["Origin"] == input.origin()]
        return render.DataGrid(df[display_cols].reset_index(drop=True), height="300px")

    @render.text
    def counts():
        return (
            f"tbl.data(): {len(tbl.data())} rows (always full dataset)   |   "
            f"tbl.data_view(): {len(tbl.data_view())} rows (current filter)"
        )

    @render.download(filename="cars_all.csv")
    def download_all():
        # tbl.data() ignores any active filters or sort — returns full original data
        yield tbl.data().to_csv(index=False)

    @render.download(filename="cars_filtered.csv")
    def download_view():
        # tbl.data_view() respects column filters and sort order
        # Add selected=True to export only selected rows
        yield tbl.data_view().to_csv(index=False)


app = App(app_ui, server)

@render.download() must use yield, not return — it’s a generator. (@session.download_handler is deprecated since Shiny 1.1.)

Pattern: Use tbl.data_view(selected=True) to export only the rows the user has clicked/selected.

Docs: render.download · ui.download_button · Shiny downloading guide · DataGrid.data_view()

Reset table selections

When the filter changes, DataGrid automatically clears its selection whenever the underlying data reference changes — because filtered() returns a new DataFrame object.

To also let users manually clear with a button, take an explicit dependency on an action_button:

@reactive.calc
def filtered():
    _ = input.reset_btn()  # explicit dependency — click invalidates filtered()
    df = cars.copy()
    if input.origin() != "All":
        df = df[df["Origin"] == input.origin()]
    return df.reset_index(drop=True)

Changing origin or clicking Reset invalidates filtered(), re-renders the DataGrid, and the selection is gone.

#| '!! shinylive warning !!': |
#|   shinylive does not work in self-contained HTML documents.
#|   Please set `embed-resources: false` in your metadata.
#| standalone: true
#| viewerHeight: 500
#| packages: [vega_datasets]

import pandas as pd
from vega_datasets import data as vega_data
from shiny import App, ui, render, reactive

# Demonstrates programmatic selection reset:
# Changing the origin filter (or clicking Reset) forces filtered() to recompute,
# which re-renders the DataGrid — DataGrid automatically clears its selection
# whenever the underlying data reference changes.

cars = pd.DataFrame(vega_data.cars())
origins = sorted(cars["Origin"].unique())

app_ui = ui.page_fluid(
    ui.h4("Filter → selection resets automatically"),
    ui.layout_columns(
        ui.input_select("origin", "Origin", choices=["All"] + origins),
        ui.input_action_button("reset_btn", "Reset selection", class_="btn-secondary"),
        col_widths=[6, 6],
    ),
    ui.output_text("selection_info"),
    ui.br(),
    ui.output_data_frame("tbl"),
)


def server(input, output, session):
    @reactive.calc
    def filtered():
        # Take an explicit dependency on reset_btn so clicking it invalidates
        # filtered(), which in turn re-renders the DataGrid and clears the selection.
        _ = input.reset_btn()
        df = cars.copy()
        if input.origin() != "All":
            df = df[df["Origin"] == input.origin()]
        return df[["Name", "Miles_per_Gallon", "Cylinders", "Origin"]].reset_index(drop=True)

    @render.data_frame
    def tbl():
        return render.DataGrid(filtered(), height="350px", selection_mode="rows")

    @render.text
    def selection_info():
        selected = tbl.data_view(selected=True)
        n = 0 if (selected is None or selected.empty) else len(selected)
        return f"Selected rows: {n}  (changes filter or click Reset to clear)"


app = App(app_ui, server)

Reset table selections

#| '!! shinylive warning !!': |
#|   shinylive does not work in self-contained HTML documents.
#|   Please set `embed-resources: false` in your metadata.
#| standalone: true
#| components: [editor, viewer]
#| viewerHeight: 500
#| packages: [vega_datasets]

import pandas as pd
from vega_datasets import data as vega_data
from shiny import App, ui, render, reactive

# Demonstrates programmatic selection reset:
# Changing the origin filter (or clicking Reset) forces filtered() to recompute,
# which re-renders the DataGrid — DataGrid automatically clears its selection
# whenever the underlying data reference changes.

cars = pd.DataFrame(vega_data.cars())
origins = sorted(cars["Origin"].unique())

app_ui = ui.page_fluid(
    ui.h4("Filter → selection resets automatically"),
    ui.layout_columns(
        ui.input_select("origin", "Origin", choices=["All"] + origins),
        ui.input_action_button("reset_btn", "Reset selection", class_="btn-secondary"),
        col_widths=[6, 6],
    ),
    ui.output_text("selection_info"),
    ui.br(),
    ui.output_data_frame("tbl"),
)


def server(input, output, session):
    @reactive.calc
    def filtered():
        # Take an explicit dependency on reset_btn so clicking it invalidates
        # filtered(), which in turn re-renders the DataGrid and clears the selection.
        _ = input.reset_btn()
        df = cars.copy()
        if input.origin() != "All":
            df = df[df["Origin"] == input.origin()]
        return df[["Name", "Miles_per_Gallon", "Cylinders", "Origin"]].reset_index(drop=True)

    @render.data_frame
    def tbl():
        return render.DataGrid(filtered(), height="350px", selection_mode="rows")

    @render.text
    def selection_info():
        selected = tbl.data_view(selected=True)
        n = 0 if (selected is None or selected.empty) else len(selected)
        return f"Selected rows: {n}  (changes filter or click Reset to clear)"


app = App(app_ui, server)

Reset all filters to defaults

Why does row selection reset automatically? DataGrid drops its selection whenever it receives a new DataFrame — a re-render means a fresh table, so old row indices no longer apply. That’s why changing origin or clicking Reset (which invalidates filtered()) clears the selection for free.

But resetting filter inputs themselves (dropdowns, sliders) back to defaults requires explicit action — inputs keep their values until you tell them otherwise. Use @reactive.effect + @reactive.event to reset all inputs when a button is clicked:

ui.input_action_button("reset_all", "Reset all filters")

@reactive.effect
@reactive.event(input.reset_all)
def _reset_filters():
    ui.update_select("origin", selected="All")
    ui.update_slider("mpg", value=0)
    ui.update_checkbox_group("cols", selected=["Name", "MPG", "HP"])
    ui.update_numeric("n_rows", value=10)
    ui.update_switch("log_scale", value=False)
    ui.update_radio_buttons("chart_type", selected="scatter")
  • @reactive.event(input.reset_all) makes the effect fire only when the button is clicked — not on every reactive cycle
  • Inside, call as many ui.update_*() functions as needed to restore defaults
  • Each update triggers downstream reactivity as usual

Editable tables (bonus)

Set editable=True and handle cell patches with @tbl.set_patch_fn:

@render.data_frame
def tbl():
    return render.DataGrid(df, editable=True)

@tbl.set_patch_fn
def _(*, patch):
    # patch.row_index, patch.column_index, patch.value
    # validate / transform the value here
    return patch["value"]

@tbl.set_patches_fn handles batches of edits at once.

The edited data is accessible via tbl.data_patched().

Block 4 — Advanced reactivity: lessons

Linked filters

  • @render.ui — return a new widget when choices/range must change
  • @reactive.effect + ui.update_*() — update in place (no rebuild)
  • req() — silently skip a render until a dynamic input exists

Column picker & editable tables

  • input_checkbox_group for explicit column selection
  • editable=True + @tbl.set_patch_fn for inline cell editing

data() vs data_view()

Method Returns
tbl.data() Full original DataFrame — ignores filters, sort
tbl.data_view() What the user sees — honours column filters + sort
tbl.data_view(selected=True) Selected rows only

Selection & filter reset

  • DataGrid auto-clears selection when its data reference changes
  • input.reset_btn() dependency in @reactive.calc — manual selection reset
  • @reactive.event + ui.update_*() — reset filter inputs to defaults

General principle: prefer tbl.data_view() over maintaining a parallel @reactive.calc-filtered DataFrame for downloads — they’re already in sync.