4 bslib

The bslib R package provides a modern UI toolkit that builds on the shiny package. In summary, bslib facilitates:

  • Creation of delightful and customizable Shiny dashboards.
  • UI components (e.g., cards, value boxes, sidebars, etc).
  • Custom theming of Shiny apps.
Example of a bslib dashboard

Figure 4.1: Example of a bslib dashboard

4.1 Fundamentals

4.1.1 Pages

One of the core features of bslib is the use of pages/tabs. To build an app this way bslib makes it easy by using the bslib::page_navbar() function to create child tabs with bslib::nav_panel(). Additionally, bslib apps tend to rely on sidebars so many functions, including bslib::page_navbar(), have a built in sidebar argument that you can fill using bslib::sidebar().

Example of a bslib dashboard with a page layout.

Figure 4.2: Example of a bslib dashboard with a page layout.

4.1.2 Cards

Another core feature of bslib apps is the card(). Apps built using bslib will almost always use cards, these are essentially containers that hold content in an organized way that make it really easy for users to engage, understand, and navigate your app.

Example of a bslib dashboard cards.

Figure 4.3: Example of a bslib dashboard cards.

4.1.3 Content filling

The last core concept to bslib is the way it is geared towards a filling layout, which means that contents are encouraged to grow/shrink to fit the browser window so that regardless of the resolution you’re accessing the app it will adjust to fill it nicely. As a result, many functions have a fill argument; these are considered “fillable” containers. The child contents, a child in this case means it is wrapped by the parent function, will fill the parent feature when fill = TRUE, which is the default. When content goes beyond the screen resolution bslib will make the content scrollable.

There is a lot to filling and it’s fundamental to bslib so it is recommended to read more about it here.

Example of a plot filling a card and cards filling a page.

Figure 4.4: Example of a plot filling a card and cards filling a page.

4.2 Layouts

4.2.1 Multi-page

To create multiple pages (or tabs) you can use bslib::page_navbar(), where you can set the sidebar argument if you want a sidebar, as well as add pages (tabs) by calling bslib::nav_panel(). Beyond a sidebar and tabs you can also create additional space between elements on the tab bar using bslib::nav_spacer() and even add links using bslib::nav_item().

Read more!

TIP

Most of the apps you will build will likely be structured something like the above as bslib::page_navbar() is one of the primary functions for building apps, especially at the BCCDC.

4.2.2 Multi-panel

Not only can we use tabs at the page level using bslib::page_sidebar(), we can also create them using any bslib::navset_*(). These are built to be outside the context of cards, but there are also some that create tabbed cards following the format of bslib::navset_card_*(). Check all of the options here.

Read more!

NOTE

Personally, the most useful feature set from this is the use of tabs for cards using bslib::navset_card_*().

4.2.3 Scrolling vs filling

Both bslib::page_sidebar() and bslib::page_navbar() default to a filling layout (see Section 4.1.3). Sometimes this results in undesireable behaviour, you can combat this by setting bslib::card() heights using min_height to restrict how small a card gets or max_height to restrict how large a card will get:

Even further, you may not want the filling layout at all. To stop this you can set fill = FALSE which will default outputs to 400px (or whatever height you have specified) and will make the whole page scrollable instead:

Read more!

4.2.4 Multi-column

4.2.4.1 bslib::layout_columns()

Create column layouts easily using bslib::layout_columns(). Optional arguments include:

  • col_widths which go up to 12 for each row. Beyond 12 cards will wrap to the next row. Negative values create an empty space. If not specified widths will be equally split.
  • row_heights Numeric inputs are fractional units but fixed units are supported too. If not specified widths will be equal.
Example of bslib::layout_columns()

Figure 4.5: Example of bslib::layout_columns()

Read more!

4.2.4.2 bslib::layout_column_wrap()

When displaying multiple cards (or value boxes, etc) at once, it’s often most visually appealing to have them displayed in a grid-like layout where each card has the same height and width. This is the core principle of bslib::layout_column_wrap(), instead of specifying every column’s width and every row’s height like bslib::layout_columns(), we will give a width and height to apply to every card.

Read more!

Fixed columns

Provide the width as width = 1/n where n is the number of columns. Columns will adjust to the window size to maintain the 1/n width:

Read more!

Responsive number of columns

Provide the width in any valid CSS unit like 200px. In this case, a two card layout will wrap to a new row when the window is less than 400 pixels wide (200px * 2 cards); beyond 400 pixels the cards will widen to equally fill the space:

Read more!

Fixed column width

If you don’t want cards to fill the free space if the window goes beyond the total width of your cards then set the argument fixed_width = TRUE:

Read more!

Varying heights by row

The default is to have all row heights equal. However, you can also allow row heights to be different set heights_equal = "row":

Read more!

Varying heights by cell

Cards are considered ‘fill items’, which means they will adapt to fill space by default. This means that if one card has less content than the other cards in the row, it will still expand to fill that empty space. However, you can prevent this by specifying fill = FALSE in specific cards:

Read more!

Varying widths

If you still want specific cards to have different widths but you want the functionality of wrapping, you can set the width to NULL and provide a custom css grid layout like so:

Read more!

Nested layouts

You can also nest layout_column_wrap() within another layout_column_wrap() to create unique layouts. For example:

Read more!

NOTE

In terms of formatting bslib::layout_column_wrap() is easier and provides a clean uniform look. It is likely that you will use this over bslib::layout_columns() in most cases.

4.2.5 Mobile layout

Filling layout for mobile devices is defaulted to FALSE. You can enable it by setting fillable_mobile = TRUE at the page level. You will want to set min_height for cards to prevent too much shrinkage.

Read more!

4.3 Cards

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

Read more!

4.3.1 Card components

  • bslib::card_header(): is where you can place contents in a header. This is a great place for your title and any icons!
  • bslib::card_body(): is where you can place your main card contents. If you find yourself using bslib::card_body() without changing any of its defaults, consider dropping it altogether since it will be wrapped together into an implicit card_body() call anyways.
  • bslib::card_footer(): is where you can place contents in a footer. This is a great place for any tooltips or popovers (see section 4.6).

4.3.2 Restricting growth

By default, a card()’s size grows to accommodate the size of its contents. Thus, if a card_body() contains a large amount of text, tables, etc., you may want to specify a height or max_height so it doesn’t get too large. If the contents go beyond the max_height the card will become scrollable.

Read more!

TIP

These things can be finnicky, you really need to feel it out for yourself and do a lot of testing. Also, setting the height helps with formatting on mobile, otherwise things can get a bit squishy.

4.3.3 Scrolling

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. When the contents of a card go beyond the height of the screen bslib will automatically create a scroll bar for the card. 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). Additionally, set the parent to fill = FALSE and the whole page will become scrollable, where you can then set the card height argument so that the content fits within the card without the need for scrolling.

Read more!

TIP

Scrolling in cards can get a bit weird depending on the content. I’d recommend always having full_screen = TRUE and comparing how scrolling the card feels compared to scrolling the page. Remember, this can differ between pages/tabs!

4.3.4 Filling outputs

A card()’s default behavior is optimized for facilitating filling layouts. More specifically, if a fill item (e.g., plotly_widget), appears as a direct child of a card_body(), it resizes to fit the card()s specified height

Fill item(s) aren’t limited in how much they grow and shrink, which can be problematic when a card becomes very small. To work around this, consider adding a min_height or height on the card_body() container or alternatively turn fill to FALSE in the cards parent container (ie. layout_column_wrap()). Setting fill to FALSE will make the page scrollable and therefore won’t force the cards to adjust to the screen size, meaning you can set the card height to fit the content into the card without the need for scrolling.

Read more!

4.3.5 Other features

There is a ton of functionality for cards that we don’t necessarily need to dive deep on. These include:

  • Multiple card bodies: You can also set up a card with multiple card bodies, which is useful for when you want a fillable item with a non fillable item or to have unique stylings. Read more!
  • Multiple columns: Sometimes you may want multiple columns in your card, for this you can use bslib::layout_column_wrap() within a card body to format the layout of your outputs. Read more!
  • Multiple cards: Again, as discussed in section 4.2.4.2 using bslib::layout_column_wrap() can also help format the layout of multiple cards. Read more!
  • Multiple tabs: We have already discussed tabs in section 4.2.2, these can be useful within cards themselves. Read more!
  • Sidebars: You can also create sidebars within a card using bslib::layout_sidebar(). Read more! and Read even more!
  • Static images: You can also embed static images! Read more!
  • Flexbox: Cards are all fillable because they are CSS flexbox containers. This means when you have inline tags (html tags) there is weird behaviour where each tag will appear on a new line. However, if you set fill = FALSE they will render inline. Read more!

4.5 Value boxes

A bslib::value_box() is a special kind of card designed for highlighting a value along with a title and a showcase placeholder (typically a bsicons icon). You can even have a plot as a backdrop to the value showcase. These can be standalone value boxes and they can also easily be inserted into cards!

An example of value boxes.

Figure 4.6: An example of value boxes.

Read more!

4.6 Tooltips and Popovers

Tooltips and popovers are useful for displaying information in a convenient and non-obtrusive way. Both require a trigger and a message. Tooltips are toggled via focus / hover whereas popovers are toggled via click.

Here is an example of a tooltip:

Here is an example of a popover:

There is a lot of functionality with tooltips and popovers, above are the simple usages but you can read more intricate methods and see more examples here.

TIP

Popovers are much more “persistent” (i.e., harder to open/close), and thus should only be used over tooltips when further interaction may be needed. To put it another way, use tooltips for small “read-only” messages, and popovers when the user should be able to interact with the message itself.

4.7 Themes

4.7.1 Bootswatch themes

Bootswatch themes are pre-packaged themes that can be applied using bslib::bs_theme(). Apply them easily within your UI code:

Read more!

4.7.2 Main colours and fonts

Sometimes you may want to have a customized theme. This is also easy to set up using bslib::bs_theme() by setting the background colour (bg), foreground colour (fg), accent colours (primary, secondary, etc) and fonts (base_font, heading_font, code_font, etc).

Read more!

4.7.3 Additional theming tools

Beyond the pre-packaged themes and customizing colours and fonts there are other tools we can use for theming. These are less important and more niche, so we won’t dive in here.

4.8 Using Shiny Info

Because we are still using shiny as a base, we can use useful tools like getCurrentOutputInfo() to drive behaviour on the server side. For example, adding new labels or plots when a window meets a certain size.

Read more!

NOTE

This is probably the most advanced you can get with shiny and bslib. When you begin to work with things at this level you will need to learn how to use things like browser() and inspecting the shiny web-page. This is a bit beyond what we are going to cover here but I thought I’d introduce it.

4.9 Advanced Example with bslib

4.9.1 Build the UI

Let’s create a more advanced UI with bslib::page_navbar so we have tabs up top, a sidebar on the left, theme it for BCCDC, with 3 tabs:

  • Tab 1 will be for a bit of an about filled with practically nothing because I just want to add more features and having this tab is the only way..
  • Tab 2 will be looking into population data for each state.
  • Tab 3 will be cover some metrics on poverty at the county level.
library(magrittr) # I need %>%...

# We will start with an htmltools::tagList() call because this allows 
# us to add multiple components together at once (ie. our css stylings before diving into the bslib setup)

ui <- htmltools::tagList(
  
  # I've commented it out because you won't have access to the css file.
  # However, this is to provide you with an example of how you'd include it if you had it
  
  # htmltools::tags$head(
  #   htmltools::tags$link(
  #     rel = "stylesheet",
  #     type = "text/css",
  #     href = "css/dashboard_styling.css")
  # ),
  
  ## Now we can begin the bslib section with our handy page_navbar()
  bslib::page_navbar(
    
    # Set up the ID for our pages
    id = "nav",
    
    # This will be the title for our app
    title = "Midwest Population Dashboard", # How exciting...
    
    # Our BCCDC colour as the background
    bg = "#004987",
    
    # Overall theme can just be something simple
    theme = bslib::bs_theme(version = 5),
    
    # Let's include underlines for our page tabs
    underline = TRUE,
    
    # We also want our window title to be related to the app's content
    # this is what appears in the tab on your browser.
    window_title = "Midwest Population Dashboard",
    
    # Let's make this mobile friendly
    fillable_mobile = TRUE,
    
    # Sidebar -----------------------------------------------------------
    
    # Here we go! Sidebar first
    # Make it not too wide, on the left hand side
    sidebar = bslib::sidebar(
      id = "sidebar",
      width = 400,
      position = "left",
      # Let's set it to not open and only open on page 2 and 3
      open = FALSE,
      
      # Conditional state selection for 2nd and 3rd pages
      # Create a drop down list of the states
      shiny::conditionalPanel(
        # Here we tell it the conditions
        condition = "input.nav == 'Population Profiles' ||
        input.nav == 'Poverty Profiles'", 
        
        # This is our selection drop down
        shiny::selectInput(
          'state',
          label = 'Select State:',
          selected = "Illinois",
          # I want them to be the full names but select based on acronym
          choices = list(
            "Illinois" = "IL",
            "Indiana" = "IN",
            "Michigan" = "MI",
            "Ohio" = "OH",
            "Wisconsin" = "WI"
          )
        )
      ),
      
      # conditional county selection for 3rd page
      # same thought process as above but this time
      # for one of the pages only
      shiny::conditionalPanel(
        condition = "input.nav == 'Poverty Profiles'",
        shiny::selectInput(
          'county',
          label = 'Select County:',
          #Selected as NULL for now.
          selected = NULL,
          # To start we'll just pull the Illinois counties
          # we will update based on state selection in server
          choices = ggplot2::midwest %>% 
            dplyr::filter(state == "IL") %>% 
            dplyr::pull(county)%>% 
            stringr::str_to_title() #they were all caps.. didn't like..
        )
      ),
      
      # At the bottom of the sidebar let's have a link to the info
      # on the midwest dataset just because
      shiny::actionButton(
        inputId = "data_info",
        label = "More on this dataset",
        icon = shiny::icon('circle-info'),
        onclick = "window.open('https://ggplot2.tidyverse.org/reference/midwest.html')"
      )
    ),
    
    # Page 1 -----------------------------------------------------------
    
    # Some gibberish About section
    bslib::nav_panel(
      title = "About",
      bslib::layout_column_wrap(
        width = 1,
        # intro card
        bslib::card(
          min_height = 750,
          bslib::card_header(
            #This is how you can add a nice icon next to your title
            shiny::h4(shiny::div(bsicons::bs_icon("list-task"), "About")) 
          ),
          shiny::htmlOutput(
            "about" #output id right here!!! we'll fill it with text in the server
          )
        ) 
      )
    ),
    
    # Page 2 -----------------------------------------------------------
    
    # this will be at state level
    # two plots, one for population by county bar chart
    # second one can be pop by race at state level
    bslib::nav_panel(
      title = "Population Profiles",
      bslib::layout_column_wrap(
        width = "600px",
        # I don't want to fill otherwise the cards will be huge and therefore the plots will get
        # too big
        fill = FALSE, 
        # Card 1 pop by county -------------------------------------------
        bslib::card(
          full_screen = TRUE, # Let the people go full screen!
          height = 575, # Seems like a good enough height
          bslib::card_header(
            shiny::h4(
              shiny::div(
                bsicons::bs_icon("pin-map-fill"), 
                "Top 10 Populated Counties"
              )
            )
          ),
          # Add in our plot 'placeholder'.. hey that's shiny!!
          shiny::plotOutput(
            outputId = "county_pop"
          )
        ),
        # Card 2 pop by race state -------------------------------------------
        # same thing here as above
        bslib::card(
          full_screen = TRUE,
          height = 575,
          bslib::card_header(
            shiny::h4(
              shiny::div(
                bsicons::bs_icon("people-fill"), 
                "State Population by Race"
              ),
            )
          ),
          shiny::plotOutput(
            outputId = "race_pop"
          )
        )
      )
    ),
    
    # Page 3 -----------------------------------------------------------
    
    # this will be at the county level and will 
    # have 4 metrics for poverty in value boxes
    bslib::nav_panel(
      title = "Poverty Profiles",
      bslib::layout_column_wrap(
        width = 1/2, # we learned this! I think.. let me go back and check
        fill = TRUE,
        # Box 1 perc pov -------------------------------------------
        bslib::value_box(
          title = "Percentage in Poverty",
          ## Because we want this value box to be reactive to our selections
          ## we need to include a textOutput for our value that we'll fill in 
          ## in the server!
          value = shiny::textOutput('perc_poverty'), 
          height = 300,
          theme = bslib::value_box_theme(bg = "#fff", fg = "#004987") # let's set the theme 
        ),
        # Box 2 perc pov -------------------------------------------
        # Same!!! but different metric
        bslib::value_box(
          title = "Percentage of Children in Poverty",
          value = shiny::textOutput('perc_child_poverty'),
          height = 300,
          theme = bslib::value_box_theme(bg = "#fff", fg = "#004987")
        ),
        # Box 2 perc pov -------------------------------------------
        # Same!!! but different metric
        bslib::value_box(
          title = "Percentage of Adults in Poverty",
          value = shiny::textOutput('perc_adult_poverty'),
          height = 300,
          theme = bslib::value_box_theme(bg = "#fff", fg = "#004987")
        ),
        # Box 2 perc pov -------------------------------------------
        # Same!!! but different metric
        bslib::value_box(
          title = "Percentage of Elderly in Poverty",
          value = shiny::textOutput('perc_elder_poverty'),
          height = 300,
          theme = bslib::value_box_theme(bg = "#fff", fg = "#004987")
        )
      )
    ),
    
    ## spaces everything after to far right because it looks nicer and 
    ## I can make this example even more complicated
    bslib::nav_spacer(),
    
    ## create a little drop down menu for links to shiny and bslib
    bslib::nav_menu(
      title = "References",
      
      # first order of business.. shiny
      bslib::nav_item(
        htmltools::tags$a(
          shiny::icon("code"), 
          htmltools::HTML("shiny"), 
          href = 'https://mastering-shiny.org/', 
          target = "_blank"
        )
      ),
      
      # second will be bslib
      bslib::nav_item(
        htmltools::tags$a(
          shiny::icon("palette"), #that's an icon!
          htmltools::HTML("bslib"), #This is our text
          href = 'https://rstudio.github.io/bslib/', #URL
          target = "_blank" #This means open the link in a new tab
        )
      )
    )
  )
)

4.9.2 Build the server

Alright it’s time to connect everything together and make it reactive!

server <- function(input, output, session) {
  
  # Let's handle the sidebar opening on page 2 and 3 first
  shiny::observe({
    bslib::toggle_sidebar( #the server toggle
      id = "sidebar", #the sidebar ID
      open = input$nav != 'About' #Do the opposite (ie open) when not on the about
    )
  })
  
  # Page 1 About ----------------------------------------
  
  # Just to show you how to render text simply here..
  output$about <- shiny::renderUI(
    "This is the about section, this tells you what the app is about......."
  )
  
  # Page 2 Population -----------------------------------
  
  ## we are only updating this page based on the state input
  ## so we can use observeEvent()
  shiny::observeEvent(input$state, {
    
    ## let's set up the data for the county plot first
    county_pop_dat <- ggplot2::midwest %>%
      dplyr::filter(state == input$state) %>% # We use the input here
      dplyr::arrange(-poptotal) %>%
      dplyr::mutate(county = factor(county, levels = county)) %>%
      dplyr::top_n(
        10,
        poptotal
      )
    
    ## Render the plot
    output$county_pop <- shiny::renderPlot(
      ggplot2::ggplot(
        county_pop_dat,
        ggplot2::aes(
          x = county,
          y = poptotal
        )
      ) +
        ggplot2::geom_bar(
          stat = "identity",
          fill = "skyblue"
        ) +
        ggplot2::labs(
          x = "County",
          y = "Population"
        ) +
        ggplot2::scale_y_continuous(labels = scales::comma) +
        ggplot2::theme_minimal() +
        ggplot2::theme(
          axis.text.x = ggplot2::element_text(angle = 90, hjust = 1, size = 14),  
          axis.title.x = ggplot2::element_text(size = 16),  
          axis.title.y = ggplot2::element_text(size = 16),
          axis.text.y = ggplot2::element_text(size = 14)
        )
    )
    
    
      
    ## Next let's do the race pop plot
    ## same idea let's filter using the input$state
    race_pop_dat <- ggplot2::midwest %>% 
      dplyr::filter(state == input$state) %>%
      dplyr::select(
        state, 
        county, 
        popwhite, 
        popblack, 
        popamerindian, 
        popasian, 
        popother
      ) %>% 
      tidyr::pivot_longer(
        cols = c(
          'popwhite',
          'popblack',
          'popamerindian',
          'popasian',
          'popother'
        ),
        names_to = "pop_type",
        values_to = "population"
      ) %>% 
      dplyr::summarise(
        population = sum(population),
        .by = c('pop_type')
      ) %>% 
      dplyr::mutate(
        pop_type = dplyr::case_when(
          pop_type == 'popwhite' ~ "White",
          pop_type == 'popblack' ~ "Black",
          pop_type == 'popother' ~ "Other",
          pop_type == 'popasian' ~ "Asian",
          pop_type == 'popamerindian' ~ "Native American"
        )
      ) %>% 
      dplyr::arrange(-population) %>% 
      dplyr::mutate(
        pop_type = factor(pop_type, levels = pop_type)
      )
    
    ## plot this bad boy
    output$race_pop <- shiny::renderPlot(
      ggplot2::ggplot(
        race_pop_dat, 
        ggplot2::aes(
          x = pop_type, 
          y = population
        )
      ) +
        ggplot2::geom_bar(
          stat = "identity", 
          fill = "skyblue"
        ) +
        ggplot2::labs(
          x = "Race", 
          y = "Population"
        ) +
        ggplot2::scale_y_continuous(labels = scales::comma) +
        ggplot2::theme_minimal() +
        ggplot2::theme(
          axis.text.x = ggplot2::element_text(angle = 90, hjust = 1, size = 14),  
          axis.title.x = ggplot2::element_text(size = 16),  
          axis.title.y = ggplot2::element_text(size = 16),
          axis.text.y = ggplot2::element_text(size = 14)
        )
    )
    
  })
  
  # Page 3 Poverty --------------------------------------
  
  ## First let's update the dropdown menu for the county 
  ## when we select a state using input$state
  shiny::observeEvent(input$state, {
    
    ## Filter for the counties for that state
    new_choices <- ggplot2::midwest %>%
      dplyr::filter(state == input$state) %>%
      dplyr::pull(county) %>% 
      stringr::str_to_title()
    
    ## Now let's update the UI reactively!
    ## to do that we only need to reference that handy ID
    shiny::updateSelectInput(
      inputId = 'county',
      label = 'Select County:',
      selected = new_choices[1], #just select the first in the list
      choices = new_choices
    )
    
  })
  
  ## Next let's update the value boxes when the county changes
  ## but we also need to take into account that the state's have
  ## some overlapping county names so it needs to react to a change in either
  ## we can do this using observe()
  shiny::observe({
    
    ## Here let's set a global variable to the currently selected
    ## state and county like so.. In observe() any reference to
    ## an input is recognized by shiny and therefore this reactive
    ## container will be re-ran.. lazily .. by shiny as a result.
    state_selection <- input$state
    county_selection <- input$county
    
    ## Now we use those to filter our data
    vb_data <- ggplot2::midwest %>% 
      dplyr::mutate(
        county = stringr::str_to_title(county)
      ) %>% 
      dplyr::filter(
        state == state_selection,
        county == county_selection
      )
    
    ## Lastly just update that text UI to include our metrics!
    output$perc_poverty <- renderText({
      paste0(round(vb_data$percbelowpoverty, 1), "%")
    })
    
    output$perc_child_poverty <- renderText({
      paste0(round(vb_data$percchildbelowpovert, 1), "%")
    })
    
    output$perc_adult_poverty <- renderText({
      paste0(round(vb_data$percadultpoverty, 1), "%")
    })
    
    output$perc_elder_poverty <- renderText({
      paste0(round(vb_data$percelderlypoverty, 1), "%")
    })
    
    
  })
  
}

4.9.3 Putting it all together

Now we’re finally ready to deploy locally!

NOTE

Phew… that was a bit of a step up. But I hope that you can see how we are slowly building on our skills from the shiny package with bslib.

4.10 Additional Resources