Skip to contents

This article covers the building blocks of filling layouts in bslib: fillable containers and fill items. Filling layout is an inherently nuanced topic, and fully groking takes some effort, but you’ll gain a powerful way to get UI elements to fill the window, fill inside cards, fill inside sidebar layouts, or generally fill anywhere you want.

Since, in theory, essentially any UI element can be coerced into a fillable container and/or fill item1, it’s useful to first study their behavior in the abstract, which we do next in the In theory section. After, the In practice section reinforces those concepts and demonstrates their power with practical examples.

Throughout these sections, be aware that most bslib components, as well as many Shiny outputs (e.g., plotOutput(), plotlyOutput(), etc) classify as fill items by default. This means they possess the potential to grow/shrink to fit their container, but that potential is only activated when their immediate parent is a fillable container with a defined height. Also be aware that bslib components and many Shiny outputs have fill and/or fillable arguments to opt out/in of this behavior (and bslib also provides an API for testing/coercing these properties on any UI element – see is_fill() for more).

In theory

Activating fill

Just like any other HTML container, a fillable container’s default height depends on the height of it’s children. So, for example, if there’s a single fill item with a defined height of 400px (the default for most Shiny outputs), the fillable container’s height is also 400px (plus any padding, border, etc).

Defining the height of a fillable container activates its immediate children’s potential to fill. So, for example, if fillable container’s height is set to 200px, the fill child would shrink to about 200px:

If multiple fill items were immediate children of this fillable container, they’d keep shrinking (in this case, to about 100px each):

Adding a non-fill item (e.g., htmltools::p()-aragraph of text) won’t cause that particular item to grow/shrink, but the fill items divvy up any remaining space (careful: if non-fill item(s) are larger than the fillable container, the fill items won’t be visible!). This is big reason why card()s have a min_height argument (to prevent fill items from shrinking too much).

Resizable example

Notice the resizing handle on the lower-right hand corner of the fillable container above. Use it to change the size of the fillable container and compare the behavior between fill and non-fill items.

Carrying fill

The previous section focuses on the fairly simple case of one parent container. However, in practice, you’ll likely be working with multiple levels of parents, which quickly complicates things, especially because:

  1. Fill items require their immediate parent to be a fillable container in order to fill.
  2. All “raw” HTML tags (e.g., div(), p(), etc.) as well as many Shiny UI elements (e.g., wellPanel(), etc.) are neither fillable nor fill (i.e., we’ll call these non-fill elements).

As a result, a common way in which (1) breaks down is that a non-fill element, like a div(), comes between fillable and fill. In fact, you’ll run into this exact behavior when using uiOutput() to insert a dynamically rendered fill item into a fillable container (see this section for a concrete example).

Assuming the goal is for the fill item to fit the fillable container, it’s useful to coerce the non-fill element into both fill item and a fillable container, which we call a fill carriers. Any UI element can be coerced into a fill carrier with as_fill_carrier().

This concept of a fill carrier is especially useful and relevant for cards. In most cases, a card has numerous children like a header and a body, and the body commonly contains fill item(s) (to ensure fill items). This is why card_body() defaults to fillable = TRUE (and fill = TRUE).

You might wonder, why then would we want or need fillable = FALSE or fill = FALSE on a card_body()? One big reason is that fillable containers are powered by CSS flexbox, which changes the way it’s children are rendered. And, although those changes are nice for “stretchy” children, there are downsides for rendering inline elements. So, that’s why, it’s recommended that you use multiple card bodies when combining fill with non-fill

In practice

This section puts into practice what we learned in the theory of fillable containers and fill items.

Setup code

The example in the sub-sections that follow assume you’ve ran the following code. Here we’re using plotly to create a list of fill items, but the same concepts extend to other htmlwidgets (e.g., leaflet) and Shiny outputs like plotOutput().2

library(plotly)
plots <- list(
  plot_ly(diamonds) |> add_histogram(x = ~price),
  plot_ly(diamonds) |> add_histogram(x = ~carat),
  plot_ly(diamonds) |> add_histogram(x = ~cut, color = ~clarity)
)
plots <- lapply(plots, function(x) {
  config(x, displayModeBar = FALSE) |>
    layout(margin = list(t = 0, b = 0, l = 0, r = 0))
})

Filling the window

Perhaps the most important fillable container is page_fillable(), which sets it’s height equal to the browser window. Thus, if fill items appear as direct children, they’ll fill the window. page_fillable() also defaults to fillable_mobile = FALSE, which means the height isn’t set equal to the viewport on mobile. As a result, fill items use their own defined height (instead of the viewport size) on mobile, which is often better behavior when showing multiple outputs.

page_fillable(
  h2("Diamond plots"),
  plots[[1]], plots[[2]], plots[[3]]
)

Resizable example

Notice the resizing handle on the lower-right hand corner of the example above. Use it to change the size of the “window” and see the behavior of the filling plots

Limiting shrinkage

If you’re worried about plots becoming too small, consider putting them in a card_body() with a min_height (like we do later on). Also, if you don’t want the card border, you can do card(class = "border-0", ...)

Multiple columns

Since layout_columns() is a fill item (by default), it grows/shrinks just like any other fill item. It also defaults to fillable = TRUE, which in this case, means each column gets wrapped in a fillable container. That’s why, in this example, plots[[1]] and plots[[1]] also grow/shrink to match the size of the layout_columns() container.

page_fillable(
  h2("Diamond plots"),
  layout_columns(plots[[1]], plots[[2]]),
  plots[[3]]
)

Value boxes

Since value_box() is a fill item (by default), it grows/shrinks just like any other fill item. This is especially useful for keeping a common baseline in a multi-column layout. That said, the multi-layout column that holds value boxes probably doesn’t want it default fill = TRUE behavior, since the value boxes should be given more/less space and the window becomes larger/smaller:

boxes <- layout_columns(
  fill = FALSE,
  value_box(
    "Total diamonds",
    scales::comma(nrow(diamonds)),
    showcase = bsicons::bs_icon("gem", size = NULL)
  ),
  value_box(
    "Average price",
    scales::dollar(mean(diamonds$price), accuracy = 1),
    showcase = bsicons::bs_icon("coin", size = NULL),
    theme_color = "success"
  ),
  value_box(
    "Average carat",
    scales::number(mean(diamonds$carat), accuracy = .1),
    showcase = bsicons::bs_icon("search", size = NULL),
    theme_color = "dark"
  )
)

page_fillable(
  boxes,
  layout_columns(plots[[1]], plots[[2]]),
  plots[[3]]
)

Column wrapping layouts

To learn more about layout_columns(), see this article.

Full-screen cards

As alluded to in the Carrying fill section, card() and card_body() are fill carriers (that is, they are both fillable and fill, by default). Therefore, by wrapping each plot in a card, the card not only grows/shrinks (since they are fill), but also retain the plot’s ability to grow/shrink (since they are fillable).

plot_card <- function(header, ...) {
  card(
    full_screen = TRUE,
    card_header(header, class = "bg-dark"),
    card_body(..., min_height = 150)
  )
}

page_fillable(
  layout_columns(
    plot_card("Diamond price", plots[[1]]),
    plot_card("Diamond carat", plots[[2]])
  ),
  plot_card("Diamond cut by clarity", plots[[3]])
)

Note that, if we changed page_fillable() to page_fluid() (or page_fixed()), each plot would render to it’s default height (400px) since we no longer have a fillable with a specified height. That said, even in that case, if we expand the card to full-screen, the plot still grows to fit the full screen card (since the card() is then a fillable container with a specified height, the card_body() is a fill carrier, and the plot is a fill item).

Similar to what we’ve seen with outputs and card()s, layout_sidebar() is also a fill item (by default), so placing it as a direct child of page_fillable() makes it fit the window. Also, the main content’s container defaults to a fillable container, so if that behavior is undesirable, set fillable = FALSE in layout_sidebar().

page_fillable(
  padding = 0,
  layout_sidebar(
    border = FALSE,
    fillable = FALSE,
    sidebar = sidebar(
      title = "Diamond plots",
      "Input controls here..."
    ),
    layout_columns(
      plot_card("Diamond price", plots[[1]]),
      plot_card("Diamond carat", plots[[2]])
    ),
    plot_card("Diamond cut by clarity", plots[[3]])
  )
)

Sidebar layouts

To learn more about layout_sidebar(), see this article.

Other advice

Dynamic UI

As alluded to in the Carrying fill section, uiOutput() puts an additional UI element around renderUI()’s return value. So, in order to carry the potential to fill down to a fill item (e.g., plot_ly()), mark uiOutput() as a fill carrier.

library(plotly)

ui <- page_fluid(
  card(
    full_screen = TRUE,
    max_height = 300,
    card_header("My plot"),
    uiOutput("plot", as_fill_carrier())
  )
)

server <- function(input, output) {
  output$plot <- renderUI({
    plot_ly(diamonds, x = ~price)
  })
}

shinyApp(ui, server)

DT tables

DT’s datatable() has it’s own unique interface for filling a container. Specifically, make sure to set datatable(fillContainer = TRUE) in order for the table to grow/shrink as you’d expect it to.

library(DT)

ui <- page_fluid(
  card(
    full_screen = TRUE,
    max_height = 350,
    card_header("My table"),
    dataTableOutput("dt")
  )
)

server <- function(input, output) {
  output$dt <- renderDataTable({
    datatable(
      mtcars, fillContainer = TRUE
    )
  })
}

shinyApp(ui, server)

Other htmlwidgets

Broadly speaking, most htmlwidgets like plotly and leaflet are fill items by default, but that might not always be the case. Also, sometimes, you might not want a particular widget to be treated as a fill item. In the Shiny case, you should be able to control this through a fill argument on the output container (e.g., plotlyOutput("id", fill = FALSE)), but if no fill argument is available you can also use bslib’s as_fill() API to opt in/out. In the non-Shiny case, you can control fill through the widget’s htmlwidgets::sizingPolicy() (e.g., leaflet()$sizingPolicy$fill).

Avoid fluidRow()/column()

Modern versions of Bootstrap Grid currently use CSS Flexbox in such a way that filling layout is mostly incompatible with fluidRow()/column(). Instead, use layout_columns() to implement multi-column filling layouts