Accessibility
Plushie provides built-in accessibility support via accesskit, a cross-platform accessibility toolkit. The default renderer build includes accessibility, activating native platform APIs automatically: VoiceOver on macOS, AT-SPI/Orca on Linux, and UI Automation/NVDA/JAWS on Windows.
Screen reader users, keyboard-only users, and other AT users interact with
the same widgets and receive the same events as mouse users. No special
event handling is needed in your update -- AT actions produce the same
Event::Widget[type: :click, id: id], Event::Widget[type: :input, ...]
events as direct interaction.
How it works
Iced's fork provides native accessibility support. Three pieces work together:
iced widgets report
Accessiblemetadata -- each widget declares its role, label, and state to the accessibility system automatically.TreeBuilder assembles the accesskit tree -- iced walks the widget tree during
operate(), collecting metadata and building an accesskitTreeUpdate.AT actions become native iced events -- when an AT triggers an action, iced translates it to a native event. The renderer maps it to a standard plushie event and sends it to Ruby over the wire protocol.
Auto-inference
Most widgets get correct accessibility semantics without any annotation.
Role mapping
| Widget type | Role | Notes |
|---|---|---|
button |
Button | |
text, rich_text |
Label | |
text_input |
TextInput | |
text_editor |
MultilineTextInput | |
checkbox |
CheckBox | |
toggler |
Switch | |
radio |
RadioButton | |
slider, vertical_slider |
Slider | |
pick_list, combo_box |
ComboBox | |
progress_bar |
ProgressIndicator | |
scrollable |
ScrollView | |
container, column, row, stack |
GenericContainer | |
window |
Window | |
image, svg, qr_code |
Image | |
canvas |
Canvas | |
table |
Table | |
markdown |
Document |
Labels
Labels are extracted from the prop that makes sense for each widget type:
| Widget type | Label source |
|---|---|
button, checkbox, toggler, radio |
label prop |
text, rich_text |
content prop |
image, svg |
alt prop |
text_input |
placeholder prop (as description) |
State
Widget state is extracted from existing props automatically:
| State | Source | Widgets |
|---|---|---|
| Disabled | disabled: true |
Any widget |
| Toggled | checked prop |
checkbox |
| Toggled | is_toggled prop |
toggler |
| Numeric value | value prop |
slider, progress_bar |
The a11y prop
Every widget accepts an a11y prop -- a hash of fields that override or
augment the inferred semantics.
Fields
| Field | Type | Description |
|---|---|---|
role |
Symbol | Override the inferred role |
label |
String | Accessible name |
description |
String | Longer description |
live |
:off, :polite, :assertive |
Live region |
hidden |
Boolean | Exclude from accessibility tree |
expanded |
Boolean | Expanded/collapsed state |
required |
Boolean | Mark form field as required |
level |
Integer | Heading level (1-6) |
busy |
Boolean | Loading/processing state |
invalid |
Boolean | Form validation failure |
modal |
Boolean | Dialog is modal |
read_only |
Boolean | Can be read but not edited |
mnemonic |
String | Alt+letter keyboard shortcut |
toggled |
Boolean | Toggled/checked state |
selected |
Boolean | Selected state |
value |
String | Current value as string |
orientation |
:horizontal, :vertical |
Orientation hint |
labelled_by |
String | ID of labelling widget |
described_by |
String | ID of describing widget |
error_message |
String | ID of error message widget |
disabled |
Boolean | Override disabled state for AT |
position_in_set |
Integer | 1-based position in a set |
size_of_set |
Integer | Total items in the set |
has_popup |
String | Popup type: "listbox", "menu", "dialog" |
Using the a11y prop
# Headings
text("title", "Welcome to MyApp", a11y: {role: :heading, level: 1})
# Icon buttons that need a label for screen readers
("close", "X", a11y: {label: "Close dialog"})
# Landmark regions
container("search_results", a11y: {role: :region, label: "Search results"}) do
# ...
end
# Live regions -- AT announces changes automatically
text("save_status", "#{model.saved_count} items saved", a11y: {live: :polite})
# Decorative elements hidden from AT
rule(a11y: {hidden: true})
image("divider", "/images/decorative-line.png", a11y: {hidden: true})
# Disclosure / expandable sections
container("details", a11y: {expanded: model., role: :group, label: "Advanced options"}) do
if model.
# ...
end
end
# Required form fields
text_input("email", model.email, a11y: {required: true, label: "Email address"})
Available roles
Interactive:
:button, :checkbox, :combo_box, :link, :menu_item, :radio,
:slider, :switch, :tab, :text_input, :text_editor, :tree_item
Structure:
:generic_container, :group, :heading, :label, :list, :list_item,
:row, :cell, :column_header, :row_header, :table, :tree
Landmarks:
:navigation, :region, :search
Status:
:alert, :alert_dialog, :dialog, :status, :timer, :meter,
:progress_indicator
Other:
:document, :image, :menu, :menu_bar, :scroll_view, :separator,
:tab_list, :tab_panel, :toolbar, :tooltip, :window
Patterns and best practices
Every interactive widget needs a name
# Good -- label is auto-inferred
("save", "Save document")
# Good -- explicit a11y label for terse visual text
("close", "X", a11y: {label: "Close dialog"})
# Bad -- screen reader just announces "button"
("do_thing", "")
Use headings to create structure
def view(model)
window("main", title: "MyApp") do
column do
text("page_title", "Dashboard", a11y: {role: :heading, level: 1})
text("h_recent", "Recent activity", a11y: {role: :heading, level: 2})
# ... activity list ...
text("h_actions", "Quick actions", a11y: {role: :heading, level: 2})
# ... action buttons ...
end
end
end
Use landmarks for page regions
column do
container("nav", a11y: {role: :navigation, label: "Main navigation"}) do
row do
("home", "Home")
("settings", "Settings")
end
end
container("main_content", a11y: {role: :region, label: "Main content"}) do
# ...
end
end
Live regions for dynamic content
:polite-- announced after the current speech finishes:assertive-- interrupts current speech
text("status", model., a11y: {live: :polite})
if model.error
text("error", model.error, a11y: {live: :assertive, role: :alert})
end
Forms
column(spacing: 12) do
column(spacing: 4) do
text("email-label", "Email")
text("email-help", "We'll send a confirmation link")
text_input("email", model.email,
a11y: {
labelled_by: "email-label",
described_by: "email-help",
error_message: "email-error"
})
if model.email_error
text("email-error", model.email_error,
a11y: {role: :alert, live: :assertive})
end
end
end
Hiding decorative content
rule(a11y: {hidden: true})
image("hero", "/images/banner.png", a11y: {hidden: true})
space(a11y: {hidden: true})
Canvas widgets
Canvas draws arbitrary shapes -- always provide alternative text:
canvas("chart", layers: {"data" => chart_shapes},
a11y: {role: :image, label: "Sales chart: Q1 revenue up 15%, Q2 flat"})
Interactive canvas shapes
When a canvas contains shapes with the interactive field, each shape
becomes a separate accessible node. The canvas widget itself is the
container; individual shapes are focusable children. Tab and Arrow keys
navigate between shapes. Enter/Space activates the focused shape.
This is how you build accessible custom widgets from canvas primitives. Without interactive shapes, a canvas is a single opaque "image" node to screen readers.
canvas("color-picker", width: 200, height: 100,
layers: {"options" => colors.each_with_index.map { |color, i|
Plushie::Canvas::Shape.rect(0, i * 32, 200, 32, fill: color.hex)
.interactive(
id: "color-#{i}",
on_click: true,
a11y: {
role: :radio,
label: color.name,
selected: color == model.selected,
position_in_set: i + 1,
size_of_set: colors.length
})
}})
Screen reader: "Red, radio button, 1 of 5, selected."
The position_in_set and size_of_set fields tell screen readers
where each shape sits in the group. Without them, the reader announces
each shape individually with no positional context.
Custom widgets with state
When building custom widgets with canvas or other primitives, use toggled,
selected, value, and orientation to expose their state to AT users.
Without these, screen readers have no way to know the state of a custom
control drawn with raw shapes.
# Custom toggle switch built with canvas
canvas("dark-mode-switch", layers: [...],
a11y: {
role: :switch,
label: "Dark mode",
toggled: model.dark_mode
})
# Custom gauge showing percentage
canvas("cpu-gauge", layers: [...],
a11y: {
role: :meter,
label: "CPU usage",
value: "#{model.cpu_percent}%",
orientation: :horizontal
})
toggled and selected are booleans. Use toggled for on/off controls
(switches, checkboxes) and selected for selection state (list items, tabs).
value is a string describing the current value in human-readable form.
orientation tells AT users whether a control is horizontal or vertical,
which affects how they navigate it.
Set position and popup hints
Use position_in_set / size_of_set when building composite widgets
from primitives (custom lists, tab bars, radio groups). Without these,
screen readers cannot announce position context like "Item 3 of 7".
# Radio group with position context
container("colors", a11y: {role: :group, label: "Favorite color"}) do
colors.each_with_index do |color, idx|
radio("color_#{color}", color, model.selected_color,
a11y: {
position_in_set: idx + 1,
size_of_set: colors.length
})
end
end
# Custom tab bar
row do
model.tabs.each_with_index do |tab, idx|
("tab_#{tab.id}", tab.label,
a11y: {
role: :tab,
selected: tab.id == model.active_tab,
position_in_set: idx + 1,
size_of_set: model.tabs.length
})
end
end
Use has_popup to tell screen readers that activating a widget opens
a popup of a specific type:
# Dropdown button
("menu_btn", "Options",
a11y: {has_popup: "menu", expanded: model.})
# Combo box with listbox popup
text_input("search", model.query,
a11y: {has_popup: "listbox", expanded: model.suggestions_visible})
Use disabled to override the disabled state for AT when a widget
is visually disabled via custom styling but doesn't use the standard
disabled prop:
("submit", "Submit",
a11y: {disabled: !model.form_valid})
Expanded/collapsed state
For disclosure widgets, toggleable panels, and dropdown menus:
def view(model)
column do
("toggle_details",
model.show_details ? "Hide details" : "Show details",
a11y: {expanded: model.show_details})
if model.show_details
container("details", a11y: {role: :region, label: "Details"}) do
# detail content
end
end
end
end
The expanded field tells AT whether the control is currently
expanded or collapsed, so screen readers can announce "Show details,
button, collapsed" or "Hide details, button, expanded".
Widget-specific accessibility props
Some widgets accept accessibility props directly as top-level fields,
outside the a11y hash. The Rust renderer reads these and maps them
to the appropriate accesskit node properties. They are simpler to use
than the full a11y hash for common cases.
alt
An accessible label string for visual content widgets where the content itself is not textual.
| Widget | Prop | Type |
|---|---|---|
image |
alt |
String |
svg |
alt |
String |
qr_code |
alt |
String |
canvas |
alt |
String |
image("logo", "/images/logo.png", alt: "Company logo")
svg("icon", "/icons/search.svg", alt: "Search")
qr_code("invite", invite_url, alt: "QR code for invite link")
canvas("chart", layers: layers, alt: "Revenue chart")
label
An accessible label string for interactive widgets that don't have a visible text label prop.
| Widget | Prop | Type |
|---|---|---|
slider |
label |
String |
vertical_slider |
label |
String |
progress_bar |
label |
String |
("volume", [0, 100], model.volume, label: "Volume")
("upload", [0, 100], model.progress, label: "Upload progress")
decorative
A boolean that hides visual content from assistive technology entirely. Use this for images and SVGs that are purely decorative and convey no information.
| Widget | Prop | Type |
|---|---|---|
image |
decorative |
Boolean |
svg |
decorative |
Boolean |
image("divider", "/images/decorative-line.png", decorative: true)
svg("flourish", "/icons/flourish.svg", decorative: true)
Testing accessibility
def test_heading_has_correct_role
assert_role("#page_title", "heading")
end
def test_email_field_is_required
assert_a11y("#email", {"required" => true, "label" => "Email address"})
end
Platform support
| Platform | AT | API | Status |
|---|---|---|---|
| Linux | Orca | AT-SPI2 | Supported |
| macOS | VoiceOver | NSAccessibility | Supported |
| Windows | NVDA, JAWS, Narrator | UI Automation | Supported |