zgui

Accessibility

The accessibility tree, every a11y attribute, the Role vocabulary, what the framework derives on its own, and how to test it.

Some people cannot see the screen, cannot use a pointer, or cannot read small text. They operate the same application through a program that reads it out, magnifies it, or drives it from a keyboard. This page explains what that program is told, what zgui works out on its own, and what you have to declare. It assumes Elements, Events and Attributes.

What an accessibility tree is

An assistive technology is a program outside your application that presents the interface in another form. A screen reader speaks it. A braille display prints it. A magnifier follows the caret. An automation script clicks through it.

None of them can read your pixels. A rectangle of colour does not say "this is a switch, it is called Wi-Fi, it is on, and pressing Space turns it off". So the operating system defines a second description of every window: a tree of nodes, each carrying

PartExample
a role — what kind of thing it isbutton, switch, list item, heading
a name — what it is called"Delete", "Wi-Fi"
state — what it is doing right nowon, expanded, disabled, invalid
bounds — where it is on the screena rectangle in CSS pixels
actions — what it can be asked to doclick it, focus it, increment it
relations — which other nodes it points at"the text that names me is over there"

That tree is the accessibility tree. It is not your document. It is a projection of it: layout boxes disappear, text nodes become names, and a control that is off the screen is marked absent.

zgui builds this tree from the document after every frame that changed anything, and hands it to AccessKit. AccessKit is the library that speaks each operating system's own protocol — UI Automation on Windows, NSAccessibility on macOS, AT-SPI on Linux — so the framework produces one description and AccessKit answers three platforms with it.

AccessKit is not hidden behind a wrapper. Role, Toggled, Live, NodeId and the rest are AccessKit's own types, re-exported at zgui::vocab. A parallel copy would have to be kept in step by hand and would convert on every property of every node.

The smallest accessible control

use zgui::prelude::*;

let count = RwSignal::new(0);

view! {
    control(
        a11y:role = Role::Button,
        on:click = move |_| count.update(|n| *n += 1)
    ) {
        "Add one"
    }
}

Three things are true of it already.

  • The role is declared. Without a11y:role the element is Role::GenericContainer, which consumers drop from the tree they present.
  • The name is derived. The element's own text child, "Add one", becomes its accessible name.
  • The action is derived. The element has a click listener, so the node advertises Action::Click. A screen reader activating it produces a real click on the same path a pointer takes, reaching the same listener.

control is also focusable by nature, so the keyboard reaches it by tabbing and Enter or Space on it produces the same click. Pointer, keyboard and assistive technology all arrive at one on:click.

What is derived and what you declare

The framework works out everything it can see for itself. It never guesses at meaning.

Derived, with no declaration

DerivedFrom
the accessible namethe element's own text children, joined with single spaces
boundsthe union of the element's fragments, resolved through the drawn frame's transforms, in CSS pixels
hiddenthe element and everything below it generated no box
clips childrena fragment of the element clips its content
childrendocument child order, minus markers and minus text nodes
Action::Clickthe element has a click listener
Action::Focus, Action::Blurthe element is focusable; Blur only while it holds focus
Action::Increment, Action::Decrementthe element declared any numeric value, range or step
Action::SetValuethe element declared a value, or has a change listener
focusthe window's focused node, reported on every update
the root transformthe window's scale factor

Declared, or absent

Everything else. The role, description, placeholder, role_description, state_description, keyboard_shortcut, tooltip, every flag, expanded, selected, toggled, invalid, live, orientation, has_popup, auto_complete, sort_direction, current, the numeric range and step, the set position, the table position, and every relation.

state: and a11y: are separate namespaces with separate destinations. state:checked writes the UI state that CSS matches with :checked; a11y:toggled_on writes the accessibility property a screen reader announces. Neither implies the other. A toggle that changes colour and says nothing is a toggle that declared one of the two.

Every a11y: attribute

One namespace, 38 properties plus the role. Every one may be reactive: a constant, a signal, or a closure.

The role

AttributeTypeSays
a11y:roleRolewhat kind of thing the element is

a11y:role may be written once on a node; writing it twice is the compile error `a11y:role` is written once.

Naming and text

AttributeTypeSays
a11y:labelSharedStringwhat this element is called
a11y:descriptionSharedStringa longer description of this element
a11y:valueSharedStringthis control's value, as text
a11y:placeholderSharedStringthe text shown when this field is empty
a11y:role_descriptionSharedStringwhat this element's role is called, in words
a11y:state_descriptionSharedStringwhat this control's value means, in words
a11y:keyboard_shortcutSharedStringthe keystroke that activates this element
a11y:tooltipSharedStringthe tooltip shown for this element

Conditions

AttributeTypeSays
a11y:disabledboolcannot be interacted with
a11y:read_onlyboolthe value cannot be changed
a11y:requiredboolmust have a value
a11y:modalbooltakes the interaction over
a11y:busyboolstill loading
a11y:hiddenboolabsent from the presented tree, with everything inside it
a11y:expandedboolthis element's content is showing
a11y:selectedboolthis element is selected
a11y:toggled_onboolthis toggle is on

expanded and selected are three-valued underneath. Saying nothing means "not expandable at all"; saying false means "collapsed, and there is a control for it". The same distinction applies to toggled_on.

Closed vocabularies

AttributeTypeValues
a11y:toggledToggledFalse, True, Mixed
a11y:invalidInvalidTrue, Grammar, Spelling
a11y:liveLiveOff, Polite, Assertive
a11y:orientationOrientationHorizontal, Vertical
a11y:has_popupHasPopupMenu, Listbox, Tree, Grid, Dialog
a11y:sort_directionSortDirectionAscending, Descending, Other
a11y:currentAriaCurrentFalse, True, Page, Step, Location, Date, Time
a11y:auto_completeAutoCompleteInline, List, Both

All eight live at zgui::vocab. a11y:toggled is the three-state form of a11y:toggled_on: a checkbox summarising a partly selected group is Toggled::Mixed, and a consumer announces it differently from either of the other two.

Numbers

AttributeTypeSays
a11y:numeric_valuef64this control's value, as a number
a11y:levelusizethis heading's level, counting from one
a11y:row_indexusizewhich row of its table, counting from zero
a11y:column_indexusizewhich column of its table, counting from zero
a11y:row_spanusizehow many rows this cell covers
a11y:column_spanusizehow many columns this cell covers

Relations

AttributeTypeSays
a11y:labelled_byNodeIdthe element whose text names this one
a11y:described_byNodeIdthe element whose text describes this one
a11y:controlsNodeIdthe element this one controls
a11y:ownsNodeIdthe element this one owns, which is not its child in the tree
a11y:active_descendantNodeIdthe descendant that behaves as though it had focus
a11y:popup_forNodeIdthe element this popup belongs to
a11y:error_messageNodeIdthe element holding this control's error message

Rules that apply to all of them

  • Every property may be reactive. They are all resolved inside one binding on the element, so a change re-lowers the whole description and re-projects one node.
  • A reactive text property must produce a SharedString. A literal &str converts on its own; a closure has to say move || SharedString::from(format!("…")).
  • Writing the same name twice is allowed for everything except role. Both steps run and the later one wins. The list relations rely on this to accumulate targets.
  • With no role, the description says nothing about what the element is. That is not the same as saying it is a box. On a component call the caller's description merges over the component's own, and a role invented for the caller would silently replace the component's.
// The caller adds a label. The component's Role::Button survives.
view! {
    SaveButton(a11y:label = "Save and close")
}

What is not writable as an attribute

PropertyWhyInstead
table_size(rows, columns)takes two arguments, and an attribute passes oneA11yBinding::table_size(rows, columns)
radio_group, numeric_range, numeric_step, set_position, clips_children, and the raw flagno method on the reactive builderA11yBinding::step

Both escape hatches go through an attribute bundle, which a view spreads with {..}:

let semantics = A11yBinding::new(Role::Slider)
    .numeric_value(move || value.get())
    .step(|a11y| a11y.numeric_range(0.0, 100.0).numeric_step(5.0));

let own = Attrs::new().a11y_from(semantics);

view! {
    control(class = "slider", tabindex = Focus::Sequential, {..own})
}

A11yBinding::step takes impl Fn(A11y) -> A11y. The closure runs inside the binding's effect, so anything reactive it reads is tracked like any other property.

Semantics::access_key and the coarse numeric jump are projected but have no builder method at all today. Partial· Reachable only by constructing a Semantics value directly.

The role vocabulary

The role is the most important thing an element declares. It decides how a consumer announces the element, which keyboard conventions apply to it, and which of the other properties mean anything — a checked state means something on a checkbox and nothing on a heading.

Two roles have framework-wide meaning:

  • Role::GenericContainer is a box that exists for layout only. It is the default for every element that declares no role, and consumers filter it out of the tree they present, so deep visual nesting does not become deep spoken nesting.
  • Role::TextRun is a run of text inside an editable field, and is likewise filtered out.

The roles you reach for most:

RoleFor
Buttonanything that does something when it is activated
Switchon or off, taking effect at once
CheckBoxa fact something else will act on later; may be Mixed
RadioButtonone of a group, with RadioGroup around them
Linknavigation
Heading with a11y:levela section title
TextInput, MultilineTextInput, SearchInput, PasswordInputtyped text
Slider, SpinButton, ProgressIndicator, Metera number
Tab, TabList, TabPaneltabs
Menu, MenuBar, MenuItem, MenuItemCheckBox, MenuItemRadiomenus
ListBox, ListBoxOption, ComboBoxa chooser
List, ListItem, Tree, TreeItema collection
Table, Row, Cell, RowHeader, ColumnHeader, Grid, GridCelltabular data
Dialog, AlertDialog with a11y:modala window inside the window
Alert, Status, Logsomething that appeared and should be read out
Group, Region, Section, Toolbar, Navigation, Mainstructure

Names

An element gets its name from one of three places, in this order.

A declared a11y:label. It wins over everything.

A labelled_by relation. The consumer reads the target node's name.

The element's own text children, joined with single spaces.

The third rule is the one that surprises people, because only the element's own text children count.

// Named "Delete": the string is a text child of the control.
control(a11y:role = Role::Button) {"Delete"}

// The control has no name. "Delete" landed on the inner element instead.
control(a11y:role = Role::Button) { text {"Delete"} }

A name assembled from a whole subtree would make one deep element pay for every text node beneath it, and the change key the projection is scheduled by hashes exactly these children. When the words are nested, declare the label or point a relation at them.

Four roles put their own text in value rather than in label: Label, TextInput, MultilineTextInput and SearchInput. A consumer reads a Label node's text from its value, and an editable element's text is its value — the document is the only copy of it, so the element declares no value and the projection reads the real one.

a11y:description is the longer second string a consumer reads after the name. Use it for the hint under a field, not for a second name.

Relations

A relation names another node. The type is NodeId, but you never write one: a NodeRef converts directly.

let caption = NodeRef::new();
let hint = NodeRef::new();

view! {
    column {
        label(node_ref = caption) {"Full name"}
        field(
            a11y:role = Role::TextInput,
            a11y:labelled_by = caption,
            a11y:described_by = hint,
            a11y:required = true
        )
        text(node_ref = hint) {"As it appears on your passport"}
    }
}

The conversion tracks. A relation written before its target mounts resolves to nothing on that frame, and is filled in on the frame the target appears. It empties again when the target goes away.

When the target itself changes, write a closure returning Option<NodeRef>:

let hint = NodeRef::new();
let error = NodeRef::new();

field(a11y:described_by = move || Some(if wrong.get() { error } else { hint }))

None names nothing, and names it the same way an unbound handle does.

Every target is filtered against the projected tree before it is written. A relation naming a node that is not there is dropped rather than published. This is not tidiness: a consumer resolves a relation with an unchecked lookup, on a thread this process does not own and cannot catch. A debug build asserts that no published update names anything unresolvable.

The eight relations are labelled_by, described_by, controls, owns, radio_group, active_descendant, popup_for and error_message. Seven are writable as attributes; radio_group goes through A11yBinding::step.

Two shapes are worth knowing. A trigger that opened a dialog the framework moved to an overlay band projects to this:

Button label="Delete" expanded=true has_popup=Dialog owns=[dialog]
Dialog modal labelled_by=[title] described_by=[body] popup_for=button

owns re-parents the portalled surface for a consumer: the dialog is a child of the overlay band in the document, and a child of the trigger in the tree that is read out.

A combobox whose keyboard focus never leaves the input projects to this:

ComboBox label="Fruit" expanded=true has_popup=Listbox controls=[list]
         owns=[list] active_descendant=option

active_descendant is what lets an arrow key walk the list while focus stays in the text field.

Live regions and announcements

There is no announcement queue and no announce() function. A live region is an element whose changes a consumer reads out without being asked, and it is declared with one attribute.

use zgui::vocab::Live;

view! {
    text(class = "status", a11y:live = Live::Polite) {
        {move || format!("{} results", results.get().len())}
    }
}
ValueBehaviour
Live::Offnot a live region
Live::Politeread out when the consumer next pauses
Live::Assertiveinterrupt whatever is being read

The mechanism is ordinary. The text changes, the node is marked, the node is re-projected, the changed node is in the next update, and the consumer sees a live region change. Nothing is queued and nothing is timed.

Two additions:

  • SemanticFlags::LIVE_ATOMIC says "announce the whole region, not the part that changed". It has no attribute; reach it with .step(|a| a.flag(SemanticFlags::LIVE_ATOMIC, true)).
  • Role::Alert and Role::Status are roles a consumer already treats as live. A toast wants Role::Alert; a quiet counter wants Live::Polite on a plain element.

Use Assertive for one thing only: something the user must act on now. Everything else is Polite.

Focus and keyboard reachability

An interface nobody can reach with a keyboard is inaccessible whatever its tree says. Focus is an accessibility concern for a second reason as well: focus is a field of every accessibility update, so a frame that moved focus and changed nothing else still publishes one.

There is exactly one answer to "which node can take focus", shared by the Tab key and by an assistive technology's focus request. Two answers would be two tab orders.

RuleDetail
focusable by naturecontrol, field and editor, with nothing declared
focusable by declarationany element with tabindex = Focus::Sequential or Focus::Programmatic
never focusableanything carrying state:disabled, or generating no visible box
sequential orderdocument order; Focus::Programmatic is reachable but not in the sequence
// A row that is not a control, but has to be reachable.
row(class = "item", tabindex = Focus::Sequential, on:click = pick) { … }

Enter and Space on the focused element produce a real click. So does an assistive technology's activation request. One on:click handles a pointer, a keyboard and a screen reader, and there is no separate accessibility activation path to write.

A control that is disabled while it holds focus has to leave the sequential order. Make tabindex reactive when the control can be disabled: tabindex = move || if disabled.get() { Focus::Programmatic } else { Focus::Sequential }.

Keyboard and focus covers traversal, :focus-visible, focus traps and programmatic focus in full.

How the tree is published

publish_a11y is the last phase of a frame that has anything to say, and it runs after the renderer. What a consumer is told about a node's position has to be what was drawn, not what was about to be.

Geometry moves are noted. A node the frame carried somewhere else is named by the fragment pass. A node whose whole coordinate system was rewritten — an element under a running transform — was never touched at all, and is named by the space that moved.

The document's accessibility marks are drained, on every frame, listener or not. Invalidation is a union: a bit nobody retires keeps every ancestor's subtree marked for the life of the window, and every other stage would then descend everywhere. Draining is cheap; what it gathers accumulates.

Nothing owed and focus unmoved: the frame publishes nothing.

Only focus moved: an update carrying no nodes at all, which costs no projection.

Otherwise the surface is handed a closure, not a tree. AccessKit runs it only if something is listening. On a machine with no assistive technology running, the whole phase costs one comparison.

What marks a node for re-projection

ChangeAlso marks
writing any a11y: propertythe node and its ancestors
changing a text node's textthe node's ancestors
setting a prop: valuethe node and its ancestors
adding or removing a listenerthe node and its ancestors
creating or inserting an elementthe node and its ancestors
removing a subtreethe node and its ancestors
checked, disabled, open or indeterminate changingthe node and its ancestors

What an update contains

An update is a difference. Each marked node is projected afresh — the projection is a pure function of the frame, nothing cached — and is put in the update only if the result differs from the last one sent for that node.

Two rules are not optional:

  • A node's parent is re-projected whenever the node is. A child list belongs to the parent and to nothing else, so an appearance, a disappearance or an identity change is only visible once the parent is sent again.
  • Departures are retired before anything is projected. A node that leaves invalidates every node naming it, and those nodes are usually not in this frame's marks at all.

Once more than 4096 nodes are owed, the next build is a whole tree instead, because at that size a rebuild is cheaper than a diff. A consumer that connects late is also sent a whole tree, although nothing is dirty — nothing has changed; what is missing is its copy.

What it costs

Nothing, when nothing is listening. The tree is built inside a closure the surface calls only when a consumer is active.

When something is listening, one projection per changed node, plus its parent. In the maintainer's gallery workload, one window resize — the worst case, where every one of 2 265 boxes moved and had to be re-measured — spent 0.58 ms in the accessibility phase, 2.6 % of an 18.4 ms frame (f.a11y → f.recycle, docs/perf/gallery-interactions.md). An ordinary interaction changes one or two nodes.

A ticking number is one node. A text node is not a node in this tree; the characters inside an element are that element's name. label { {count} } is one node whose name changes.

Actions coming back

An assistive technology does not only read. It acts. Each request arrives as a wake, is matched to the window whose document holds the node, and is carried out inside that window's reactive scope.

RequestedWhat happens
Clicka synthesised click on the node, down the same capture, target and bubble path a pointer takes
Focus / Blurfocus moves to the node, or away from it
Increment / Decrementa change event carrying the stepped value
SetValue / ReplaceSelectedTexta change event carrying the new value
ShowContextMenua context_menu event on the node
ScrollIntoViewthe same call NodeRef::scroll_to makes
SetScrollOffsetthe container scrolls to an absolute offset in CSS pixels
ScrollUp / Down / Left / Rightone line of scrolling, as a wheel would have reported it
Collapse, Expand, CustomAction, ShowTooltip, HideTooltip, ScrollToPoint, SetTextSelectionignored

A request naming a node this build cannot act on is left alone rather than absorbed. An assistive technology told an action succeeded when nothing happened tells its user the application responded.

The step size for Increment and Decrement is the declared numeric_step when it is above zero, otherwise a hundredth of the declared range, otherwise one hundredth. The result is clamped to whichever of the range ends were declared.

Testing accessibility

One component

zgui-testkit-view mounts a component with no window, no style engine and no layout, and lets you read what it wrote. Add it to your [dev-dependencies]; it is not re-exported by zgui.

use zgui::prelude::*;
use zgui::vocab::{Role, Semantics, Toggled};
use zgui_testkit_view::Window;

#[test]
fn a_toggle_says_what_it_is_and_which_position_it_is_in() {
    let window = Window::open();
    let on = window.scope.with(|| RwSignal::new(false));

    let mut built = window.scope.with(|| {
        view! {
            control(
                a11y:role = Role::Switch,
                a11y:label = "Dark mode",
                a11y:toggled_on = move || on.get()
            )
        }
        .into_view()
        .build(&mut window.cx.cx())
    });
    built.mount(&window.dom_handle, window.root, None);
    window.frame();

    let node = window.dom.tree().children(window.root)[0];
    let semantics: Semantics = window
        .dom
        .tree()
        .semantics(node)
        .expect("the control says what it is");

    assert_eq!(semantics.role, Role::Switch);
    assert_eq!(semantics.label.as_deref(), Some("Dark mode"));
    assert_eq!(semantics.toggled, Some(Toggled::False));

    on.set(true);
    window.frame();
    assert_eq!(
        window.dom.tree().semantics(node).expect("still there").toggled,
        Some(Toggled::True)
    );
}

Everything on Semantics is assertable this way: role, label, description, value, flags, toggled, invalid, live, numeric, position, table and relations.

The same window keeps a transcript of every change made to the tree, one line each. A description appears as its role, and a cleared one says so:

semantics #1 Switch
semantics #1 cleared

Transcript::assert_matches(path) compares the whole recorded sequence against a checked-in file, which catches a description that stopped being written as well as one that changed.

window.frame() is what makes a written signal reach the tree. Asserting straight after an interaction asserts on the frame before the one it caused.

A whole window

zgui-platform-headless runs the real frame loop over an offscreen surface and records every accessibility update the surface was handed:

Method on OffscreenSurfaceAnswers
last_a11y_update()the most recent TreeUpdate
a11y_log()the whole sequence, in order
a11y_updates()how many were published

The whole sequence is kept because an update is a difference: what it names resolves against everything sent before it.

zgui_a11y::dump(&update) turns one update into stable, sorted text — one line per node, with identifiers rewritten as small ordinals — which is what a golden file compares against. A screenshot cannot show a missing relation, and a combobox that stopped pointing at its active option looks identical and is unusable.

tree root=#1
focus #1
#1 Window children=[#2]
#2 GenericContainer children=[#3 #4 #5]
#3 GenericContainer label="Full name"
#4 TextInput required labelled_by=[#3] described_by=[#5]
#5 GenericContainer label="As it appears on your passport"

zgui_a11y::dangling(&update, retained) answers the other question: does every identifier this update mentions resolve, either inside the update or inside what the consumer already holds. The runtime runs it in a debug assertion around every published update.

Testing covers both harnesses in full.

A worked example: an accessible toggle

Everything above, in one component. No component library, no primitives — a control, a label and a style sheet.

use zgui::prelude::*;

/// A switch with a caption beside it, operable by pointer, keyboard and screen reader.
#[component]
fn Toggle(
    /// Whether it is on. Written back to when it is flipped.
    on: RwSignal<bool>,
    /// What it is called.
    #[prop(into)]
    caption: String,
    /// Whether it can be operated.
    #[prop(into, default = Signal::stored(false))]
    disabled: Signal<bool>,
) -> impl IntoView {
    let name = NodeRef::new();

    view! {
        row(class = "toggle") {
            label(class = "toggle__caption", node_ref = name) {{caption}}
            control(
                class = "toggle__switch",
                // What CSS matches.
                state:checked = move || on.get(),
                state:disabled = move || disabled.get(),
                // What a screen reader is told. Separate, and both are needed.
                a11y:role = Role::Switch,
                a11y:labelled_by = name,
                a11y:toggled_on = move || on.get(),
                a11y:disabled = move || disabled.get(),
                a11y:state_description = move || {
                    zgui::vocab::SharedString::from(if on.get() { "on" } else { "off" })
                },
                on:click = move |_| {
                    if !disabled.get_untracked() {
                        on.update(|value| *value = !*value);
                    }
                }
            ) {
                box(class = "toggle__thumb")
            }
        }
    }
}

const SHEET: &str = css!(
    ".toggle {
        align-items: center;
        gap: 12px;
    }

    .toggle__switch {
        width: 44px;
        height: 24px;
        padding: 2px;
        border-radius: 999px;
        background-color: #2b3243;
        display: flex;
        justify-content: flex-start;
    }

    .toggle__thumb {
        width: 20px;
        height: 20px;
        border-radius: 999px;
        background-color: #e8ecf4;
    }

    .toggle__switch:checked { background-color: #3b6cf6; justify-content: flex-end; }
    .toggle__switch:focus-visible { outline: 2px solid #7aa2ff; outline-offset: 2px; }
    .toggle__switch:disabled { opacity: 0.4; }"
);

What each part earns:

WrittenGives
controlfocusable by nature, so Tab reaches it and Enter or Space activates it
a11y:role = Role::Switchannounced as a switch, with two positions and no mixed one
a11y:labelled_by = namenamed by the caption beside it, which is not its ancestor
a11y:toggled_onthe position, announced on every change
state:checkedthe position, matched by :checked in the sheet
a11y:disabled + state:disabledannounced as disabled, refuses pointer events, and drops out of the tab order
a11y:state_description"on" and "off" instead of the consumer's default wording
on:clickone handler for the pointer, the keyboard and the accessibility action

Nothing here declares bounds, children, actions or focus. All four are derived.

What is left to check by hand

  • Contrast and size. The framework does not check either. :focus-visible gets an outline from the framework's own sheet; a sheet of yours that removes it removes the only sign of where the keyboard is.
  • Reading order. The tree follows document order. If the visual order differs from the source order, the two disagree.
  • Screen coordinates on Wayland. The compositor does not report window position, so features that need "where is this on the screen" — a magnifier following the caret — are degraded there. Partial· A platform limitation, not a framework one.

Next

On this page