Theming

Back to bslib

We have been touching on bslib package all day today, and we’ll now take a closer look at one final feature, using it to customize the theming of our Shiny apps using Bootstrap themes.

Bootswatch

Due to the ubiquity of Bootstrap, a large amount of community effort has already gone into developing custom themes. A large free collection of these are available at bootswatch.com/.

bs_theme()

Provides a high level interface to adjusting the theme for an entire Shiny app, and is passed to the theme argument of of the page function of our UI (e.g. page_sidebar(), fluidPage(), etc.).

bs_theme() allows allows us to construct a theme object by specifying:

  • a bootstrap version via version argument

  • a bootswatch theme via preset or bootswatch arguments

  • basic color palette values (bg, fg, primary, secondary, etc.)

  • fonts (base_font, code_font, heading_font, font_scale)

  • and more …

Demo 10 - Dynamic theming

demos/demo10.R

library(tidyverse)
library(shiny)
library(bslib)

d = readr::read_csv(here::here("data/weather.csv"))

d_vars = c(
  "Average temp" = "temp_avg",   "Min temp"       = "temp_min",
  "Max temp"     = "temp_max",   "Total precip"   = "precip",
  "Snow depth"   = "snow",       "Wind direction" = "wind_direction",
  "Wind speed"   = "wind_speed", "Air pressure"   = "air_press"
)

ui = page_sidebar(
  theme = bs_theme(),
  title = "Weather Data",
  sidebar = sidebar(
    selectInput(
      "region", "Select a region", 
      choices = c("West", "Midwest", "Northeast", "South")
    ),
    selectInput(
      "name", "Select an airport", choices = c()
    ),
    selectInput(
      "var", "Select a variable",
      choices = d_vars, selected = "temp_avg"
    )
  ),
  card(
    card_header(
      textOutput("title"),
    ),
    card_body(
      plotOutput("plot")
    )
  ),
  uiOutput("valueboxes")
)

server = function(input, output, session) {
  bslib::bs_themer()
  
  observe({
    updateSelectInput(
      session, "name",
      choices = d |>
        distinct(region, name) |>
        filter(region == input$region) |>
        pull(name)
    )
  })
  
  output$valueboxes = renderUI({
    clean = function(x) {
      round(x,1) |> paste("°C")
    }
    
    layout_columns(
      value_box(
        title = "Average Temp",
        value = mean(d_city()$temp_avg, na.rm=TRUE) |> clean(),
        showcase = bsicons::bs_icon("thermometer-half"),
        theme = "success"
      ),
      value_box(
        title = "Minimum Temp",
        value = min(d_city()$temp_min, na.rm=TRUE) |> clean(),
        showcase = bsicons::bs_icon("thermometer-snow"),
        theme = "primary"
      ),
      value_box(
        title = "Maximum Temp",
        value = max(d_city()$temp_max, na.rm=TRUE) |> clean(),
        showcase = bsicons::bs_icon("thermometer-sun"),
        theme = "danger"
      )
    )
  })
  
  output$title = renderText({
    names(d_vars)[d_vars==input$var]
  })
  
  d_city = reactive({
    req(input$name)
    d |>
      filter(name %in% input$name)
  })
  
  output$plot = renderPlot({
    d_city() |>
      ggplot(aes(x=date, y=.data[[input$var]])) +
      geom_line() +
      theme_minimal()
  })
}

shinyApp(ui = ui, server = server)

thematic

thematic is an R package that:

Simplified theming of ggplot2, lattice, and {base} R graphics. In addition to providing a centralized approach to styling R graphics, thematic also enables automatic styling of R plots in Shiny, R Markdown, and RStudio.

In the case of our Shiny app, to get dynamic theming of our plot as well as our UI all we need to do is to include a call to thematic_shiny() before the app is loaded.

Demo 11 - Full dynamic theming

demos/demo11.R

library(tidyverse)
library(shiny)
library(bslib)

d = readr::read_csv(here::here("data/weather.csv"))

d_vars = c(
  "Average temp" = "temp_avg",   "Min temp"       = "temp_min",
  "Max temp"     = "temp_max",   "Total precip"   = "precip",
  "Snow depth"   = "snow",       "Wind direction" = "wind_direction",
  "Wind speed"   = "wind_speed", "Air pressure"   = "air_press"
)

ui = page_sidebar(
  theme = bs_theme(),
  title = "Weather Data",
  sidebar = sidebar(
    selectInput(
      "region", "Select a region", 
      choices = c("West", "Midwest", "Northeast", "South")
    ),
    selectInput(
      "name", "Select an airport", choices = c()
    ),
    selectInput(
      "var", "Select a variable",
      choices = d_vars, selected = "temp_avg"
    )
  ),
  card(
    card_header(
      textOutput("title")
    ),
    card_body(
      plotOutput("plot")
    )
  ),
  uiOutput("valueboxes")
)

server = function(input, output, session) {
  bslib::bs_themer()
  
  observe({
    updateSelectInput(
      session, "name",
      choices = d |>
        distinct(region, name) |>
        filter(region == input$region) |>
        pull(name)
    )
  })
  
  output$valueboxes = renderUI({
    clean = function(x) {
      round(x,1) |> paste("°C")
    }
    
    layout_columns(
      value_box(
        title = "Average Temp",
        value = mean(d_city()$temp_avg, na.rm=TRUE) |> clean(),
        showcase = bsicons::bs_icon("thermometer-half"),
        theme = "success"
      ),
      value_box(
        title = "Minimum Temp",
        value = min(d_city()$temp_min, na.rm=TRUE) |> clean(),
        showcase = bsicons::bs_icon("thermometer-snow"),
        theme = "primary"
      ),
      value_box(
        title = "Maximum Temp",
        value = max(d_city()$temp_max, na.rm=TRUE) |> clean(),
        showcase = bsicons::bs_icon("thermometer-sun"),
        theme = "danger"
      )
    )
  })
  
  output$title = renderText({
    names(d_vars)[d_vars==input$var]
  })
  
  d_city = reactive({
    req(input$name)
    d |>
      filter(name %in% input$name)
  })
  
  output$plot = renderPlot({
    d_city() |>
      ggplot(aes(x=date, y=.data[[input$var]])) +
      geom_line()
  })
}

thematic::thematic_shiny()

shinyApp(ui = ui, server = server)

Your turn - Exercise 06

Using code provided in exercises/ex06.R (which is the same as demo09) experiment with bslib’s themer tool to explore different themes .

  • Try changing the main theme as well as the foreground and background colors

  • Try changing one or more of the accent colors

  • Try the fonts being used (e.g. Prompt, roboto, Oswald, Fira Sans) and changing the base font size

  • Based on the output in your console, update the bs_theme() call in the script to reflect the changes you made

  • Suggestion - making a good theme can be challenging, instead try making the ugliest possible app. This is a lot easier and more fun and just as instructive.

12:00

brand.yml

A recent addition to bslib (and quarto + other Posit products) is the ability to define a common theme elements using a brand.yml file. This allows you to define a theme once and then use it across multiple apps or documents.

  • For shiny apps the _brand.yml file is brought in automatically via the bslib package if bs_theme() is used

    • Behavior can be controlled via the brand argument

brand.yml compatibility

Demo 12 - Shiny + brand.yml

meta:
  name:
    short: Duke
    full: Duke University
  link:
    home: https://www.duke.edu
    brand: https://brand.duke.edu

logo:
  images:
    primary: logos/duke-primary-logo.png
    reversed: logos/duke-primary-logo-reversed.png
    shield: logos/duke-shield.png
  small: logos/duke-shield.png
  medium: primary
  large: primary

color:
  palette:
    duke-navy-blue: "#012169"     # Primary Duke Blue
    duke-royal-blue: "#00539B"
    copper: "#C84E00"
    persimmon: "#E89923"
    dandelion: "#FFD960"
    piedmont: "#A1B70D"
    eno: "#339898"
    magnolia: "#1D6363"
    prussian-blue: "#005587"
    shale-blue: "#0577B1"
    ironweed: "#993399"
    hatteras: "#E2E6ED"
    whisper-gray: "#F3F2F1"
    ginger-beer: "#FCF7E5"
    dogwood: "#988675"
    shackleford: "#DAD0C6"
    cast-iron: "#262626"
    graphite: "#666666"
    granite: "#B5B5B5"
    limestone: "#E5E5E5"

  foreground: cast-iron
  background: white
  primary: duke-navy-blue
  secondary: duke-royal-blue
  tertiary: persimmon
  success: magnolia
  info: eno
  warning: dandelion
  danger: copper
  light: white
  dark: cast-iron

typography:
  fonts:
    - family: EB Garamond
      source: google
    - family: Open Sans
      source: google
    - family: Roboto
      source: google
    - family: Roboto Mono
      source: google

  base:
    family: Open Sans
  headings:
    family: EB Garamond
    color: primary
  monospace:
    family: Roboto Mono
  navbar:
    family: EB Garamond
    color: primary

defaults:
  bootstrap:
    defaults:
      navbar-bg: $brand-duke-royal-blue
      sidebar-bg: $brand-duke-navy-blue
library(tidyverse)
library(shiny)
library(bslib)

d = readr::read_csv(here::here("data/weather.csv"))

d_vars = c(
  "Average temp" = "temp_avg",   "Min temp"       = "temp_min",
  "Max temp"     = "temp_max",   "Total precip"   = "precip",
  "Snow depth"   = "snow",       "Wind direction" = "wind_direction",
  "Wind speed"   = "wind_speed", "Air pressure"   = "air_press"
)

ui = page_sidebar(
  theme = bs_theme(brand = here::here("demos/demo12/_brand.yml")),
  title = "Weather Data",
  sidebar = sidebar(
    selectInput(
      "region", "Select a region", 
      choices = c("West", "Midwest", "Northeast", "South")
    ),
    selectInput(
      "name", "Select an airport", choices = c()
    ),
    selectInput(
      "var", "Select a variable",
      choices = d_vars, selected = "temp_avg",
      multiple = TRUE
    )
  ),
  card(
    card_header(
      textOutput("title")
    ),
    card_body(
      plotOutput("plot")
    )
  ),
  uiOutput("valueboxes")
)

server = function(input, output, session) {

  bs_themer()

  observe({
    updateSelectInput(
      session, "name",
      choices = d |>
        distinct(region, name) |>
        filter(region == input$region) |>
        pull(name)
    )
  })
  
  output$valueboxes = renderUI({
    clean = function(x) {
      round(x,1) |> paste("°C")
    }
    
    layout_columns(
      value_box(
        title = "Average Temp",
        value = mean(d_city()$temp_avg, na.rm=TRUE) |> clean(),
        showcase = bsicons::bs_icon("thermometer-half"),
        theme = "success"
      ),
      value_box(
        title = "Minimum Temp",
        value = min(d_city()$temp_min, na.rm=TRUE) |> clean(),
        showcase = bsicons::bs_icon("thermometer-snow"),
        theme = "primary"
      ),
      value_box(
        title = "Maximum Temp",
        value = max(d_city()$temp_max, na.rm=TRUE) |> clean(),
        showcase = bsicons::bs_icon("thermometer-sun"),
        theme = "danger"
      )
    )
  })
  
  output$title = renderText({
    names(d_vars)[d_vars %in% input$var] |> paste(collapse = " + ")
  })
  
  d_city = reactive({
    req(input$name)
    d |>
      filter(name %in% input$name)
  })
  
  output$plot = renderPlot({
    d_city() |> 
      pivot_longer(c(input$var), names_to="var", values_to="value") |>
      ggplot(aes(x=date, y=value, color=var)) +
      geom_line() +
      theme_minimal()
  })
}

thematic::thematic_shiny()

shinyApp(ui = ui, server = server)

Deploying Shiny apps

Your turn - Exercise 07 - Part 1

Go to shinyapps.io and sign up for an account if you don’t have one already.

  • You can create a new account via email & a password

  • or via o-auth through Google or GitHub.

If asked to pick a plan, use the Free option (more than sufficient for our needs here)

03:00

Organizing your app

For deployment generally apps will be organized as a single folder that contains all the necessary components (R script, data files, other static content).

  • Pay attention to the nature of any paths used in your code

    • Absolute paths are almost certainly going to break

    • Relative paths should be to the root of the app folder

  • Static files (e.g. css, js, etc.) are generally placed in the www/ subfolder

  • Your script does not need to be named app.R or ui.R/server.R

  • Check / think about package dependencies

Your turn - Exercise 07 - Part 2

Now we will publish our app to shinyapps.io (you will need to have completed Exercise 7 - Part 1)

  1. Package up ex07.R as an app in exercises/ex07app (you will need to create this folder)

    • Make sure to copy the data (weather.csv) into this folder
    • and adjust any paths if necessary
  2. Open the script file in exercises/ex07app and click the Publish Document button in the upper right of the pane

    • You should be presented with the “Publish to server” dialog, click on the Add New Account link in the upper right

    • Select shinyapps.io and follow the instructions to connect

Your turn - Exercise 07 - Part 2 (cont.)

  1. Once authenticated you should be back at the “Publish to server” dialog, use this to select which files to include (select your script and data file)

  2. Your Shiny app should now be deploying and should open on shinyapps.io once live - check to see if everything works, if not go back and check Steps 1 and 3.

  3. Once you have successfully deployed your app, share the links with your neighbour(s) and see if you can run their deployed app.

10:00

Demo 13 - shinylive

One of the really exciting developments in the last couple of years is the ability to run R (and Python) inside a web browser using WebAssembly. shinylive is a package that lets you bundle your shiny app as a static website that can be hosted anywhere you can host static html.

We can build a shinylive site using,

shinylive::export("demos/demo13","demos/demo13/site")

you can then run a local server with,

httpuv::runStaticServer("demos/demo13/site")

or check out the version of the app that is running on GitHub pages as part of this workshop,

Some notes on shinylive

  • You may have noticed we slightly changed which packages we load at the beginning of the app,

  • Specifically we replaced tidyverse with dplyr and ggplot2 - the reason for this is that each user of the shinylive app will need to download all the packages used in the app, so we want to keep this as minimal as possible.

  • We are also loading a package called munsell - this is a dependency of ggplot2 that is not automatically included when you load ggplot2 but is needed for the app to work.

  • I knew that I needed to add this because before adding it the app did not work (blank screen after loading)

  • To debug shinylive apps you need to use your browser’s developer tools (F12 in most browsers) and check the javascript console for error messages.

Other publishing options

  • For other R users - you can share your script(s) and data directly

    • or better yet, bundle them into an R package
  • Run a local instance of shiny server

  • Use shinyapps.io (public) or posit.cloud (within a team)

  • Use Posit Connect

What next / what else

Mastering Shiny

Shiny user showcase

The Shiny User Showcase is comprised of contributions from the Shiny app developer community. The apps are categorized into application areas and presented with a brief description, tags, and for many, the source code. Note that many of these apps are winners and honorable mentions of our annual Shiny contest!

Shiny contest

See the various winners in the following blog posts:

Shiny Assistant

Shinylive + a LLM (with Shiny specific context) that can help you start developing a Shiny app. Also super easy to share with others.

Awesome Shiny Extensions

A curated list of awesome R packages that offer extended UI or server components for the R web framework Shiny.

Workshop Survey





Thank you!

posit-conf-2025.github.io/shiny-r/
github.com/posit-conf-2025/shiny-r/
rundel@gmail.com
colin.rundel@duke.edu
rundel