Getting Started with a11yShiny

Overview

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:

  1. Page layouta11y_fluidPage, a11y_fluidRow, a11y_column
  2. Input controlsa11y_actionButton, a11y_selectInput, a11y_numericInput, a11y_textInput, a11y_radioButtons, a11y_dateInput
  3. Composite widgetsa11y_textButtonGroup, a11y_textInputsGroup, a11y_highContrastButton
  4. Data displaya11y_renderDataTable, a11y_ggplot2_line, a11y_ggplot2_bar

Installation

# Install from source
devtools::install()

Accessible Page Layout

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.

Input Controls

All input wrappers share a common set of accessibility features:

Action Button

# 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"
)

Select Input

a11y_selectInput(
  inputId = "dataset",
  label = "Choose a dataset",
  choices = c("iris", "mtcars", "faithful"),
  describedby_text = "Select one of the built-in R datasets."
)

Numeric Input

a11y_numericInput(
  inputId = "n",
  label   = "Number of observations",
  value   = 100,
  min     = 1,
  max     = 1000,
  step    = 10
)

Text Input

a11y_textInput(
  inputId     = "name",
  label       = "Your name",
  placeholder = "e.g. Jane Doe"
)

Radio Buttons

a11y_radioButtons(
  inputId = "color",
  label   = "Favourite colour",
  choices = c("Red", "Green", "Blue")
)

Date Input

a11y_dateInput(
  inputId  = "start",
  label    = "Start date",
  value    = Sys.Date(),
  language = "en"
)

Composite Widgets

Text + Button Group

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"
)

Grouped Text Inputs

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")
  )
)

High-Contrast Toggle

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"
)

Accessible Data Tables

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.

Accessible Charts (ggplot2)

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.

Line Chart

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"
)

Bar Chart

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"
)

Key Design Decisions

Demo App Walkthrough

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.

Page structure and landmarks

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.

Input controls: standard vs. accessible

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.

Select input

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."
)

Numeric input

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"
)

Date input

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
)

Search bar (text + button composite)

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"
)

Address form (grouped text inputs)

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
)

Action buttons

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"
)

Charts: colour alone is not enough

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.

Data tables: keyboard navigation and language

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"))

Running the demo yourself

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.