Skip to contents

A spinner tells the user to wait. A skeleton shows the user what is coming: bars for a bar chart, rows for a table, lines for text. The page does not look empty, and the eye of the user can go to the place where the content will be.

Install

bones is not on CRAN yet. Install it from GitHub:

# install.packages("remotes")
remotes::install_github("tenmeh/bones")

Wrap an output

Put withBones() around an output in the UI. That is all:

library(shiny)
library(bones)

ui <- fluidPage(
  withBones(plotOutput("chart")),
  withBones(tableOutput("results"), rows = 8)
)

bones finds the shape from the output. A plotOutput() gets columns on an axis, a tableOutput() gets a header row and body rows, and a textOutput() gets lines of text. A table from DT, reactable, gt or rhandsontable gets a shape that copies that package. The Shapes page shows each one.

Wrap every output at once

bones_auto() looks through a UI and puts each output in withBones(). Give it the whole page, or only a part of it:

ui <- bones_auto(fluidPage(
  plotOutput("chart"),
  tableOutput("results"),
  withBones(plotOutput("trend"), type = "line")
))

Each output gets the shape of its kind. An output that is in withBones() already keeps its own wrapper, so wrap one output yourself to give it a type or other options. Arguments after the UI go to each wrapper, and exclude names outputs to leave alone:

ui <- bones_auto(
  fluidPage(plotOutput("chart"), plotOutput("small")),
  stale = FALSE,
  exclude = "small"
)

An inline output, such as textOutput("n", inline = TRUE), is left alone, because a wrapper would break its line. A renderUI() makes its outputs later, so bones_auto() does not see them. Call bones_auto() on the UI inside that renderUI() too.

Name the shape when bones cannot find it

A plotOutput() only says that a plot will be there. renderPlot() decides the kind of chart later, on the server. So name the kind with type:

withBones(plotOutput("trend"), type = "line")
withBones(plotOutput("fit"),   type = "scatter")

A uiOutput() can hold anything, so give it a type too:

withBones(uiOutput("kpis"),  type = "value", n = 3)
withBones(uiOutput("cards"), type = "cards", n = 4)

The page does not move

Until the content arrives, the placeholder keeps the height of the output. If the output sets a height, as plotOutput() does, the placeholder uses it. If not, the height comes from rows, lines or n, or from height:

withBones(uiOutput("summary"), type = "text", lines = 5)
withBones(uiOutput("map"),     type = "plot", height = 500)

When the content arrives, the content sets the height, and no gap stays under it.

The browser also stores the real height of the content. On the next visit, the placeholder keeps that height in place of the estimate, so the page does not move at all. Turn this off with remember = FALSE.

A fast load does not flicker

The skeleton waits for 300 ms before it appears, so a load that ends sooner shows nothing. Once it has appeared, it stays for at least 500 ms, so it never flashes for one frame. Change the times with delay and min_time:

withBones(plotOutput("chart"), delay = 150, min_time = 400)

The second load keeps the old content

On the first load, the user sees a skeleton. When the output calculates again, the old content stays on the screen, dimmed, until the new content arrives. The user is possibly in the middle of reading it, and grey boxes in its place would remove information.

To show the skeleton again on each load, set stale = FALSE:

withBones(plotOutput("chart"), stale = FALSE)

Set the look for the whole app

bones_defaults() sets the animation, the colours and the speed for the session. An argument to withBones() is stronger than a default:

bones_defaults(
  animation = "pulse",   # "wave" (the default), "pulse", "cascade", "sweep" or "none"
  radius    = "0.5rem",
  speed     = 2,
  stale     = FALSE
)

The default colours come from the theme of the page: the text colour of the bslib theme, made faint. A skeleton thus takes the tint of a branded theme, and turns light in a dark theme, with no configuration. With no Bootstrap 5 theme, the colours are neutral greys. Users who ask their system to reduce motion get no animation.

A placeholder with no output

bones_skeleton() makes a skeleton on its own, for a place where there is no Shiny output:

output$panel <- renderUI({
  if (is.null(data())) return(bones_skeleton("table", rows = 5))
  tableOutput("results")
})