Shiny 1.9 (released in July 2024) comes with a new feature: a spinner overlay on outputs or a page-level pulsing banner that shows up whenever the app is busy. No more boring “faded out” outputs when they are recalculating, but a nice visual cue! Such visual cues improve loading experience for your users, ultimately decreasing perceived waiting time. And that’s what we want! But how does it work? What happens in the Shiny source code? How does Shiny know when an output is busy, and when to display a spinner? You will find out all about it in this edition of “Shiny Source Code Explained”!
The new busy indicators
So what are we actually talking about? Two things: spinners and a page-level pulse banner. They look like this:
There’s a variety of spinner looks to choose from: bars, dots, pulse, ring… And you can even provide your own SVG file! You can also change things like color, size, pulse background, speed, and more.
When using bslib, like the examples above, the busy indicators are enabled by default. At the time of writing, December 2024, you have to enable them otherwise. However, in the future they will be turned on by default in Shiny as well.
HTML dependencies
Shiny needs dependencies to make it function the way it does. These dependencies are jQuery, and a bunch of JavaScript, Sass, CSS, and other required files that are attached to the HTML. For the new busy indicators, there’s also a new Shiny dependency: busyIndicatorDependency, which you can find in busy-indicators.R.
busyIndicatorDependency <- function() {
htmlDependency(
name = "shiny-busy-indicators",
version = get_package_version("shiny"),
src = "www/shared/busy-indicators",
package = "shiny",
stylesheet = "busy-indicators.css",
# TODO-future: In next release make spinners and pulse opt-out
# head = as.character(useBusyIndicators())
)
}
As you can see, a folder is used for src (which can be found here: www/shared/busy-indicators), and a CSS stylesheet. The folder contains the stylesheet and the SVGs that make up the spinners.
This busyIndicatorDependency is used within the shinyDependencies function in shinyui.R:
shinyDependencies <- function() {
list(
bslib::bs_dependency_defer(shinyDependencyCSS),
busyIndicatorDependency(),
htmlDependency(
name = "shiny-javascript",
version = get_package_version("shiny"),
src = "www/shared",
package = "shiny",
script =
if (isTRUE(
get_devmode_option(
"shiny.minified",
TRUE
)
))
"shiny.min.js"
else
"shiny.js",
all_files = FALSE
)
)
}
And in turn, shinyDependencies is used together with the jQuery dependency (jqueryDependency) in the renderPage() function, which makes sure the dependencies are in the HTML. You can read more about how page rendering works in an earlier edition of “Shiny Source Code Explained”, namely “Rendering the UI”.
Displaying the spinners and banner: the (S)CSS
So the necessary CSS and SVG files that make the spinners and banner look the way they do are in the HTML and can be used. But how are they being displayed?!
For that, we need to take a closer look at the SCSS in srcts/extras/busy-indicators/busy-indicators.scss. Note that this is not the CSS file that was added in the dependencies! SCSS stands for Sassy Cascading Style Sheets. It’s a CSS preprocessor that adds features like variables, nesting, and mixins to make styling easier. The (minified) CSS file is actually build from this SCSS file, because browsers can only understand plain CSS, not SCSS directly.
To make it easier to grasp, we’re going to look at this SCSS file in chunks. To start, let’s look at the positioning of the spinner for busy elements:
:where([data-shiny-busy-spinners] .recalculating) {
position: relative;
}
/* This data atttribute is set by ui.busy_indicators.use() */
[data-shiny-busy-spinners] {
.recalculating {
&::after {
position: absolute;
content: "";
/* ui.busy_indicators.spinner_options() */
--_shiny-spinner-url: var(--shiny-spinner-url, url(spinners/ring.svg));
--_shiny-spinner-color: var(--shiny-spinner-color, var(--bs-primary, #007bc2));
--_shiny-spinner-size: var(--shiny-spinner-size, 32px);
--_shiny-spinner-delay: var(--shiny-spinner-delay, 1s);
background: var(--_shiny-spinner-color);
width: var(--_shiny-spinner-size);
height: var(--_shiny-spinner-size);
inset: calc(50% - var(--_shiny-spinner-size) / 2);
mask-image: var(--_shiny-spinner-url);
-webkit-mask-image: var(--_shiny-spinner-url);
opacity: 0;
animation-delay: var(--_shiny-spinner-delay);
animation-name: fade-in;
animation-duration: 250ms;
animation-fill-mode: forwards;
}
}
}
First things first, the data attribute called data-shiny-busy-spinners. This attribute is added to the parent element when busy indicators are enabled in Shiny using useBusyIndicators(). It signals that elements inside can show a loading spinner. It basically acts as a “flag” for styling spinners when the app is recalculating. This R code sets that flag (see busy-indicators.R):
useBusyIndicators <- function(..., spinners = TRUE, pulse = TRUE, fade = TRUE) {
rlang::check_dots_empty()
attrs <- list("shinyBusySpinners" = spinners, "shinyBusyPulse" = pulse)
js <- vapply(names(attrs), character(1), FUN = function(key) {
if (attrs[[key]]) {
sprintf("document.documentElement.dataset.%s = 'true';", key)
} else {
sprintf("delete document.documentElement.dataset.%s;", key)
}
})
if (!fade) {
res <- tagList(res, fadeOptions(opacity = 1))
}
res
}
The function modifies the document.documentElement.dataset object to set or remove specific data-* attributes on the root <html> element, like:
data-shiny-busy-spinners(shinyBusySpinners)data-shiny-busy-pulse(shinyBusyPulse)
This ties directly to the [data-shiny-busy-spinners] CSS rule in the SCSS file.
That was the first line of SCSS, what else do we have? When an element in your Shiny app is busy, Shiny applies a .recalculating class to that element. The CSS above ensures a spinner is displayed using ::after. ::after is a CSS pseudo-element that lets you insert content after an element without modifying the HTML directly. It’s often used for decorative or functional purposes, like adding icons, tooltips, or in this case: spinners.
You can see that the styles are set for the spinner: the spinner appears as a masked image over the busy element. The spinner size, color, and image are customizable using CSS variables like --_shiny-spinner-size. We’ll come back to that a little bit later.
Spinners aren’t the only thing available as busy indicator: there’s also a page-level pulse banner. When the entire page is busy (but no specific spinner is visible), a glowing pulse bar appears at the top of the page:
@mixin shiny-page-busy {
/* ui.busy_indicators.pulse_options() */
--_shiny-pulse-background: var(
--shiny-pulse-background,
linear-gradient(
120deg,
transparent,
var(--bs-indigo, #4b00c1),
var(--bs-purple, #74149c),
var(--bs-pink, #bf007f),
transparent
)
);
--_shiny-pulse-height: var(--shiny-pulse-height, 3px);
--_shiny-pulse-speed: var(--shiny-pulse-speed, 1.2s);
/* Color, sizing, & positioning */
position: fixed;
top: 0;
left: 0;
height: var(--_shiny-pulse-height);
background: var(--_shiny-pulse-background);
z-index: 9999;
/* Animation */
animation-name: busy-page-pulse;
animation-duration: var(--_shiny-pulse-speed);
animation-direction: alternate;
animation-iteration-count: infinite;
animation-timing-function: ease-in-out;
content: ""; /* Used in a ::after context */
}
For the page-level pulse banner, a mixin is used. This is a reusable block of styles in SCSS. It allows you to define a set of CSS rules once and then include (or “mix in”) them wherever needed, saving time and avoiding code repetition. Handy. In this case, the mixin is used in two places:
- For page-level pulse with spinners, so when both spinners and a pulse banner are enabled.
- For pulse-only, so when only the pulse banner is enabled (and no spinners).
/* Page-level pulse with spinners */
/*
In spinners+pulse mode (the recommended default), show a page-level banner if the
page is busy, but there are no recalculating elements.
*/
[data-shiny-busy-spinners][data-shiny-busy-pulse] {
&.shiny-busy::after {
@include shiny-page-busy;
}
// Hide the pulse if there are spinners on the page
// (Note: UI outputs don't get spinners)
&.shiny-busy:has(.recalculating:not(.shiny-html-output))::after {
display: none;
}
&.shiny-busy:has(#shiny-disconnected-overlay)::after {
display: none;
}
}
/* Pulse-only */
/* In pulse _only_ mode, show a page-level banner whenever shiny is busy. */
[data-shiny-busy-pulse]:not([data-shiny-busy-spinners]) {
&.shiny-busy::after {
@include shiny-page-busy;
}
&.shiny-busy:has(#shiny-disconnected-overlay)::after {
display: none;
}
}
The mixin defines the pulsing animation styles for the ::after pseudo-element when the page is busy. It applies a linear gradient background and a pulsing animation for visual feedback. The page-level banner is hidden in certain conditions, such as when spinners are visible or when a disconnection overlay is active.
There are two animations that are important for a smooth appearance of the busy indicators: fade-in, and busy-page-pulse. These are defined using keyframes, which define the steps of an animation by specifying how an element’s styles should change at different points (percentages) during the animation:
/* Keyframes for the fading spinner */
@keyframes fade-in {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
/* Keyframes for the pulsing banner */
@keyframes busy-page-pulse {
0% {
left: -14%;
right: 97%;
}
45% {
left: 0%;
right: 14%;
}
55% {
left: 14%;
right: 0%;
}
to {
left: 97%;
right: -14%;
}
}
There’s more code in the SCSS file, like making sure the spinner is visible on “dimmed” elements (which happens by default for busy elements), disabling spinners for problematic outputs (like uiOutput()), and preventing redundant spinners (in the case shinycssloaders is used).
The recalculating and shiny-busy CSS classes
As you’ve seen, there are two flags that important for displaying the busy indicators in Shiny:
- The data attribute (either
data-shiny-busy-spinners, ordata-shiny-busy-pulse) - The
recalculatingCSS class (in the case of spinners) orshiny-busyCSS class (in the case of the page-level pulsing banner)
You know where the data attributes come from, but where do the CSS classes come from?!
It all starts with message handlers that are added in shinyapp.ts. These message handlers can be called elsewhere to, surprise, send a message. We have one for “recalculating”, and one for “busy”. Let’s start with “recalculating”:
addMessageHandler(
"recalculating",
(message: {
name?: string;
status?: "recalculated" | "recalculating";
}) => {
if (
hasOwnProperty(message, "name") &&
hasOwnProperty(message, "status")
) {
const binding = this.$bindings[message.name as string];
if (binding) {
$(binding.el).trigger("shiny:" + message.status);
} else {
$().trigger("shiny:" + message.status);
}
}
}
);
The “recalculating” one is a bit complex, as it handles a message with a particular name (e.g. the id of a Shiny output), and a status. It first needs to look up the binding (from this.$bindings) with the given name/id. If the binding is found, it triggers a custom jQuery event on the element (binding.el). The event name is dynamically created by appending message.status to "shiny:". This triggers either the events "shiny:recalculating" or "shiny:recalculated", depending on the status. You can listen for these events in your custom JavaScript for example.
However, these events don’t explain how a CSS class like recalculating ends up in the output element. In fact, the Shiny source code itself doesn’t make use of these events.
Instead, we need to take a closer look at how output bindings are constructed. Going in-dept would require another edition of “Shiny Source Code Explained”, but it comes down to the showProgress method in the OutputBinding class (srcts/src/bindings/output/outputBinding.ts):
showProgress(el: HTMLElement, show: boolean): void {
const recalcClass = "recalculating";
if (show) $(el).addClass(recalcClass);
else $(el).removeClass(recalcClass);
}
So there we have the CSS class! This showProgress method is called in various places, but most importantly:
- When the output is initialised
- When the output is invalidated
This diagram displays it nicely:
So yes, rather complicated! You can also just believe that somewhere in the source code the recalculating class is added and the CSS for the busy indicators reacts in an appropriate manner.
Let’s now focus on the simpler “busy” message handler:
addMessageHandler("busy", (message: "busy" | "idle") => {
if (message === "busy") {
$(document.documentElement).addClass("shiny-busy");
$(document).trigger("shiny:busy");
} else if (message === "idle") {
$(document.documentElement).removeClass("shiny-busy");
$(document).trigger("shiny:idle");
}
});
This message handler can have two messages: “busy” or “idle”. When it receives the message “busy”, it will add the shiny-busy class to the document. If the message is “idle”, it will remove the shiny-busy class. Easy peasy.
Busy indicator options
As mentioned previously, it’s possible to customize the spinners and the page-level pulsing banner to your liking. You can do that by including busyIndicatorOptions(...) in your UI. This is the code behind this function:
busyIndicatorOptions <- function(
...,
spinner_type = NULL,
spinner_color = NULL,
spinner_size = NULL,
spinner_delay = NULL,
spinner_selector = NULL,
fade_opacity = NULL,
fade_selector = NULL,
pulse_background = NULL,
pulse_height = NULL,
pulse_speed = NULL
) {
rlang::check_dots_empty()
res <- tagList(
spinnerOptions(
type = spinner_type,
color = spinner_color,
size = spinner_size,
delay = spinner_delay,
selector = spinner_selector
),
fadeOptions(opacity = fade_opacity, selector = fade_selector),
pulseOptions(
background = pulse_background,
height = pulse_height,
speed = pulse_speed
)
)
bslib::as.card_item(dropNulls(res))
}
spinnerOptions <- function(type = NULL, color = NULL, size = NULL, delay = NULL, selector = NULL) {
if (is.null(type) && is.null(color) && is.null(size) && is.null(delay) && is.null(selector)) {
return(NULL)
}
url <- NULL
if (!is.null(type)) {
stopifnot(is.character(type) && length(type) == 1)
if (file.exists(type) && grepl("\\.svg$", type)) {
typeRaw <- readBin(type, "raw", n = file.info(type)$size)
url <- sprintf("url('data:image/svg+xml;base64,%s')", rawToBase64(typeRaw))
} else {
type <- rlang::arg_match(type, .busySpinnerTypes)
url <- sprintf("url('spinners/%s.svg')", type)
}
}
# Options controlled via CSS variables.
css_vars <- htmltools::css(
"--shiny-spinner-url" = url,
"--shiny-spinner-color" = htmltools::parseCssColors(color),
"--shiny-spinner-size" = htmltools::validateCssUnit(size),
"--shiny-spinner-delay" = delay
)
id <- NULL
if (is.null(selector)) {
id <- paste0("spinner-options-", p_randomInt(100, 1000000))
selector <- sprintf(":has(> #%s)", id)
}
css <- HTML(paste0(selector, " {", css_vars, "}"))
tags$style(css, id = id)
}
fadeOptions <- function(opacity = NULL, selector = NULL) {
if (is.null(opacity) && is.null(selector)) {
return(NULL)
}
css_vars <- htmltools::css(
"--shiny-fade-opacity" = opacity
)
id <- NULL
if (is.null(selector)) {
id <- paste0("fade-options-", p_randomInt(100, 1000000))
selector <- sprintf(":has(> #%s)", id)
}
css <- HTML(paste0(selector, " {", css_vars, "}"))
tags$style(css, id = id)
}
pulseOptions <- function(background = NULL, height = NULL, speed = NULL) {
if (is.null(background) && is.null(height) && is.null(speed)) {
return(NULL)
}
css_vars <- htmltools::css(
"--shiny-pulse-background" = background,
"--shiny-pulse-height" = htmltools::validateCssUnit(height),
"--shiny-pulse-speed" = speed
)
tags$style(HTML(paste0(":root {", css_vars, "}")))
}
There are three functions that take care of the different options:
spinnerOptions()fadeOptions()pulseOptions()
Each of these option functions set CSS variables, like --shiny-spinner-color. They form a block of CSS that is injected into the page using tags$style. And these CSS variables are subsequently used in the SCSS you saw earlier. You can call it magic, or just very clever :).
Loading experience and perceived waiting time
All this effort for some spinners and a banner, you might think. Why are they important?
While you do your best to create an app that feels amazingly fast, some loading time will be unavoidable. In that case, the only thing you can do, is making the waiting experience for your end-user as pleasant as possible. There are few ways to do so, and visual cues like spinners already tick some boxes to reduce the perceived waiting time and improve user satisfaction. Curious about what else you can do? You can check out “Reduce Perceived Waiting Time in Shiny Apps” on YouTube 🎥 .
Shiny Source Code Explained: other editions
In a few months there could be a book about the Shiny source code… Who knows! For now, you’ll have to do with separate blogs, and these are the previous editions:
Wrap up
Another mystery in the Shiny source code uncovered- and this is quite a new one! I hope you had fun reading and learning about the Shiny busy indicators.
☕️ Was this useful to you and would you like to support me? You can buy me a coffee!
📚 Want to learn more about Shiny? Discover my online Shiny courses: ProductioniZing Shiny , CustomiZing WidgetZ and Mobile StructureZ.
I provide R and Shiny consultancy. Do you need help with a project? Reach out and let’s have a chat!
