A spinner says wait. A skeleton says what is coming.

R
Shiny
packages

A short video about how websites use grey placeholders made me ask if Shiny had them. It did not, so I wrote bones. This is how it began, and the four problems that were harder than the grey boxes.

Author

Tanmay Chanda

Published

28 September 2026

The bones hex logo, alive: the bone bounces, a teal wave sweeps across the grey lines under it, and grey placeholders around it fill in with real content.

It started with a reel.

It was one of those short videos that explain a trick of the web. When a page loads, the site does not show you a spinner. It shows you grey boxes in the shape of the page: a bar where the title will be, a rectangle where the picture will be, three lines where the text will be. Then the real content fills in.

The point of the video was that this is a kind of trick. The page is not faster. The data takes as long to arrive as before. But the wait feels shorter, because you already see the shape of what is coming. Your eye goes to the place where the title will be. You start to read the page before there is anything on it.

These grey boxes are called skeleton screens. Once you know about them, you see them everywhere: in social media feeds, in video sites, in almost every large web application.

Does Shiny have this?

My first thought was about Shiny. A Shiny output that takes two seconds to calculate shows a spinner, or it shows nothing and then jumps into place. So I looked for a skeleton package for Shiny.

What I found gave me spinners and overlays. They are good packages, and a spinner does one job well: it tells the user that something happens. But that is all it says. It does not say what is coming, or where.

I did not find a package that gives an output a placeholder in the shape of its content. So I decided to write one.

The idea: the output already knows its shape

The first design question was the most important one. Where does the shape come from?

I did not want users to draw their skeletons by hand. A skeleton that you have to describe for each output is work, and work means that nobody does it. But a Shiny output already says a lot about what it will show. plotOutput() will show a chart. DT::DTOutput() will show a DT table, with its search box and its pages. textOutput() will show some lines of text.

So bones reads the output that you give it, and picks the shape from it:

library(shiny)
library(bones)

ui <- fluidPage(
  withBones(plotOutput("chart")),
  withBones(DT::DTOutput("patients"), rows = 10)
)

The chart gets columns on an axis. The DT table gets the “Show entries” control, the search box, ten rows and the page buttons. A gt table gets a title, a spanner and a source note. A leaflet map gets tiles, roads and pins. And if you do not want to wrap each output, one call wraps them all:

ui <- bones_auto(fluidPage(
  plotOutput("chart"),
  tableOutput("results")
))

That part was fun. The grey boxes themselves were the easy part. The hard part was everything around them.

A short recreation of what this post describes, made in a browser.

Problem one: the second load

A skeleton is right for the first load. The page is empty, and the user waits for the first answer.

But a dashboard calculates again and again. The user changes a filter, and the chart calculates again. The obvious answer is to show the skeleton each time. It is the wrong answer.

When the chart calculates again, the user is often in the middle of reading it. The old chart is still an answer. It is a slightly old answer, and a better one comes a moment later. Grey boxes in its place remove information that the user was reading.

So bones shows the skeleton on the first load only. When an output calculates again, the old content stays on the screen, dimmed. The user can still read it, and can see that it is out of date.

There was a second part to this. When several outputs use one slow reactive, Shiny calculates them one after the other. If each output dims only when its own turn comes, the dimming crawls across the page one output at a time, and the outputs that wait look up to date when they are not. So bones dims all of them as soon as they are out of date.

Problem two: a skeleton that flashes is worse than none

Many loads are fast. If the data comes back in 100 ms, a skeleton that appears and disappears in 100 ms is not help. It is a flash, and the eye reads a flash as a fault.

So the skeleton waits. It appears only after 300 ms, so a fast load shows nothing at all. And when it has appeared, it stays for at least 500 ms, so a load that ends just after the delay does not flash it for one frame.

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

These two numbers made more difference to how the package feels than any of the shapes did.

Problem three: the page must not move

A skeleton has a second job, and it is as important as the first. It keeps the space of the content, so the page does not jump when the content arrives.

This is easy when the output sets a height. plotOutput() is 400 px unless you say otherwise. It is harder for a table or a block of text, where the height depends on the data. bones estimates it from the number of rows or lines, and when the content arrives, the content sets the height. If the estimate was too tall, the space closes, and no gap stays under the content.

An estimate can still be wrong. So when the content arrives, the browser stores its real height. On the next visit, the placeholder keeps that height in place of the estimate, and from the second visit the page does not move at all.

Problem four: display: none and renderPlot()

This one is specific to Shiny, and I think it is the most useful thing in this post even if you never use bones.

The obvious way to hide the content under a skeleton is display: none. It does not work, and the reason is not obvious.

renderPlot() makes its image at the width of the output element. An element with display: none has a width of zero. So a plot that renders while it is hidden gets the wrong size. Shiny keeps that image, and the plot looks broken until something causes a new render.

bones hides the content with visibility: hidden in place of display: none, and puts the skeleton on top of it with absolute position. The output keeps its box, and only the paint is hidden. The plot always renders at the right width, because it always has one.

If you hide Shiny outputs in your own code, for tabs, for collapsed panels, for anything, this is worth remembering.

What bones cannot know

There is one limit that I could not remove. plotOutput() says that a plot will be there. It does not say which plot. renderPlot() decides that later, on the server. So a plotOutput() gets the bar shape unless you say otherwise:

withBones(plotOutput("trend"), type = "line")
withBones(plotOutput("share"), type = "pie")

An interactive chart is different. A plotly, echarts4r or highcharter output sends a description of its series to the browser. So bones reads the kind of chart from the first value, and the next loads show that shape. With remember, the kind is stored in the browser, and the first skeleton of the next visit has the right shape too.

The small things

A few decisions that are easy to miss:

  • The colours come from the theme of the page. A skeleton takes the tint of a branded bslib theme, and turns light in a dark theme, with no configuration.
  • Users who ask their system to reduce motion get no animation. A placeholder that moves is exactly what that setting is for.
  • The placeholder is hidden from screen readers. Shiny already tells them that an output is busy, and a second message would only repeat it.

From a reel to a package

The first commit of bones was on 23 September. The live demo on the package site runs in the browser with shinylive, so you can try every shape and option without installing anything.

A reel about how websites fool you with grey boxes turned into a package that does the same for Shiny. I do not think it is a trick, though. The wait is the same length. But a user who sees the shape of the answer waits in a better way.

Try it

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

shiny::runApp(system.file("examples/demo", package = "bones"))

Issues and pull requests are welcome. I would especially like to hear about outputs that get the wrong shape, because each one is a shape that bones could learn.