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 WidgetClick(id: id, ..), WidgetInput(id: id, value: val, ..), etc. events as direct interaction.

How it works

Iced’s fork (v0.14.0-a11y-accesskit branch) provides native accessibility support. Three pieces work together:

  1. iced widgets report Accessible metadata – each widget implements the Accessible trait via iced’s operate() mechanism. Widgets declare their role, label, and state to the accessibility system automatically.

  2. TreeBuilder assembles the accesskit treeiced_winit::a11y contains a TreeBuilder that walks the widget tree during operate(), collecting Accessible metadata and building an accesskit TreeUpdate. This happens natively inside iced – plushie does not build the tree.

  3. AT actions become native iced events – when an AT triggers an action (e.g. a screen reader user activates a button), iced translates it to a native event. The renderer maps it to a standard plushie event and sends it to Gleam over the wire protocol.

Host (Gleam)              Renderer (iced)               Platform AT
   |                         |                              |
   |--- UI tree (a11y) ----->|                              |
   |                         |-- operate() + TreeBuilder -->|
   |                         |-- TreeUpdate --------------->|
   |                         |                              |
   |                         |<-- AT Action (Click) --------|
   |                         |   (native iced event)        |
   |<-- WidgetClick ---------|                              |

plushie’s role

plushie does not build its own accesskit tree. Iced handles tree building, AT actions, and platform integration natively. plushie’s contribution is the A11yOverride wrapper widget (a11y_widget.rs in plushie) that intercepts operate() to apply Gleam-side overrides from the a11y prop.

This means:

Accessibility is compiled unconditionally – there are no feature flags to toggle it.

Auto-inference

Most widgets get correct accessibility semantics without any annotation. Iced automatically reports roles, labels, and state from widget types and existing props via the Accessible trait.

Role mapping

Every widget type maps to an accesskit role:

Widget typeRoleNotes
buttonButton
text, rich_textLabel
text_inputTextInput
text_editorMultilineTextInput
checkboxCheckBox
togglerSwitch
radioRadioButton
slider, vertical_sliderSlider
pick_list, combo_boxComboBox
progress_barProgressIndicator
scrollableScrollView
container, column, row, stackGenericContainerAlso: keyed_column, grid, float, pin, responsive, space, themer, mouse_area, sensor, overlay
windowWindow
image, svg, qr_codeImage
canvasCanvas
tableTable
tooltipTooltip
markdownDocument
pane_gridGroup
ruleSplitter

Labels

Labels are the accessible name announced by screen readers. They are extracted from the prop that makes sense for each widget type:

Widget typeLabel source
button, checkbox, toggler, radiolabel prop
text, rich_textcontent prop
image, svgalt prop
text_inputplaceholder prop (as description, not label)

If a widget has no auto-inferred label and no a11y label override, the screen reader sees the role with no name. This is fine for structural containers but not for interactive widgets – always give buttons, inputs, and toggles either a visible label or an a11y label.

State

Widget state is extracted from existing props automatically:

StateSourceWidgets
Disableddisabled: TrueAny widget
Toggledchecked propcheckbox
Toggledis_toggled proptoggler
Toggledselected prop (boolean)radio
Numeric valuevalue prop (number)slider, progress_bar
Min/maxrange prop ([min, max])slider, progress_bar
String valuevalue prop (string)text_input
Selected itemselected prop (string)pick_list

The a11y prop

For cases where auto-inference is insufficient, every widget accepts an a11y prop – built with the plushie/prop/a11y module’s builder functions.

Fields

FieldBuilder functionDescription
rolea11y.role(r)Override the inferred role (see available roles)
labela11y.label(s)Accessible name (what the screen reader announces)
descriptiona11y.description(s)Longer description (secondary announcement)
livea11y.live(s)Live region – AT announces content changes ("off", "polite", "assertive")
hiddena11y.hidden(b)Exclude from accessibility tree entirely
expandeda11y.expanded(b)Expanded/collapsed state (menus, disclosures)
requireda11y.required(b)Mark form field as required
levela11y.level(n)Heading level (1-6, only meaningful with Heading role)
busya11y.busy(b)Loading/processing state (AT announces when done)
invalida11y.invalid(b)Form validation failure
modala11y.modal(b)Dialog is modal (AT restricts navigation to this container)
read_onlya11y.read_only(b)Can be read but not edited
mnemonica11y.mnemonic(s)Alt+letter keyboard shortcut (single character)
toggleda11y.toggled(b)Toggled/checked state (for custom toggle widgets)
selecteda11y.selected(b)Selected state (for custom selectable widgets)
valuea11y.value(s)Current value as a string (for custom value-displaying widgets)
orientationa11y.orientation(o)Orientation hint for AT navigation (Horizontal or Vertical)
labelled_bya11y.labelled_by(id)ID of the widget that labels this one
described_bya11y.described_by(id)ID of the widget that describes this one
error_messagea11y.error_message(id)ID of the widget showing the error message
disableda11y.disabled(b)Override disabled state for AT
position_in_seta11y.position_in_set(n)1-based position in a set (“Item 3 of 7”)
size_of_seta11y.size_of_set(n)Total items in the set
has_popupa11y.has_popup(p)Popup type: ListboxPopup, MenuPopup, DialogPopup, TreePopup, GridPopup

The type is defined in plushie/prop/a11y. All fields are optional – start with a11y.new() and pipe through only the setters you need.

Using the a11y prop

With plushie/ui (convenience builder):

import plushie/ui
import plushie/prop/a11y
import plushie/prop/padding

// Headings
ui.text("title", "Welcome to MyApp", [
  ui.a11y(a11y.new() |> a11y.role(a11y.Heading) |> a11y.level(1)),
])
ui.text("settings_heading", "Settings", [
  ui.a11y(a11y.new() |> a11y.role(a11y.Heading) |> a11y.level(2)),
])

// Icon buttons that need a label for screen readers
ui.button("close", "X", [
  ui.a11y(a11y.new() |> a11y.label("Close dialog")),
])

// Landmark regions
ui.container("search_results", [
  ui.a11y(a11y.new() |> a11y.role(a11y.Region) |> a11y.label("Search results")),
], [
  // ...
])

// Live regions -- AT announces changes automatically
ui.text("save_status", int.to_string(model.saved_count) <> " items saved", [
  ui.a11y(a11y.new() |> a11y.live("polite")),
])

// Decorative elements hidden from AT
ui.rule("divider", [ui.a11y(a11y.new() |> a11y.hidden(True))])
ui.image("divider", "/images/decorative-line.png", [
  ui.a11y(a11y.new() |> a11y.hidden(True)),
])

// Disclosure / expandable sections
ui.container("details", [
  ui.a11y(
    a11y.new()
    |> a11y.expanded(model.expanded)
    |> a11y.role(a11y.Group)
    |> a11y.label("Advanced options"),
  ),
], case model.expanded {
  True -> [/* ... */]
  False -> []
})

// Required form fields
ui.text_input("email", model.email, [
  ui.a11y(a11y.new() |> a11y.required(True) |> a11y.label("Email address")),
])

With the typed widget builder API (plushie/widget/*):

import plushie/widget/button
import plushie/widget/text
import plushie/widget/text_input
import plushie/prop/a11y

button.new("close", "X")
|> button.a11y(a11y.new() |> a11y.label("Close dialog"))
|> button.build()

text.new("title", "Welcome")
|> text.a11y(a11y.new() |> a11y.role(a11y.Heading) |> a11y.level(1))
|> text.build()

text_input.new("email", model.email)
|> text_input.a11y(a11y.new() |> a11y.required(True) |> a11y.label("Email address"))
|> text_input.build()

Available roles

The role function accepts a Role constructor from plushie/prop/a11y. Use them to override the auto-inferred role when a widget is semantically different from its type (e.g. a text that’s actually a heading, or a container that’s a navigation landmark).

Interactive: Button, CheckBox, ComboBox, Link, MenuItem, RadioButton, Slider, Switch, Tab, TextInput, MultilineTextInput, TreeItem

Structure: Group, Heading, Label, List, ListItem, Row, Cell, ColumnHeader, Table, Tree

Landmarks: Navigation, Region, Search

Status: Alert, AlertDialog, Dialog, Status, Meter, ProgressIndicator

Other: Document, Image, Menu, MenuBar, ScrollView, Separator, TabList, TabPanel, Toolbar, Tooltip, Window

Unknown role values are accepted but mapped to Unknown.

Patterns and best practices

Every interactive widget needs a name

Screen readers announce a widget’s role and its label. A button with no label is announced as just “button” – useless. Make sure every button, input, checkbox, and toggle has either:

// Good -- label is auto-inferred from the button's label prop
ui.button_("save", "Save document")

// Good -- terse label with explicit a11y override for clarity
ui.button("close", "X", [
  ui.a11y(a11y.new() |> a11y.label("Close dialog")),
])

// Bad -- screen reader just announces "button" with no name
ui.button_("do_thing", "")

Use headings to create structure

Screen reader users navigate by headings. Use the a11y prop to mark section titles:

fn view(model: Model) -> Node {
  ui.window("main", [ui.title("MyApp")], [
    ui.column("content", [], [
      ui.text("page_title", "Dashboard", [
        ui.a11y(a11y.new() |> a11y.role(a11y.Heading) |> a11y.level(1)),
      ]),

      ui.text("h_recent", "Recent activity", [
        ui.a11y(a11y.new() |> a11y.role(a11y.Heading) |> a11y.level(2)),
      ]),
      // ... activity list ...

      ui.text("h_actions", "Quick actions", [
        ui.a11y(a11y.new() |> a11y.role(a11y.Heading) |> a11y.level(2)),
      ]),
      // ... action buttons ...
    ]),
  ])
}

Use landmarks for page regions

Landmarks let screen reader users jump between major sections. Wrap significant regions in containers with landmark roles:

ui.column("layout", [], [
  ui.container("nav", [
    ui.a11y(a11y.new() |> a11y.role(a11y.Navigation) |> a11y.label("Main navigation")),
  ], [
    ui.row("nav_buttons", [], [
      ui.button_("home", "Home"),
      ui.button_("settings", "Settings"),
      ui.button_("help", "Help"),
    ]),
  ]),

  ui.container("main_content", [
    ui.a11y(a11y.new() |> a11y.role(a11y.Region) |> a11y.label("Main content")),
  ], [
    // ...
  ]),

  ui.container("search_area", [
    ui.a11y(a11y.new() |> a11y.role(a11y.Search) |> a11y.label("Search")),
  ], [
    ui.text_input("query", model.query, [ui.placeholder("Search...")]),
    ui.button_("go", "Search"),
  ]),
])

Live regions for dynamic content

When content changes and you want the screen reader to announce it without the user navigating to it, use live regions:

// Status bar that announces changes
ui.text("status", model.status_message, [
  ui.a11y(a11y.new() |> a11y.live("polite")),
])

// Error message that interrupts (use list spreading for conditional nodes)
..case model.error {
  Some(err) -> [
    ui.text("error", err, [
      ui.a11y(a11y.new() |> a11y.live("assertive") |> a11y.role(a11y.Alert)),
    ]),
  ]
  None -> []
}

// Counter value announced on change
ui.text("counter", "Count: " <> int.to_string(model.count), [
  ui.a11y(a11y.new() |> a11y.live("polite")),
])

Tip: Only mark the element that changes as live, not its parent container. Marking a large container as live causes the entire container’s text to be re-announced on every change.

Forms

Label your inputs, mark required fields, and provide clear error feedback:

ui.column("form", [ui.spacing(12)], [
  ui.text("form_heading", "Create account", [
    ui.a11y(a11y.new() |> a11y.role(a11y.Heading) |> a11y.level(1)),
  ]),

  ui.column("username_group", [ui.spacing(4)], [
    ui.text_("username_label", "Username"),
    ui.text_input("username", model.username, [
      ui.a11y(a11y.new() |> a11y.required(True) |> a11y.label("Username")),
    ]),
  ]),

  ui.column("email_group", [ui.spacing(4)], [
    ui.text_("email_label", "Email"),
    ui.text_input("email", model.email, [
      ui.a11y(a11y.new() |> a11y.required(True) |> a11y.label("Email address")),
    ]),
    ..case model.email_error {
      Some(err) -> [
        ui.text("email_error", err, [
          ui.a11y(a11y.new() |> a11y.live("assertive") |> a11y.role(a11y.Alert)),
        ]),
      ]
      None -> []
    }
  ]),

  ui.button_("submit", "Create account"),
])

Why the explicit a11y.label("Username") when there’s a visible text_("username_label", "Username") above? Because plushie doesn’t automatically associate a text label with the input below it. The visible text and the input are separate widgets in the tree. The a11y label connects them for AT users.

Cross-widget relationships

Instead of duplicating label text in the a11y prop, you can point to another widget by ID using labelled_by, described_by, and error_message. The renderer resolves these to accesskit node references so the screen reader follows the relationship automatically.

ui.column("form", [ui.spacing(12)], [
  ui.text("form_heading", "Create account", [
    ui.a11y(a11y.new() |> a11y.role(a11y.Heading) |> a11y.level(1)),
  ]),

  ui.column("email_group", [ui.spacing(4)], [
    ui.text_("email-label", "Email"),
    ui.text_("email-help", "We'll send a confirmation link"),
    ui.text_input("email", model.email, [
      ui.a11y(
        a11y.new()
        |> a11y.labelled_by("email-label")
        |> a11y.described_by("email-help")
        |> a11y.error_message("email-error"),
      ),
    ]),
    ..case model.email_error {
      Some(err) -> [
        ui.text("email-error", err, [
          ui.a11y(a11y.new() |> a11y.role(a11y.Alert) |> a11y.live("assertive")),
        ]),
      ]
      None -> []
    }
  ]),

  ui.button_("submit", "Create account"),
])

When the user focuses the email input, the screen reader announces the label text from the email-label widget and the description from email-help. If the field is invalid, it also announces the error text from email-error.

Use labelled_by instead of label when a visible text widget already provides the label – it avoids duplicating the string and keeps the label in sync if you change the visible text.

Hiding decorative content

Decorative elements that add no information should be hidden from AT:

// Decorative dividers
ui.rule("divider", [ui.a11y(a11y.new() |> a11y.hidden(True))])

// Decorative images
ui.image("hero", "/images/banner.png", [
  ui.a11y(a11y.new() |> a11y.hidden(True)),
])

// Spacing elements
ui.space("gap", [ui.a11y(a11y.new() |> a11y.hidden(True))])

Don’t hide functional elements. If an image conveys information, give it an alt prop instead:

ui.image("status_icon", icon_path, [ui.alt("Status: online")])

Canvas widgets

Canvas draws arbitrary shapes – accesskit can’t infer anything from raw geometry. Always provide alternative text:

import plushie/widget/canvas
import plushie/prop/length.{Fill}

// Static chart -- describe the content
canvas.new("chart", Fill, Fill)
|> canvas.layer("data", chart_shapes)
|> canvas.a11y(
  a11y.new()
  |> a11y.role(a11y.Image)
  |> a11y.label("Sales chart: Q1 revenue up 15%, Q2 flat"),
)
|> canvas.build()

// Interactive canvas -- describe the interaction model
canvas.new("drawing", Fill, Fill)
|> canvas.layer("shapes", shapes)
|> canvas.a11y(
  a11y.new()
  |> a11y.role(a11y.Image)
  |> a11y.label("Drawing canvas, " <> int.to_string(list.length(shapes)) <> " shapes"),
)
|> canvas.build()

For complex interactive canvases, consider whether the canvas is the right choice for AT users, or whether an alternative text-based representation would work better.

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.

import plushie/canvas/shape
import plushie/widget/canvas
import plushie/prop/length.{Px}

canvas.new("color-picker", Px(200.0), Px(100.0))
|> canvas.layer("options",
  list.index_map(colors, fn(color, i) {
    shape.rect(0.0, int.to_float(i * 32), 200.0, 32.0)
    |> shape.fill(color.hex)
    |> shape.interactive(
      "color-" <> int.to_string(i),
      [
        shape.on_click(True),
        shape.hover_style([shape.stroke("#000"), shape.stroke_width(2.0)]),
        shape.a11y(
          a11y.new()
          |> a11y.role(a11y.RadioButton)
          |> a11y.label(color.name)
          |> a11y.selected(color == model.selected)
          |> a11y.position_in_set(i + 1)
          |> a11y.size_of_set(list.length(colors)),
        ),
      ],
    )
  }),
)
|> canvas.build()

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.

import plushie/widget/canvas
import plushie/prop/length.{Px}

// Custom toggle switch built with canvas
canvas.new("dark-mode-switch", Px(60.0), Px(30.0))
|> canvas.layer("switch", switch_shapes)
|> canvas.a11y(
  a11y.new()
  |> a11y.role(a11y.Switch)
  |> a11y.label("Dark mode")
  |> a11y.toggled(model.dark_mode),
)
|> canvas.build()

// Custom gauge showing percentage
canvas.new("cpu-gauge", Px(200.0), Px(40.0))
|> canvas.layer("gauge", gauge_shapes)
|> canvas.a11y(
  a11y.new()
  |> a11y.role(a11y.Meter)
  |> a11y.label("CPU usage")
  |> a11y.value(int.to_string(model.cpu_percent) <> "%")
  |> a11y.orientation(a11y.Horizontal),
)
|> canvas.build()

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”.

import plushie/widget/radio

// Radio group with position context
ui.container("colors", [
  ui.a11y(a11y.new() |> a11y.role(a11y.Group) |> a11y.label("Favorite color")),
], list.index_map(colors, fn(color, idx) {
  radio.new("color_" <> color, color, model.selected_color)
  |> radio.a11y(
    a11y.new()
    |> a11y.position_in_set(idx + 1)
    |> a11y.size_of_set(list.length(colors)),
  )
  |> radio.build()
}))

// Custom tab bar
ui.row("tabs", [], list.index_map(model.tabs, fn(tab, idx) {
  ui.button("tab_" <> tab.id, tab.label, [
    ui.a11y(
      a11y.new()
      |> a11y.role(a11y.Tab)
      |> a11y.selected(tab.id == model.active_tab)
      |> a11y.position_in_set(idx + 1)
      |> a11y.size_of_set(list.length(model.tabs)),
    ),
  ])
}))

Use has_popup to tell screen readers that activating a widget opens a popup of a specific type:

// Dropdown button
ui.button("menu_btn", "Options", [
  ui.a11y(
    a11y.new()
    |> a11y.has_popup(a11y.MenuPopup)
    |> a11y.expanded(model.menu_open),
  ),
])

// Combo box with listbox popup
ui.text_input("search", model.query, [
  ui.a11y(
    a11y.new()
    |> a11y.has_popup(a11y.ListboxPopup)
    |> a11y.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:

ui.button("submit", "Submit", [
  ui.a11y(a11y.new() |> a11y.disabled(!model.form_valid)),
])

Expanded/collapsed state

For disclosure widgets, toggleable panels, and dropdown menus:

fn view(model: Model) -> Node {
  ui.column("layout", [], [
    ui.button(
      "toggle_details",
      case model.show_details {
        True -> "Hide details"
        False -> "Show details"
      },
      [ui.a11y(a11y.new() |> a11y.expanded(model.show_details))],
    ),

    ..case model.show_details {
      True -> [
        ui.container("details", [
          ui.a11y(a11y.new() |> a11y.role(a11y.Region) |> a11y.label("Details")),
        ], [
          // detail content
        ]),
      ]
      False -> []
    }
  ])
}

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 object. The Rust renderer reads these and maps them to the appropriate accesskit node properties. They are simpler to use than the full a11y builder for common cases.

alt

An accessible label string. Used on visual content widgets where the content itself is not textual. The renderer auto-populates the accesskit label from this prop.

WidgetPropType
imagealtString
svgaltString
qr_codealtString
canvasaltString
import plushie/widget/svg
import plushie/widget/qr_code

ui.image("logo", "/images/logo.png", [ui.alt("Company logo")])

svg.new("icon", "/icons/search.svg")
|> svg.alt("Search")
|> svg.build()

qr_code.new("invite", invite_url)
|> qr_code.alt("QR code for invite link")
|> qr_code.build()

ui.canvas("chart", [ui.alt("Revenue chart")])

label

An accessible label string for interactive widgets that don’t have a visible text label prop. The renderer auto-populates the accesskit label from this prop.

WidgetPropType
sliderlabelString
vertical_sliderlabelString
progress_barlabelString
ui.slider("volume", #(0.0, 100.0), model.volume, [ui.label("Volume")])
ui.vertical_slider("brightness", #(0.0, 100.0), model.brightness, [ui.label("Brightness")])
ui.progress_bar("upload", #(0.0, 100.0), model.progress, [ui.label("Upload progress")])

description

An extended accessible description string. Announced as secondary information after the label. Useful for providing additional context that doesn’t fit in a short label.

WidgetPropType
imagedescriptionString
svgdescriptionString
qr_codedescriptionString
canvasdescriptionString
ui.image("photo", path, [ui.alt("Team photo"), ui.description("The engineering team at the 2025 offsite")])
ui.canvas("chart", [ui.alt("Sales chart"), ui.description("Q1 up 15%, Q2 flat, Q3 down 8%")])

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. This is a shorthand – the equivalent using the a11y builder would be a11y.new() |> a11y.hidden(True).

WidgetPropType
imagedecorativeBool
svgdecorativeBool
ui.image("divider", "/images/decorative-line.png", [ui.decorative(True)])
svg.new("flourish", "/icons/flourish.svg")
|> svg.decorative(True)
|> svg.build()

Relationship to the a11y prop

These widget-specific props and the a11y prop are complementary. The widget-specific props are read directly by the Rust renderer as top-level node properties. The a11y prop provides the full set of accesskit overrides via the A11yOverride wrapper widget.

If both are set (e.g. alt("Photo") and a11y.new() |> a11y.label("Team photo")), the a11y override takes precedence for the accesskit label since A11yOverride runs after the widget’s own Accessible implementation.

Action handling

When an AT triggers an action, iced translates it to a native event. The renderer maps it to a standard plushie event:

AT actionPlushie eventNotes
ClickWidgetClick(id: id, ..)Screen reader activate, switch press
SetValueWidgetInput(id: id, value: val, ..)AT sets an input value directly
Focus(internal)Focus tracking, no event emitted
OtherA11yAction(id: id, action: name)Scroll, dismiss, etc.

Your update already handles WidgetClick and WidgetInput – AT actions produce identical events. The A11yAction event is a catch-all for actions without a direct widget equivalent:

fn update(model: Model, event: Event) {
  case event {
    A11yAction(action: "scroll_down", ..) -> scroll(model, Down)
    A11yAction(action: "dismiss", ..) -> close_dialog(model)
    A11yAction(..) -> #(model, command.none())
    // ...
  }
}

Testing accessibility

The test framework provides assertions for verifying accessibility semantics without running a screen reader.

assert_role

Checks the inferred role for an element. This mirrors the role mapping, so it catches mismatches between your widget type and the intended role:

import plushie/test

pub fn heading_has_correct_role_test() {
  let session = test.start(my_app())
  test.assert_role(session, "#page_title", "heading")
}

pub fn nav_container_is_navigation_landmark_test() {
  let session = test.start(my_app())
  test.assert_role(session, "#nav", "navigation")
}

assert_role accounts for a11y role overrides – if the element has an a11y role set (e.g. Heading), that takes precedence over the widget type.

assert_a11y

Checks specific fields in the a11y prop:

pub fn email_field_is_required_and_labelled_test() {
  let session = test.start(my_app())
  test.assert_a11y(session, "#email", [#("required", "true"), #("label", "Email address")])
}

pub fn status_has_live_region_test() {
  let session = test.start(my_app())
  test.assert_a11y(session, "#status", [#("live", "polite")])
}

pub fn decorative_image_is_hidden_test() {
  let session = test.start(my_app())
  test.assert_a11y(session, "#hero_image", [#("hidden", "true")])
}

Note: assert_a11y checks the raw a11y prop on the element – it doesn’t verify auto-inferred values (those come from iced’s Accessible trait). If the element has no a11y prop set, the assertion fails with a clear message.

Element helpers

plushie/test/element provides lower-level accessors:

import plushie/test
import plushie/test/element

pub fn element_accessors_test() {
  let session = test.start(my_app())
  let el = test.find(session, "#heading")

  // Get the raw a11y prop map
  let a11y_props = element.a11y(el)
  // => dict with "role" => "heading", "level" => 1

  // Get the inferred role (checks a11y override, then widget type)
  let role = element.inferred_role(el)
  // => "heading"
}

Testing patterns

Test the semantics, not the implementation. Focus on what AT users experience:

pub fn todo_app_is_accessible_test() {
  let session = test.start(todo_app())

  // Headings provide structure
  test.assert_role(session, "#title", "heading")

  // Interactive widgets are labelled
  test.assert_a11y(session, "#new_todo", [#("label", "New todo")])

  // Status updates are announced
  test.type_text(session, "#new_todo", "Buy milk")
  test.submit(session, "#new_todo")
  test.assert_a11y(session, "#todo_count", [#("live", "polite")])

  // Form validation errors are assertive
  test.submit(session, "#new_todo")  // empty submit
  test.assert_a11y(session, "#error", [#("live", "assertive")])
}

Building

Accessibility is included by default in both precompiled binaries (gleam run -m plushie/download) and source builds (gleam run -m plushie/build).

The renderer uses an iced fork (v0.14.0-a11y-accesskit branch) that adds native accessibility support. The fork is referenced via [patch.crates-io] in the renderer’s Cargo.toml. No vendored crates or local path overrides are needed.

Accessibility support is provided by:

ComponentWhat it provides
plushie-iced forkaccesskit + accesskit_winit, TreeBuilder, per-window adapter management
plushie-extA11yOverride wrapper widget, HiddenInterceptor, AT action handling

Platform support

PlatformATAPIStatus
LinuxOrcaAT-SPI2Supported
macOSVoiceOverNSAccessibilitySupported
WindowsNVDA, JAWS, NarratorUI AutomationSupported

All three platforms are supported via accesskit. The iced fork’s a11y integration creates platform adapters via accesskit_winit.

Testing with a screen reader

To manually verify accessibility with a real screen reader:

Linux (Orca)

# Build the renderer (a11y is included by default)
gleam run -m plushie/build

# Start Orca (usually Super+Alt+S, or from accessibility settings)
orca &

# Run your app
gleam run -m my_app

Orca should announce widget roles and labels as you navigate with Tab. Activate buttons with Enter or Space.

macOS (VoiceOver)

# Build the renderer (a11y is included by default)
gleam run -m plushie/build

# Toggle VoiceOver: Cmd+F5
# Run your app
gleam run -m my_app

Use VoiceOver keys (Ctrl+Option + arrow keys) to navigate. VoiceOver should announce each widget’s role and label.

Windows (NVDA)

# Build the renderer (a11y is included by default)
gleam run -m plushie/build

# Start NVDA
# Run your app
gleam run -m my_app

Tab between widgets. NVDA should announce roles, labels, and state (checked, disabled, expanded, etc.).

Architecture details

For contributors working on the accessibility internals:

iced fork (v0.14.0-a11y-accesskit branch)

The iced fork adds native accessibility support. Key additions:

The fork is referenced via [patch.crates-io] in the renderer’s Cargo.toml.

A11yOverride wrapper widget

a11y_widget.rs in plushie contains two wrapper widgets:

These wrappers are applied automatically by the renderer when building the iced widget tree from plushie’s UI tree. No manual wrapping is needed from Gleam.

Renderer integration

When the renderer builds the iced widget tree from a plushie snapshot or patch, it checks each node’s a11y prop. If present (and not just hidden: True), the rendered widget is wrapped in A11yOverride. If hidden: True, it’s wrapped in HiddenInterceptor. Nodes without an a11y prop are rendered as-is – iced’s native Accessible trait provides their baseline accessibility semantics.

Search Document