Alt text for dynamic plots in Shiny

Adding informative alternative text (alt text) to images is a fundamental principle of web accessibility. Currently Shiny does not have an option to add alt text to a dynamic plot created with the renderPlot() function. This article demonstrates a method of achieving this.

Histogram plot showing HTML with accessibility features and attributes overlayed in the columns.

A Common Problem

Having discovered that there was no equivalent of the renderImage() alt parameter for renderPlot() I started searching for solutions and found a thread on this subject in the RStudio Shiny repo on GitHub. Within this thread a solution is referred to by leonawicz using observers to add alt text to dynamic plots using their id, in the absence of a feature being implemented within Shiny. The following is my solution based on this principle.

Example app

This example is based on the default histogram of Old Faithful Geyser data app created in RStudio when you use File > New File > Shiny Web App… to create a new Shiny app. I’ve made some other alterations to further improve accessibility, besides those relating to the alt text, which I’ll cover at the end of the article.

#
# Based on RStudio example Shiny web application.
# Find out more about building applications with Shiny here:
#
# http://shiny.rstudio.com/
#
library(shiny)# Define UI for application that draws a histogram
ui <- fluidPage(title = "Adding dynamic alt text to plots",
# Set the language of the page - important for accessibility
tags$html(lang = "en"),
# CSS to improve contrast and size of slider widget numbers.
# Placed in the <head> tag for HTML conformity
tags$head(
tags$style("
/* slider scale, start and end numbers */
.irs-grid-text, .irs-min, .irs-max {
color: #333;
font-size: 0.8em;
}
/* value of slider appearing above the 'thumb' control */
.irs-from, .irs-to, .irs-single {
background-color: #333;
}
")
),
# Application title
h1("Old Faithful Geyser Data"),
# Set up the main landmark for the page - important for
# accessibility
HTML("<main>"),
# Sidebar with a slider input for number of bins
sidebarLayout(
sidebarPanel(
sliderInput("bins",
"Number of bins:",
min = 1,
max = 50,
value = 30)
),
# Show a plot of the generated distribution
mainPanel(
plotOutput("distPlot")
)
),
# End the main landmark and insert the script to add the alt text
# to the plot image
HTML("</main>
<script>
// Receive call from Shiny server with the alt text for
// the dynamic plot <img>.
Shiny.addCustomMessageHandler('altTextHandler', function(altText) {
// Setup a call to the update function every 500
// milliseconds in case the plot does not exist yet
var altTextCallback = setInterval(function() {
try {
// Get reference to <div> containing the plot as the
// <img> element does not have an id itself
var plotContainer = document.getElementById('distPlot');
// Add the alt attribute to the plot <img>
plotContainer.firstChild.setAttribute('alt', altText);
// Cancel the callback as we have updated the alt
// text
clearInterval(altTextCallback);
}
catch(e) {
// An error occurred, likely the <img> hasn't been
// created yet. Function will run again in 500ms
}
}, 500); });
</script>
")
)
# Define server logic required to draw a histogram
server <- function(input, output, session) {
# create a title for the plot which can also serve as the
# beginning of the alt text
plotTitle <- "Histogram of eruption waiting times (min)"
output$distPlot <- renderPlot({
# extract the eruption waiting times from the dataset and
# generate bins based on input$bins from ui.R
x <- faithful[, 2]
bins <- seq(min(x), max(x), length.out = input$bins + 1)
# draw the histogram with the specified number of bins
hist(x, breaks = bins, col = 'darkgray', border = 'white', main = plotTitle, xlab = "Eruption waiting time (min)", ylab = "Frequency of eruptions")
})
observe({
session$sendCustomMessage("altTextHandler", paste0(plotTitle, ", bins = ", input$bins, "."))
})
}
# Run the application
shinyApp(ui = ui, server = server)

The 2 main differences between this and the default Shiny app example which are required to achieve the updating of the alt text are:

  • the observe() in the server object
  • the Shiny.addCustomMessageHandler() event handler script in the ui object

As with the “default” RStudio app, changes to the number of bins by the user via the slider widget results in renderPlot() being re-evaluated and the plot redrawn. The role of the observer is to also communicate this change outside of the R server code:

observe({
session$sendCustomMessage("altTextHandler", paste0(plotTitle, ", bins = ", input$bins, "."))
})

Here we create the string of text to use as the alt text for the plot, taking the static title of the plot and adding to it the number of bins taken from the slider value. (This also demonstrates how the alt text can be dynamic so that it can meaningfully convey what the plot is showing.) The text is then passed to altTextHandler, an event handler declared in the script block whose job is to “listen” out for the call from the observer.

// Receive call from Shiny server with the alt text for
// the dynamic plot <img>.
Shiny.addCustomMessageHandler('altTextHandler', function(altText) {
// Setup a call to the update function every 500
// milliseconds in case the plot does not exist yet
var altTextCallback = setInterval(function() {
try {
// Get reference to <div> containing the plot as the
// <img> element does not have an id itself
var plotContainer = document.getElementById('distPlot');
// Add the alt attribute to the plot <img>
plotContainer.firstChild.setAttribute('alt', altText);
// Cancel the callback as we have updated the alt
// text
clearInterval(altTextCallback);
}
catch(e) {
// An error occurred, likely the <img> hasn't been
// created yet. Function will run again in 500ms
}
}, 500);});

When the observer calls the event handler, the string we created to use as the alt text is passed to it as the parameter altText, ready to be used within the function. From here you might expect to be able to simply add it to the plot but there is one further issue to overcome. When Shiny calls the event handler to communicate the change, the plot hasn’t actually been redrawn yet. In fact it doesn’t actually update on the web page until after the event handler function has been executed, meaning that any changes made to the plot are essentially overwritten once we return. To counter this we use javascript’s Window setInterval() method to essentially decouple the changes we want to perform on the plot from the current flow of events.

The setInterval method will run the code inside function() { … } every 500 milliseconds until it is told to stop. The reason for this repetition is to allow Shiny to remove the current plot from the page and to replace it with the new one based on the changed values — otherwise we might be trying to add alt text to something which no longer exists, causing an error! The try { … } catch(e) { } code handles this eventuality; try to add the alt text to the plot, but if it doesn’t exist, don’t worry and try again in another 500 milliseconds. Once the new plot exists on the page we can finally add the alt text.

In order to access the <img> element representing the plot we need some way to reference it. Unfortunately there are no unique identifiers present. Instead we have to do it via its parent element, a <div> containing the id you passed to the plotOutput() function in the ui.R code. In this example it is “distPlot”:

var plotContainer = document.getElementById('distPlot');

The plot is the first element within the <div>, so the following references the <img> element representing the plot and adds the alt text to it using the alt attribute:

plotContainer.firstChild.setAttribute('alt', altText);

So long as we haven’t encountered any issues by this point, the final line cancels any further calls to this function:

clearInterval(altTextCallback);

If you inspect the elements using the developer tools within your browser (Tools > Web Developer > Inspector for Firefox, View > Developer > Inspect elements for Chrome and Develop > Show Web Inspector for Safari) you will see the alt text being added after the plot changes, following a slight delay.

Further Improvements

Users with visual impairments may not be able to see the plot updating when the input values change, so by declaring the plot container as a dynamic region, i.e. the contents can change, screen readers can announce the alt text each time the plot changes. We do by adding an aria-live attribute to the plot container in our event handler code:

try {
// Get reference to <div> containing the plot as the
// <img> element does not have an id itself
var plotContainer = document.getElementById('distPlot');
// Screen readers can announce the alt text when plot updates
if (plotContainer['aria-live'] === undefined) plotContainer.setAttribute('aria-live', 'polite');
// Add the alt attribute to the plot <img>
plotContainer.firstChild.setAttribute('alt', altText);
// Cancel the callback as we have updated the alt
// text
clearInterval(altTextCallback);
}
catch(e) {
// An error occurred, likely the <img> hasn't been
// created yet. Function will run again in 500ms
}

This only needs to be done once, so we check first to see if the aria-live attribute exists and then add it if not. The value 'polite’ simply means that the announcement of the alt text will be delayed until the screen reader has finished reading out any other information, to prevent interruption.

Other accessibility improvements I added to the example are:

  • Setting the language of the page tags$html(lang = “en”) so that the content can be better understood by software, including screen readers.
  • Identifying the main content of the page using a landmark element HTML(“<main>”) to assist navigation.
  • Using CSS to improve the contrast and size of the numbers used on the slider widget:
tags$head(
tags$style("
/* slider scale, start and end numbers */
.irs-grid-text, .irs-min, .irs-max {
color: #333;
font-size: 0.8em;
}
/* value of slider appearing above the 'thumb' control */
.irs-from, .irs-to, .irs-single {
background-color: #333;
}
")
),

Hopefully these techniques will help improve the accessibility of your Shiny apps, and any other web content you produce.

Suggested Further Reading

Supporting decision-making in Trafford by revealing patterns in data through visualisation.

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store