Accessible Shiny, Streamlit and Gradio Dashboards with py-maidr
A dashboard is where accessibility is most often lost, because the chart is rebuilt on every interaction and a screen reader user is dropped back to the top of the page. py-maidr ships an output for Shiny for Python (output_maidr() with @render_maidr), a renderer for Streamlit (render_maidr()) and a component for Gradio (output_maidr() with render_maidr()); all three wrap the chart in an iframe that loads maidr.js, and the Shiny output puts focus back on the chart after a reactive flush when the chart was what held focus. The sections below show the minimal app for each framework, then the two details that matter in production: caching the rendered HTML in Streamlit and serving the bundle offline.
Every plot type py-maidr supports works here, with the same keyboard model as in a notebook: see the examples overview for the plot families and the keyboard shortcuts.
Shiny
Shiny support ships as an optional extra:
pip install "maidr[shiny]"Pair output_maidr() in the UI with @render_maidr in the server, the same way you would pair ui.output_plot() with @render.plot:
import matplotlib.pyplot as plt
from shiny import App, ui
from maidr.widget.shiny import output_maidr, render_maidr
app_ui = ui.page_fluid(
ui.input_slider("n", "Bars", min=2, max=10, value=4),
output_maidr("bars"),
)
def server(input, output, session):
@render_maidr
def bars():
fig, ax = plt.subplots()
ax.bar(range(input.n()), range(1, input.n() + 1))
return ax
app = App(app_ui, server)The decorated function may return a matplotlib, seaborn or plotnine chart, a Plotly Figure, a Bokeh plot, or an Altair chart. Returning None leaves the output blank.
The chart takes the size of its output. output_maidr(id, width="100%", height="auto", fill=True) sizes the output (a CSS length, a number of pixels, or None); with a height of its own, or in a fillable container such as a ui.card(), the chart is fitted inside it and follows it into full screen.
@render_maidr accepts width, height, fill, use_cdn and hover_mode. Its width and height size the box the chart is fitted to, at the top left of the output, as @render.plot’s size its image; a size left unset follows the output. fill is for Shiny Express, where the decorator places its own output. Prefer use_cdn here over maidr.set_use_cdn(): the setter is process-wide state shared by every concurrent session, while the argument is scoped to one output.
@render_maidr(height=400, use_cdn=False)
def bars():
...The chart is drawn once, at its figure’s size, and scaled to fit its box, so its text is scaled with it. To keep the text large in a small output, give the figure a size near the output’s, such as figsize=(5, 2.5) for a 300px-tall one (a line below the chart is kept for maidr’s text). See Size and placement for the rest.
use_cdn=False is the setting for an air-gapped deployment. The chart is rendered in an iframe whose document is fetched from a session URL rather than pushed over the websocket, and that document loads the bundled maidr.js from the app itself, which serves it once per app and version – so each chart stays around 50 KB whichever setting you choose, and the ~1.9 MB bundle crosses the wire once rather than with every chart. The default ("auto") loads from the CDN and falls back to that same copy when the CDN is unreachable. See the Shiny note under Offline Use and the JavaScript Bundle.
A reactive flush replaces the whole output, so a reader who was navigating the chart when some input changed would be dropped to the top of the page without being told. @render_maidr puts focus back on the updated chart when — and only when — the chart was what held focus, so a reader who had deliberately moved to another control keeps their place.
In Shiny Express there is no separate UI call: the decorated function places its own container, and @output_args() sizes it, exactly as it does for @render.plot; the decorator’s own width and height size the chart’s box inside it.
from shiny.express import output_args
@output_args(width="600px")
@render_maidr
def bars():
...Check out a reactive Shiny dashboard example with maidr and its source code is available on GitHub.
Streamlit
Streamlit support ships as an optional extra:
pip install "maidr[streamlit]"import matplotlib.pyplot as plt
import streamlit as st
from maidr.widget.streamlit import render_maidr
day = st.selectbox("Day", ["Thu", "Fri", "Sat", "Sun"])
fig, ax = plt.subplots()
ax.bar(["lunch", "dinner"], [12, 30])
ax.set_title(f"Tips on {day}")
render_maidr(ax)render_maidr() accepts a matplotlib or seaborn artist, a Plotly Figure, or an Altair chart, plus height, width, tab_index, and use_cdn.
The defaults are chosen for accessibility rather than for layout:
height="content"lets Streamlit measure the chart, so maidr’s braille and text panels stay visible when they open. A fixed height crops them.tab_index=Noneleaves the frame’s tab order to the browser. The chart is still reached by tabbing: an iframe’s contents take part in sequential focus navigation, and maidr gives the chart inside its own tab stop. Passing0makes the frame a stop as well, which is one extra Tab before the chart.
The frame is named after the chart it holds – "Tips on Fri, accessible chart" here, or "Accessible chart" when the chart has no title – the same name maidr gives its own frames in a notebook, so a screen-reader user moving through a dashboard can tell one chart from the next. That needs a Streamlit whose st.iframe takes alt (streamlit#17040); older releases name every frame st.iframe, and render_maidr() leaves them to it.
Streamlit reloads the chart’s frame when the HTML it is handed changes. For a matplotlib, seaborn, Plotly or Altair chart that happens only when the chart itself changes: an unchanged chart renders to the same HTML on every rerun, and Streamlit keeps its frame. A reader who is inside the chart when the script reruns – an st.fragment(run_every=...) around it, or an auto-refresh – stays where they were, keeping their place and everything else maidr holds for them, with nothing announced because nothing changed.
Two things still start a reader over. A chart that did change is a new chart, and loads fresh. And a reader who leaves the chart to use a control and then comes back begins again at the start – not because of the rerun: maidr lets go of its place whenever focus leaves a chart, on any page.
Bokeh charts, which are experimental, still render differently every time, so their frame is reloaded on every rerun. Caching the HTML rather than the render call keeps it, since the cached string is the same each run – and for any chart, it saves rendering the chart again:
from maidr.widget.streamlit import maidr_html
@st.cache_data
def chart_html(_fig, key): # `_fig` is not hashed; `key` is the cache key
return maidr_html(_fig)
st.iframe(
chart_html(fig, key=day),
height="content",
width="stretch",
alt=f"Tips on {day}, accessible chart", # the frame's accessible name
)Calling st.iframe yourself means naming the frame yourself: alt is what a screen reader announces for it, and without one every chart is “st.iframe”. Drop the argument on a Streamlit that predates it.
st.iframe arrived in Streamlit 1.56. On older versions place the same cached string with st.components.v1.html(chart_html(fig, key=day), height=600, scrolling=True) — it cannot size itself, so give it a height. render_maidr() handles that difference for you, which is why it is the better default when you are not caching.
Both arguments matter. The leading underscore tells Streamlit not to hash the figure, which a matplotlib Figure does not support – which leaves key as the only thing it can hash. Drop it and every argument is skipped, the cache key is constant, and the first chart is handed back for the rest of the session: a chart that quietly stops matching its own controls.
use_cdn=False embeds the ~1.9 MB maidr.js bundle in the page. That is required rather than wasteful: the chart is rendered inside an iframe, and an iframe built from an HTML string cannot fetch anything the app serves. The default loads from the CDN and stays around 15 KB.
Check out this Streamlit dashboard with maidr, and its source code is available on GitHub.
Note: the Streamlit framework has some “Unlabeled 0 Button” which does not have to do with our maidr package. This issue needs to be addressed by the Streamlit team.
Gradio
Gradio support ships as an optional extra:
pip install "maidr[gradio]"Place a chart with output_maidr() when the app is built, and return render_maidr() from an event handler whose output is that component, the way you would return a figure for a gr.Plot:
import gradio as gr
import matplotlib.pyplot as plt
from maidr.widget.gradio import output_maidr, render_maidr
def bars(n):
fig, ax = plt.subplots()
ax.bar([f"bar {i + 1}" for i in range(int(n))], range(1, int(n) + 1))
ax.set_title(f"{int(n)} bars")
markup = render_maidr(ax)
plt.close(fig)
return markup
with gr.Blocks() as demo:
n = gr.Slider(2, 10, value=4, step=1, label="Bars")
chart = output_maidr()
demo.load(bars, n, chart)
n.change(bars, n, chart)
demo.launch()output_maidr(chart) takes a chart to show from the start, and passes any other argument, such as label= or elem_id=, on to the gr.HTML it makes. Both take a matplotlib or seaborn artist, a Plotly Figure, a Bokeh figure, an Altair chart, or a chart a py-maidr reader returned, such as maidr.read_wandb_history()’s, and use_cdn.
gr.HTML does not run the scripts of its value, so the chart is placed in an iframe, the one maidr uses in a notebook: named after the chart, "5 bars, accessible chart" here, sized to it as maidr’s braille and text panels open, and allowed to reach a refreshable braille display. A reader tabs straight onto the chart inside it. The chart is always passed in rather than read from matplotlib’s current figure, which every user of a Gradio app shares. use_cdn=False embeds the ~1.9 MB maidr.js bundle in each chart, for an app without network access.
Gradio 5 or newer is what the extra installs, on Python 3.10 or newer.