Cards and panels

Use cards and panels to define areas of related content in your Shiny app.

Use cards and panels to define areas of related content.

Relevant Functions

  • ui.card
    ui.card(*args, full_screen=False, height=None, max_height=None, min_height=None, fill=True, class_=None, **kwargs)

  • ui.card_header
    ui.card_header(*args, container=tags.div, **kwargs)

  • ui.card_body
    ui.card_body(*args, fillable=True, min_height=None, max_height=None, max_height_full_screen=None, height=None, padding=None, gap=None, fill=True, class_=None, **kwargs)

  • ui.card_footer
    ui.card_footer(*args, **kwargs)

  • ui.panel_absolute
    ui.panel_absolute(*args, top=None, left=None, right=None, bottom=None, width=None, height=None, draggable=False, fixed=False, cursor='auto', **kwargs)

  • ui.panel_fixed
    ui.panel_fixed(*args, **kwargs)

  • ui.panel_well
    ui.panel_well(*args, **kwargs)

No matching items

Cards

Cards are a common organizing unit for modern user interfaces (UI). At their core, they’re just rectangular containers with borders and padding. However, when utilized properly to group related information, they help users better digest, engage, and navigate through content.

Hello card()

A ui.card() is designed to handle any number of “known” card items (e.g., ui.card_header(), ui.card_body(), etc) as unnamed arguments (i.e., children). As we’ll see shortly, ui.card() also has some useful named arguments (e.g., full_screen, height, etc).

At their core, ui.card() and card items are just an HTML div() with a special Bootstrap class, so you can use Bootstrap’s utility classes to customize things like colors, text, borders, etc.

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

from shiny import App, ui

app_ui = ui.page_fluid(
    ui.card(
        ui.card_header(
            "A header",
            class_="bg-dark"
        ),
        ui.card_body(
            ui.markdown("Some text with a [link](https://github.com)")
        )
    )
)

def server(input, output, session):
    pass

app = App(app_ui, server)
from shiny.express import ui

with ui.card():
    ui.card_header("A header", class_="bg-dark")
    ui.markdown("Some text with a [link](https://github.com)")
from shiny import App, ui

app_ui = ui.page_fluid(
    ui.card(
        ui.card_header(
            "A header",
            class_="bg-dark"
        ),
        ui.card_body(
            ui.markdown("Some text with a [link](https://github.com)")
        )
    )
)

def server(input, output, session):
    pass

app = App(app_ui, server)
No matching items

Implicit card_body()

If you find yourself using ui.card_body() without changing any of its defaults, consider dropping it altogether since any direct children of ui.card() that aren’t “known” ui.card() items are wrapped together into an implicit ui.card_body() call.

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

from shiny import App, ui

app_ui = ui.page_fluid(
    ui.card(
        ui.card_header(
            "A header",
            class_="bg-dark"
        ),
        ui.markdown("Some text with a [link](https://github.com).")
    )
)

def server(input, output, session):
    pass

app = App(app_ui, server)
from shiny.express import ui

with ui.card():
    ui.card_header("A header", class_="bg-dark")
    ui.markdown("Some text with a [link](https://github.com).")
from shiny import App, ui

app_ui = ui.page_fluid(
    ui.card(
        ui.card_header(
            "A header",
            class_="bg-dark"
        ),
        ui.markdown("Some text with a [link](https://github.com).")
    )
)

def server(input, output, session):
    pass

app = App(app_ui, server)
No matching items

Restricting growth

By default, a ui.card()’s size grows to accommodate the size of its contents. Thus, if a ui.card_body() contains a large amount of text, tables, etc., you may want to specify a height or max_height. That said, when laying out multiple cards, it’s likely best not to specify height on the ui.card(), and instead, let the layout determine the height.

Although scrolling is convenient for reducing the amount of space required to park lots of content, it can also be a nuisance to the user. To help reduce the need for scrolling, consider pairing scrolling with full_screen=True (which adds an icon to expand the card’s size to the browser window). Notice how, when the card is expanded to full-screen, max_height/height won’t affect the full-screen size of the card.

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

from shiny import App, ui

app_ui = ui.page_fluid(
    ui.card(
        ui.card_header("A long, scrolling, description"),
        "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris. " * 15,
        max_height="250px",
        full_screen=True
    )
)

def server(input, output, session):
    pass

app = App(app_ui, server)
from shiny.express import ui

with ui.card(max_height="250px", full_screen=True):
    ui.card_header("A long, scrolling, description")
    "Lorem ipsum dolor sit amet, consectetur adipiscing elit. " * 50
from shiny import App, ui

app_ui = ui.page_fluid(
    ui.card(
        ui.card_header("A long, scrolling, description"),
        "Lorem ipsum dolor sit amet, consectetur adipiscing elit. " * 50,
        max_height="250px",
        full_screen=True
    )
)

def server(input, output, session):
    pass

app = App(app_ui, server)
No matching items

Filling outputs

A ui.card()’s default behavior is optimized for facilitating filling layouts. More specifically, if a fill item (e.g., ui.output_plot()), appears as a direct child of a ui.card_body(), it resizes to fit the ui.card()’s specified height. This means, by specifying height="250px" we’ve effectively shrunk the plot’s height from its default of 400 down to about 200 pixels. And, when expanded to full_screen, the plot grows to match the ui.card()’s new size.

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

from shiny import App, render, ui
import matplotlib.pyplot as plt

app_ui = ui.page_fluid(
    ui.card(
        ui.card_header("A filling plot"),
        ui.card_body(ui.output_plot("plot")),
        height="250px",
        full_screen=True
    )
)

def server(input, output, session):
    @render.plot
    def plot():
        fig, ax = plt.subplots()
        ax.plot([1, 2, 3], [1, 4, 9])
        return fig

app = App(app_ui, server)
from shiny.express import render, ui
import matplotlib.pyplot as plt

with ui.card(height="250px", full_screen=True):
    ui.card_header("A filling plot")

    @render.plot
    def plot():
        fig, ax = plt.subplots()
        ax.plot([1, 2, 3], [1, 4, 9])
        return fig
from shiny import App, render, ui
import matplotlib.pyplot as plt

app_ui = ui.page_fluid(
    ui.card(
        ui.card_header("A filling plot"),
        ui.card_body(ui.output_plot("plot")),
        height="250px",
        full_screen=True
    )
)

def server(input, output, session):
    @render.plot
    def plot():
        fig, ax = plt.subplots()
        ax.plot([1, 2, 3], [1, 4, 9])
        return fig

app = App(app_ui, server)
No matching items

Multiple cards

ui.layout_columns() is especially nice for laying out multiple cards since each card in a particular row will have the same height (by default).

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

from shiny import App, render, ui

app_ui = ui.page_fluid(
    ui.layout_columns(
        ui.card(
            ui.card_header("User Settings"),
            ui.card_body(
                ui.input_text("name", "Name", "John Doe"),
                ui.input_slider("age", "Age", 18, 100, 30),
            ),
            ui.card_footer(
                ui.input_action_button("save", "Save Changes", class_="btn-primary")
            ),
            full_screen=True,
        ),
        ui.card(
            ui.card_header("Activity Summary"),
            ui.card_body(
                ui.output_text("summary"),
                ui.tags.ul(
                    ui.tags.li("Last login: 2 hours ago"),
                    ui.tags.li("Messages: 12 unread"),
                    ui.tags.li("Tasks: 5 pending"),
                ),
            ),
            full_screen=True,
        ),
        col_widths=[6, 6],
        height="300px",
    )
)

def server(input, output, session):
    @render.text
    def summary():
        return f"Hello, {input.name()}!"

app = App(app_ui, server)
from shiny.express import input, render, ui

with ui.layout_columns(col_widths=[6, 6], height="300px"):
    with ui.card(full_screen=True):
        ui.card_header("User Settings")
        with ui.card_body():
            ui.input_text("name", "Name", "John Doe")
            ui.input_slider("age", "Age", 18, 100, 30)
        with ui.card_footer():
            ui.input_action_button("save", "Save Changes", class_="btn-primary")

    with ui.card(full_screen=True):
        ui.card_header("Activity Summary")
        with ui.card_body():
            @render.text
            def summary():
                return f"Hello, {input.name()}!"

            ui.tags.ul(
                ui.tags.li("Last login: 2 hours ago"),
                ui.tags.li("Messages: 12 unread"),
                ui.tags.li("Tasks: 5 pending"),
            )
from shiny import App, render, ui

app_ui = ui.page_fluid(
    ui.layout_columns(
        ui.card(
            ui.card_header("User Settings"),
            ui.card_body(
                ui.input_text("name", "Name", "John Doe"),
                ui.input_slider("age", "Age", 18, 100, 30),
            ),
            ui.card_footer(
                ui.input_action_button("save", "Save Changes", class_="btn-primary")
            ),
            full_screen=True,
        ),
        ui.card(
            ui.card_header("Activity Summary"),
            ui.card_body(
                ui.output_text("summary"),
                ui.tags.ul(
                    ui.tags.li("Last login: 2 hours ago"),
                    ui.tags.li("Messages: 12 unread"),
                    ui.tags.li("Tasks: 5 pending"),
                ),
            ),
            full_screen=True,
        ),
        col_widths=[6, 6],
        height="300px",
    )
)

def server(input, output, session):
    @render.text
    def summary():
        return f"Hello, {input.name()}!"

app = App(app_ui, server)
No matching items

Follow these steps to create an app with content separated into cards:

  1. Use ui.layout_columns() to arrange multiple cards in a grid. Set col_widths to control column widths using Bootstrap’s 12-column grid (e.g., col_widths=[6, 6] for two equal-width cards per row).

  2. Add ui.card() calls inside ui.layout_columns(). You can use ui.card_header() and ui.card_footer() to create card headers and footers.

  3. Control the appearance and functionality of each card by passing additional arguments to ui.card(). For example, set full_screen=True to allow users to expand the card to fullscreen.

Tabbed card

Use ui.navset_card_underline() inside a card to create tabbed content.

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

from shiny import App, ui

app_ui = ui.page_fluid(
    ui.navset_card_underline(
        ui.nav_spacer(),
        ui.nav_panel(
            "Overview",
            "This project has 3 milestones and 12 tasks.",
            ui.br(),
            "Current status: On track",
        ),
        ui.nav_panel(
            "Team",
            ui.tags.ul(
                ui.tags.li("Alice (Lead)"),
                ui.tags.li("Bob (Developer)"),
                ui.tags.li("Carol (Designer)"),
            ),
        ),
        ui.nav_panel(
            "Timeline",
            "Start: January 2026",
            ui.br(),
            "Expected completion: June 2026",
        ),
        title="Project Dashboard",
    ),
    full_screen=True,
    height="300px",
)

def server(input, output, session):
    pass

app = App(app_ui, server)
from shiny.express import ui

ui.page_opts(full_screen=True, height="300px")

with ui.navset_card_underline(title="Project Dashboard"):
    ui.nav_spacer()
    with ui.nav_panel("Overview"):
        "This project has 3 milestones and 12 tasks."
        ui.br()
        "Current status: On track"
    with ui.nav_panel("Team"):
        ui.tags.ul(
            ui.tags.li("Alice (Lead)"),
            ui.tags.li("Bob (Developer)"),
            ui.tags.li("Carol (Designer)"),
        )
    with ui.nav_panel("Timeline"):
        "Start: January 2026"
        ui.br()
        "Expected completion: June 2026"
from shiny import App, ui

app_ui = ui.page_fluid(
    ui.navset_card_underline(
        ui.nav_spacer(),
        ui.nav_panel(
            "Overview",
            "This project has 3 milestones and 12 tasks.",
            ui.br(),
            "Current status: On track",
        ),
        ui.nav_panel(
            "Team",
            ui.tags.ul(
                ui.tags.li("Alice (Lead)"),
                ui.tags.li("Bob (Developer)"),
                ui.tags.li("Carol (Designer)"),
            ),
        ),
        ui.nav_panel(
            "Timeline",
            "Start: January 2026",
            ui.br(),
            "Expected completion: June 2026",
        ),
        title="Project Dashboard",
    ),
    full_screen=True,
    height="300px",
)

def server(input, output, session):
    pass

app = App(app_ui, server)
No matching items

Card with sidebar

Use ui.layout_sidebar() inside a card to create a card with a sidebar layout. This pattern is useful for cards that need input controls or filters alongside their main content.

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

import pandas as pd
from shiny import App, reactive, render, ui

# Create a small flower dataset
flowers = pd.DataFrame({
    "flower": ["Rose", "Tulip", "Daisy", "Orchid", "Lily", "Sunflower", "Violet", "Poppy"],
    "species": ["Rosa", "Tulipa", "Bellis", "Orchidaceae", "Lilium", "Helianthus", "Viola", "Papaver"],
    "color": ["Red", "Yellow", "White", "Purple", "Pink", "Yellow", "Purple", "Red"],
    "petal_length": [5.2, 3.8, 2.1, 6.3, 4.5, 8.1, 2.8, 3.5],
})

app_ui = ui.page_fluid(
    ui.card(
        ui.card_header("Flower Data Explorer"),
        ui.layout_sidebar(
            ui.sidebar(
                ui.input_select(
                    "color_filter",
                    "Filter by color",
                    ["All", "Red", "Yellow", "White", "Purple", "Pink"],
                ),
                ui.input_slider("rows", "Rows to show", 1, 8, 5),
                ui.input_action_button("reset", "Reset"),
            ),
            ui.card_body(
                ui.output_data_frame("flower_table"),
            ),
        ),
        full_screen=True,
        height="350px",
    )
)

def server(input, output, session):
    @render.data_frame
    def flower_table():
        # Filter by color
        if input.color_filter() == "All":
            df = flowers
        else:
            df = flowers[flowers["color"] == input.color_filter()]

        # Limit rows
        return df.head(input.rows())

    @reactive.effect
    @reactive.event(input.reset)
    def _():
        ui.update_select("color_filter", selected="All")
        ui.update_slider("rows", value=5)

app = App(app_ui, server)
import pandas as pd
from shiny.express import input, render, ui
from shiny import reactive

# Create a small flower dataset
flowers = pd.DataFrame({
    "flower": ["Rose", "Tulip", "Daisy", "Orchid", "Lily", "Sunflower", "Violet", "Poppy"],
    "species": ["Rosa", "Tulipa", "Bellis", "Orchidaceae", "Lilium", "Helianthus", "Viola", "Papaver"],
    "color": ["Red", "Yellow", "White", "Purple", "Pink", "Yellow", "Purple", "Red"],
    "petal_length": [5.2, 3.8, 2.1, 6.3, 4.5, 8.1, 2.8, 3.5],
})

with ui.card(full_screen=True, height="350px"):
    ui.card_header("Flower Data Explorer")
    with ui.layout_sidebar():
        with ui.sidebar():
            ui.input_select(
                "color_filter",
                "Filter by color",
                ["All", "Red", "Yellow", "White", "Purple", "Pink"],
            )
            ui.input_slider("rows", "Rows to show", 1, 8, 5)
            ui.input_action_button("reset", "Reset")
        with ui.card_body():

            @render.data_frame
            def flower_table():
                # Filter by color
                if input.color_filter() == "All":
                    df = flowers
                else:
                    df = flowers[flowers["color"] == input.color_filter()]

                # Limit rows
                return df.head(input.rows())

        @reactive.effect
        @reactive.event(input.reset)
        def _():
            ui.update_select("color_filter", selected="All")
            ui.update_slider("rows", value=5)
import pandas as pd
from shiny import App, reactive, render, ui

# Create a small flower dataset
flowers = pd.DataFrame({
    "flower": ["Rose", "Tulip", "Daisy", "Orchid", "Lily", "Sunflower", "Violet", "Poppy"],
    "species": ["Rosa", "Tulipa", "Bellis", "Orchidaceae", "Lilium", "Helianthus", "Viola", "Papaver"],
    "color": ["Red", "Yellow", "White", "Purple", "Pink", "Yellow", "Purple", "Red"],
    "petal_length": [5.2, 3.8, 2.1, 6.3, 4.5, 8.1, 2.8, 3.5],
})

app_ui = ui.page_fluid(
    ui.card(
        ui.card_header("Flower Data Explorer"),
        ui.layout_sidebar(
            ui.sidebar(
                ui.input_select(
                    "color_filter",
                    "Filter by color",
                    ["All", "Red", "Yellow", "White", "Purple", "Pink"],
                ),
                ui.input_slider("rows", "Rows to show", 1, 8, 5),
                ui.input_action_button("reset", "Reset"),
            ),
            ui.card_body(
                ui.output_data_frame("flower_table"),
            ),
        ),
        full_screen=True,
        height="350px",
    )
)

def server(input, output, session):
    @render.data_frame
    def flower_table():
        # Filter by color
        if input.color_filter() == "All":
            df = flowers
        else:
            df = flowers[flowers["color"] == input.color_filter()]

        # Limit rows
        return df.head(input.rows())

    @reactive.effect
    @reactive.event(input.reset)
    def _():
        ui.update_select("color_filter", selected="All")
        ui.update_slider("rows", value=5)

app = App(app_ui, server)
No matching items

Floating panel

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

## file: app.py
from htmltools import css
from shiny import App, ui

app_ui = ui.page_fillable(
    ui.panel_absolute(  
        ui.panel_well(
            ui.panel_title("Draggable panel"),
            "Move this panel anywhere you want.",
        ),
        width="300px",  
        right="50px",  
        top="50px",  
        draggable=True,  
    ),  
    style=css(
        background_image="url(https://unsplash.com/photos/XKXGghL7GQc/download?force=true&w=1920)",
        background_repeat="no-repeat",
        background_size="cover",
        background_position="center bottom",
    ),
)


def server(input, output, session):
    pass


app = App(app_ui, server)
from functools import partial

from htmltools import css
from shiny.express import ui
from shiny.ui import page_fillable

page_style = css(
    background_image="url(https://unsplash.com/photos/XKXGghL7GQc/download?force=true&w=1920)",
    background_repeat="no-repeat",
    background_size="cover",
    background_position="center bottom",
)

ui.page_opts(page_fn=partial(page_fillable, style=page_style))

with ui.panel_absolute(  
    width="300px",  
    right="50px",  
    top="50px",  
    draggable=True,  
):  
    with ui.panel_well():
        ui.h2("Draggable panel")
        "Move this panel anywhere you want."
from htmltools import css
from shiny import App, ui

app_ui = ui.page_fillable(
    ui.panel_absolute(  
        ui.panel_well(
            ui.panel_title("Draggable panel"),
            "Move this panel anywhere you want.",
        ),
        width="300px",  
        right="50px",  
        top="50px",  
        draggable=True,  
    ),  
    style=css(
        background_image="url(https://unsplash.com/photos/XKXGghL7GQc/download?force=true&w=1920)",
        background_repeat="no-repeat",
        background_size="cover",
        background_position="center bottom",
    ),
)


def server(input, output, session):
    pass


app = App(app_ui, server)

Follow these steps to create an app that has a panel floating over a main image.

First, to create the floating panel:

  1. Pass ui.panel_absolute() as the second argument of your Shiny UI page method, after ui.img(). Pass elements that you want to appear inside the panel to ui.panel_absolute().

  2. Position the panel using the top, bottom, left, and/or right parameters. Set the size of the panel using the height and/or width parameters.

  3. If you want the panel to be draggable, set the draggable parameter to True.

In the example above, we used CSS to add a scaling background image to the page. You can also use ui.img() to create this effect:

  1. Pass ui.img() to any Shiny UI page method (e.g., ui.page_fluid()). ui.img() creates an image.

  2. Pass the path or URL of your desired image to ui.img()’s src parameter. Set additional parameters to control the appearance of the image (e.g., width and height).

See also: ui.panel_fixed(). ui.panel_fixed() is equivalent to calling ui.panel_absolute() with fixed=True (i.e., the panel does not scroll with the rest of the page).