Plushie integrates with platform accessibility services via AccessKit: VoiceOver on macOS, AT-SPI/Orca on Linux, UI Automation/NVDA/JAWS on Windows. Most accessibility semantics are inferred automatically from widget types, so you get correct roles, labels, and state without extra work.
This reference covers how auto-inference works, when to use the a11y
prop for explicit overrides, keyboard navigation, live regions, common
patterns, and testing.
Accessible by default
Plushie's vendored Iced fork includes extensive accessibility and keyboard navigation support built on top of Iced's rendering architecture. Built-in widgets expose accessibility metadata automatically: a button announces itself as a button, a checkbox tracks its checked state, a slider exposes its numeric value and range. Application code does not need to add accessibility attributes for standard widgets.
When you do need overrides (custom canvas controls, widgets with
context-dependent labels, relationship annotations), the a11y prop
is available on every widget.
Auto-inference
Role mapping
| Widget type | Inferred role |
|---|---|
button | button |
text, rich_text | label |
text_input | text_input |
text_editor | multiline_text_input |
checkbox | check_box |
toggler | switch |
radio | radio_button |
slider, vertical_slider | slider |
pick_list, combo_box | combo_box |
progress_bar | progress_indicator |
scrollable | scroll_view |
image, svg, qr_code | image |
canvas | canvas |
table | table |
pane_grid | group |
rule | splitter |
window | window |
markdown | document |
tooltip | tooltip |
| Containers (column, row, etc.) | generic_container |
Layout containers use generic_container, which is filtered from the
platform accessibility tree automatically. Screen reader users navigate
through the semantic content (buttons, text, inputs) without encountering
intermediate layout wrappers.
Label inference
| Widget type | Prop used as label |
|---|---|
button, checkbox, toggler, radio | label prop |
text, rich_text | content prop |
image, svg | alt prop |
text_input | placeholder prop (as description) |
State inference
| State | Source |
|---|---|
| Disabled | disabled: true on any widget |
| Toggled | checked (checkbox), is_toggled (toggler) |
| Numeric value | value (slider, progress_bar) |
| Range | range: [min, max] (slider, progress_bar) |
| String value | value (text_input) |
| Selected | selected (pick_list) |
The a11y prop
Every widget accepts an a11y: prop for explicit overrides. Pass a map
or keyword list:
button("save", "Save", a11y: %{description: "Save the current document"})
text_input("email", model.email,
a11y: %{required: true, labelled_by: "email-label"}
)See Plushie.Type.A11y for the full struct definition.
Fields
| Field | Type | Purpose |
|---|---|---|
role | atom | Override the inferred role |
label | string | Accessible name (override inferred label) |
description | string | Longer description read after the label |
live | :polite | :assertive | Live region announcement mode |
hidden | boolean | Exclude from accessibility tree |
expanded | boolean | Disclosure state (combobox, menu) |
required | boolean | Form field is required |
level | 1-6 | Heading level |
busy | boolean | Suppress announcements during updates |
invalid | boolean | Form validation error state |
modal | boolean | Dialog is modal (traps focus) |
read_only | boolean | Value is readable but not editable |
toggled | boolean | Toggle/checked state |
selected | boolean | Selection state |
value | string | Current value for assistive technology |
orientation | :horizontal | :vertical | Layout orientation hint |
disabled | boolean | Disabled state override |
mnemonic | string | Keyboard mnemonic (single character) |
position_in_set | integer | 1-based position in a group |
size_of_set | integer | Total items in the group |
has_popup | string | Popup type: "listbox", "menu", "dialog", "tree", "grid" |
Cross-references
| Field | Purpose |
|---|---|
labelled_by | ID of the widget that provides this widget's label |
described_by | ID of the widget that provides a description |
error_message | ID of the widget showing the error message |
Cross-reference IDs are resolved relative to the current scope during
tree normalisation. A bare ID like "label" inside scope "form"
resolves to "form/label". See Scoped IDs.
Roles
Roles are organised into categories:
Interactive: button, check_box, combo_box, link,
menu_item, radio_button, slider, switch, tab, text_input,
multiline_text_input, tree_item
Structure: generic_container, group, heading, label, list,
list_item, column_header, table_row, table_cell, table, tree
Landmarks: navigation, region, search
Status: alert, alert_dialog, dialog, status, meter,
progress_indicator
Other: document, image, canvas, menu, menu_bar,
scroll_view, separator, tab_list, tab_panel, toolbar,
tooltip, window
Aliases (normalised to canonical): :cell -> :table_cell,
:checkbox -> :check_box, :container / :generic ->
:generic_container, :progress_bar -> :progress_indicator,
:radio -> :radio_button, :row -> :table_row,
:text_editor -> :multiline_text_input
Accessible name computation
When a screen reader encounters a widget, it announces the widget's accessible name. Getting this right is the most common accessibility concern. The name is determined in this order:
- Direct label - if the
a11y: %{label: "..."}prop or the widget's inferred label is set, that's the name. - Labelled-by - if no direct label, the framework checks
labelled_by. For roles that support name-from-contents (button, checkbox, radio, link), descendant text content is used automatically. - No name - the screen reader announces only the role.
If a widget has no accessible name, screen readers say things like
"button" with no context. Always ensure interactive widgets have either
a label prop or a labelled_by reference.
Keyboard navigation
Plushie has built-in keyboard navigation:
| Key | Behaviour |
|---|---|
| Tab / Shift+Tab | Cycle focus through focusable widgets |
| Space / Enter | Activate the focused widget |
| Arrow keys | Navigate within sliders, lists, etc. |
| F6 / Shift+F6 | Cycle focus between pane_grid panes |
| Ctrl+Tab | Escape the current focus scope |
| Escape | Close popups, dismiss modals |
Focus follows the focus-visible pattern: focus rings appear on keyboard navigation but not on mouse clicks.
Canvas keyboard navigation
Canvas interactive groups can opt into keyboard focus with
focusable: true:
group "save-btn", on_click: true, focusable: true,
a11y: %{role: :button, label: "Save"} do
rect(0, 0, 100, 36, fill: "#3b82f6")
endfocusable: true adds the group to the Tab order. Space/Enter activates
it. Without focusable: true, the group responds to mouse clicks but
is invisible to keyboard navigation and screen readers.
Live regions
The live: field controls how screen readers announce dynamic content
changes. Use it on widgets whose content updates while visible:
| Value | Behaviour | Use for |
|---|---|---|
:polite | Announced after current speech finishes | Status messages, counters, progress updates |
:assertive | Interrupts current speech immediately | Error messages, critical alerts |
text("status", model.status_message, a11y: %{live: :polite})
text("error", model.error, a11y: %{live: :assertive, role: :alert})Use :assertive sparingly. Rapid updates cause announcement storms.
Prefer :polite for anything that updates more than once per user
action.
Do not set live: on static content. The screen reader re-announces
it on every tree rebuild even when the content hasn't changed.
Disabled vs read-only
These are semantically different:
| State | Meaning | Screen reader behaviour |
|---|---|---|
| Disabled | Not currently usable | Often skipped in Tab navigation, announced as "dimmed" or "unavailable" |
| Read-only | Has a value that can be read but not changed | Fully navigable and announced, editing commands blocked |
Use disabled: true for controls that become active based on other
state (e.g. a Submit button disabled until required fields are filled).
Use read_only: true for displaying values the user can select/copy
but not edit.
Common patterns
Form field labelling
Every form control needs an accessible name. Three approaches:
Direct label (simplest):
text_input("email", model.email,
placeholder: "Email address",
a11y: %{label: "Email address"}
)Cross-widget labelled_by:
text("email-label", "Email address")
text_input("email", model.email,
a11y: %{labelled_by: "email-label"}
)Description for additional context:
text_input("password", model.password,
a11y: %{label: "Password", described_by: "password-hint"}
)
text("password-hint", "Must be at least 8 characters", size: 11)Grouping related controls
Use the :group role when controls are logically related and the
grouping helps the user understand context:
container "shipping-options",
a11y: %{role: :group, label: "Shipping options"} do
radio("standard", :standard, model.shipping, label: "Standard (5-7 days)")
radio("express", :express, model.shipping, label: "Express (1-2 days)")
endDo not wrap things in groups unless the grouping adds semantic value.
Layout containers (column, row) already use generic_container
and are invisible to screen readers.
Canvas accessibility
Canvas is a raw drawing surface. The renderer has no way to know that a group of shapes is meant to be a "button." You must provide explicit accessibility annotations:
group "save-btn",
on_click: true,
cursor: :pointer,
focusable: true,
a11y: %{role: :button, label: "Save experiment"} do
rect(0, 0, 100, 36, fill: "#3b82f6")
text(50, 11, "Save", fill: "#fff", size: 14)
endWithout a11y annotations, canvas elements are invisible to screen
readers and keyboard navigation.
Platform notes
| Platform | AT service | Integration |
|---|---|---|
| macOS | VoiceOver | Via AccessKit -> NSAccessibility |
| Linux | Orca (AT-SPI) | Via AccessKit -> AT-SPI2 |
| Windows | NVDA / JAWS | Via AccessKit -> UI Automation |
Assistive technology actions (e.g. VoiceOver "activate") produce the
same WidgetEvent as direct interaction. No special handling needed in
update/2.
Screen reader differences
NVDA/JAWS (Windows) operate in two modes: browse mode (screen reader intercepts keys for virtual navigation) and focus mode (keys pass to the app). They auto-switch to focus mode when Tab reaches an interactive control.
VoiceOver (macOS) uses a rotor for category-based navigation (headings, buttons, form fields). Correct roles ensure widgets appear in the right rotor categories.
Orca (Linux) provides structural navigation similar to NVDA's browse mode. Known caveat: Wayland keyboard input is currently broken for screen readers, so Linux screen reader users need X11.
Testing accessibility
# Assert role
assert_role("#save", "button")
# Assert accessibility properties
assert_a11y("#email", %{required: true, invalid: false})
# Find by accessibility attributes
find_by_role(:button)
find_by_label("Save")These assertions verify the accessibility tree, not just the visual output. They catch missing labels, wrong roles, and missing state annotations.
See the Testing reference for the full assertion API.
See also
Plushie.Type.A11y- full struct and field documentation- Canvas reference - canvas accessibility annotations and interactive groups
- Scoped IDs reference - how
labelled_byanddescribed_byIDs are resolved - AccessKit - the cross-platform accessibility library Plushie uses
- WAI-ARIA Authoring Practices - W3C patterns for accessible widget design
- WCAG 2.1 - Web Content Accessibility Guidelines (the standard Plushie targets)