knitr::opts_chunk$set( collapse = TRUE, comment = "#>", eval = FALSE )
a11yShiny provides accessible drop-in replacements for popular Shiny UI
functions. Every component enforces ARIA attributes, visible labels, and
semantic HTML according to BITV 2.0 (the German
implementation of WCAG 2.1).
The package covers four areas:
a11y_fluidPage, a11y_fluidRow, a11y_columna11y_actionButton, a11y_selectInput,
a11y_numericInput, a11y_textInput, a11y_radioButtons,
a11y_dateInputa11y_textButtonGroup, a11y_textInputsGroup,
a11y_highContrastButtona11y_renderDataTable, a11y_ggplot2_line,
a11y_ggplot2_bar# Install from source devtools::install()
a11y_fluidPage wraps shiny::fluidPage and enforces two accessibility
essentials: a page title and a language attribute on the <html>
element. It also creates proper landmark regions (<main>, <header>,
<nav>, <footer>, <aside>).
library(shiny) library(a11yShiny) ui <- a11y_fluidPage( title = "My Accessible App", lang = "en", header = tags$header(tags$h1("Dashboard")), nav = tags$nav(tags$a(href = "#", "Home")), footer = tags$footer("Footer content"), # Everything passed via ... goes into <main> a11y_fluidRow( a11y_column(6, tags$p("Left column")), a11y_column(6, tags$p("Right column")) ) )
a11y_fluidRow validates that all direct children are a11y_column elements
and that their widths (plus offsets) sum to 12, preventing broken grid
layouts.
All input wrappers share a common set of accessibility features:
describedby_text creates a screen-reader-only description linked
via aria-describedby.heading_level optionally marks the label as a heading
(role="heading" + aria-level), useful for sectioned forms.# Button with visible label a11y_actionButton("go", label = "Submit") # Icon-only button -- aria_label is required when label is missing a11y_actionButton( "search", icon = icon("search"), aria_label = "Search" )
a11y_selectInput( inputId = "dataset", label = "Choose a dataset", choices = c("iris", "mtcars", "faithful"), describedby_text = "Select one of the built-in R datasets." )
a11y_numericInput( inputId = "n", label = "Number of observations", value = 100, min = 1, max = 1000, step = 10 )
a11y_textInput( inputId = "name", label = "Your name", placeholder = "e.g. Jane Doe" )
a11y_radioButtons( inputId = "color", label = "Favourite colour", choices = c("Red", "Green", "Blue") )
a11y_dateInput( inputId = "start", label = "Start date", value = Sys.Date(), language = "en" )
a11y_textButtonGroup pairs a text input with an action button. The button
automatically receives aria-controls pointing to the text input.
a11y_textButtonGroup( textId = "query", buttonId = "run_query", label = "Search term", placeholder = "Enter a keyword", button_icon = icon("search"), button_aria_label = "Run search", layout = "inline" )
a11y_textInputsGroup wraps multiple related text fields in a
<fieldset> / <legend> structure, which screen readers announce as a
single group.
a11y_textInputsGroup( groupId = "address", legend = "Postal address", inputs = list( list(inputId = "street", label = "Street"), list(inputId = "city", label = "City"), list(inputId = "zip", label = "ZIP code", width = "120px") ) )
a11y_highContrastButton adds a button that toggles a .high-contrast
CSS class on the <body> element and manages its own aria-pressed state.
a11y_highContrastButton( inputId = "contrast", label = "High Contrast", aria_label = "Toggle high-contrast mode" )
a11y_renderDataTable wraps DT::renderDataTable. It enables the
KeyTable extension for keyboard navigation by default and warns when
inaccessible features (copy/print/pdf buttons, column filters) are used.
server <- function(input, output, session) { output$table <- a11y_renderDataTable( expr = iris, lang = "en" ) }
German translations are built in -- set lang = "de" and the table
interface is fully translated. For other languages, pass a custom list via
dt_language.
Both chart helpers use a high-contrast, WCAG-compliant colour palette by default. The line chart also distinguishes series by marker shape, so the information is not conveyed by colour alone.
df <- data.frame( year = rep(2020:2024, 2), value = c(10, 14, 13, 17, 20, 8, 9, 11, 12, 15), group = rep(c("A", "B"), each = 5) ) a11y_ggplot2_line( data = df, x = year, y = value, group = group, title = "Trend by Group", x = "Year", y = "Value" )
df <- data.frame( category = c("Alpha", "Beta", "Gamma"), count = c(23, 17, 31) ) a11y_ggplot2_bar( data = df, x = category, y = count, title = "Counts by Category" )
The package ships with a complete Shiny application that places standard
Shiny components side-by-side with their accessible a11y_*
counterparts. You can launch it with:
shiny::runApp(system.file("examples/demo", package = "a11yShiny"))
The rest of this section walks through the demo and highlights what the
standard component gets wrong and how the a11y_* wrapper fixes it.
The demo wraps its entire UI in a11y_fluidPage, which enforces a page
title, a lang attribute on the <html> element, and semantic landmark
regions:
ui <- a11y_fluidPage( lang = "de", title = "Demo", header = tags$header( class = "page-header", tags$h1("Demo Dashboard"), tags$h2("A dashboard with a11yShiny components") ), aside = tags$aside( class = "help-panel", tags$h2("Help"), tags$p("Supplementary information goes here.") ), footer = tags$footer(tags$p("Copyright 2025")), # Everything below becomes <main> a11y_fluidRow( a11y_column(8, tags$p("Main content")), a11y_column(4, a11y_highContrastButton()) ) )
Screen readers use these landmarks (<header>, <main>, <aside>,
<footer>) for quick navigation. A standard fluidPage produces none of
them.
The demo shows each input type twice -- first the standard Shiny version,
then the accessible wrapper. The key problems with the standard versions
and how a11yShiny resolves them are outlined below.
Standard (inaccessible): The label is set to NULL, so screen readers
cannot announce what the control is for.
# Standard -- no visible label, no ARIA description selectInput("n_breaks", label = NULL, choices = c(10, 20, 35, 50))
Accessible: A visible label is required (the function errors
otherwise). Optional heading_level promotes the label to a heading for
form sections, and describedby_text adds a screen-reader-only
description.
a11y_selectInput( inputId = "n_breaks_1", label = "Number of bins", choices = c(10, 20, 35, 50), selected = 20, heading_level = 3 ) a11y_selectInput( inputId = "n_breaks_2", label = "Number of bins", choices = c(10, 20, 35, 50), selected = 20, describedby_text = "Select the number of histogram bins." )
Standard (inaccessible): Again label = NULL, and no ARIA role or
value attributes on the <input> element.
numericInput("seed", label = NULL, value = 123)
Accessible: Adds role="spinbutton", aria-valuemin, aria-valuemax,
and aria-valuenow on the control. The describedby parameter can link to
an existing help-text element by its ID instead of creating a new one.
# With auto-generated sr-only description a11y_numericInput( inputId = "seed_3", label = "Seed", value = 123, heading_level = 6, describedby_text = "Choose the seed for the random number generator." ) # Linking to an existing help-text element a11y_numericInput( inputId = "seed_1", label = "Seed", value = 123, describedby = "seed_help" )
Standard: dateInput provides a label, but the datepicker widget has
no heading-level annotation and no aria-describedby support.
dateInput("mydate", "Choose a date:")
Accessible: Adds the language parameter for locale-aware rendering
and heading_level to integrate the label into the page's heading
hierarchy.
a11y_dateInput( "mydate_acc", "Choose a date:", language = "de", heading_level = 2 )
Standard (inaccessible): A textInput with label = NULL and an
actionButton with label = NULL are placed in a raw <div>. There is no
ARIA linkage between the two, and neither element has an accessible name.
div( textInput("searchbox", label = NULL, placeholder = "Enter your query:", width = "100%" ), actionButton("do_search", label = NULL, icon = icon("search")) )
Accessible: a11y_textButtonGroup enforces a visible label on the text
field, requires button_aria_label when the button has no visible text, and
automatically wires aria-controls from the button to the text input.
a11y_textButtonGroup( textId = "text-acc", buttonId = "btn-acc", label = "Enter your query:", button_icon = icon("search"), button_aria_label = "Search", layout = "inline" )
Standard (inaccessible): Four separate textInput fields under a
plain <h3> heading. Screen readers see no relationship between the
fields.
div(h3("Address")) textInput("adr_street", "Street and number") textInput("adr_postcode", "ZIP code") textInput("adr_city", "City") textInput("adr_country", "Country")
Accessible: a11y_textInputsGroup wraps the fields in a
<fieldset> / <legend> structure with role="group" and
aria-labelledby. The legend can be promoted to a heading via
legend_heading_level, and a group-level describedby_text adds a
screen-reader description for the entire group.
a11y_textInputsGroup( groupId = "address_group", legend = "Address", inputs = list( list(inputId = "adr_street_acc", label = "Street and number"), list(inputId = "adr_postcode_acc", label = "ZIP code"), list(inputId = "adr_city_acc", label = "City"), list(inputId = "adr_country_acc", label = "Country") ), describedby_text = "Please enter your full postal address.", legend_heading_level = 3 )
Standard (inaccessible): Icon-only buttons with label = NULL produce
a <button> with no accessible name at all.
# Icon-only button -- no accessible name actionButton("refresh", label = NULL, icon = icon("refresh")) # Empty button -- no label, no icon, no aria-label actionButton("refresh_0", label = NULL)
Accessible: a11y_actionButton requires either a visible label or an
aria_label. Both can be combined to provide a richer screen-reader
announcement (e.g., a short visible label plus a longer aria_label).
# Visible label + icon a11y_actionButton("refresh_1", label = "Refresh", icon = icon("refresh") ) # Icon-only with aria_label a11y_actionButton("refresh_2", icon = icon("refresh"), aria_label = "Click to refresh" ) # Both visible label and aria_label a11y_actionButton("refresh_3", label = "Refresh", aria_label = "Click to refresh data" )
The demo renders the same data as both a standard ggplot2 chart and an
accessible version. The standard chart uses a manually chosen palette
(#A8A8A8, #FEF843, #6E787F) whose contrast ratios are too low,
particularly against a white background. The colours are also the only
distinguishing feature between series.
# Standard -- insufficient contrast, no shape distinction ggplot(df, aes(x = time, y = value, color = group)) + geom_line() + geom_point() + scale_color_manual( values = c("A" = "#A8A8A8", "B" = "#FEF843", "C" = "#6E787F") ) + theme_minimal()
Accessible: a11y_ggplot2_line applies a WCAG-compliant palette
(minimum 3:1 contrast ratio to the background) and maps each series to
a different marker shape, so colour is never the sole information carrier.
The result is a standard ggplot2 object that can be extended with
additional layers.
p <- a11y_ggplot2_line( data = df, x = time, y = value, group = group, legend_title = "Group", title = "Simulated time series by group" ) # The result is a regular ggplot2 object -- add layers as usual p <- p + ggplot2::geom_hline(yintercept = 0, linetype = "dashed") + ggplot2::labs(x = "Date", y = "Measurement")
The same principle applies to a11y_ggplot2_bar, which adds black bar
outlines so bars remain distinguishable even without colour perception.
The demo shows a standard DT::datatable with column filters and
copy/print/PDF buttons alongside the accessible version.
Standard (problems): Column filters (especially the numeric range slider) are not keyboard-accessible. The copy, print, and PDF buttons open modal dialogs or browser tabs that are difficult for screen-reader and keyboard users to operate.
output$tbl <- DT::renderDataTable({ DT::datatable( head(iris[, 1:4], 10), filter = "top", selection = "none", options = list( pageLength = 5, dom = "Bfrtip", buttons = c("excel", "copy", "csv", "pdf", "print") ) ) })
Accessible: a11y_renderDataTable enables the KeyTable extension
by default for full keyboard navigation between cells. It warns at
render time when inaccessible features are requested (column filters,
copy/print/PDF buttons). Setting lang = "de" activates built-in German
translations for all interface strings.
output$tbl_acc <- a11y_renderDataTable( expr = head(iris[, 1:4], 10), lang = "de", selection = "none", extensions = c("Buttons"), options = list( pageLength = 5, dom = "Bfrtip", buttons = c("excel", "csv") ) )
Wrap the output in a <div class="a11y-dt"> on the UI side to
apply the accessible focus and contrast styles shipped with the package:
div(class = "a11y-dt", dataTableOutput("tbl_acc"))
Use the demo to compare both versions with accessibility testing tools: inspect the rendered HTML in your browser's developer tools, navigate with the keyboard only (Tab / Shift-Tab / Arrow keys), or test with a screen reader such as NVDA, JAWS, or VoiceOver.
Any scripts or data that you put into this service are public.
Add the following code to your website.
For more information on customizing the embed code, read Embedding Snippets.