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_bara11y_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.a11y_textInputsGroup wraps multiple related text fields
in a <fieldset> / <legend>
structure, which screen readers announce as a single group.
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.
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:
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.
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.
Accessible: Adds the language parameter
for locale-aware rendering and heading_level to integrate
the label into the page’s heading hierarchy.
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
)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.
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.