Skip to main content

Accessibility

react-x11 applications are visible to screen readers. The renderer carries a built-in AT-SPI2 bridge — the interface GTK and Qt register through — so Orca reads a react-x11 window like any other: roles, names, states, focus, live text editing, and the actions that drive controls without a pointer. It is on by default; with no accessibility bus the bridge never comes up, and the render path pays one property read per event.

Per backend. On win32 the same tree is pushed to UI Automation instead (windows.md). On Cocoa there is no bridge yet: createRoot() leaves AT-SPI off there (desktop: { a11y: false } by default) and a Cocoa-backend app is not reachable by VoiceOver. Write role, aria-* and announce() anyway: they live on the shared node tree, and an NSAccessibility bridge will read them unchanged.

<box role="button" aria-label="Close" onClick={close} focusable>
<text>×</text>
</box>

That is the entire API for most code. role and aria-* are the web's names, accepted on every host element — the vocabulary react-dom takes — so a component library that sets them is accessible here with no react-x11-specific knowledge, and the built-in widgets (Button, Checkbox, Select, Menu, Tabs, …) already carry them.

Design notes: design/architecture/accessibility.md (no mirror tree, the web's vocabulary, what was rejected) and design/accessibility.md.

What a screen reader hears without any props​

Accessibility is not opt-in; an unlabelled tree is boring to a screen reader rather than broken:

elementreads as
<window>frame, named by title
<popup>window (give the content a role — menu, dialog, …)
<box>filler — skipped silently, like GTK's own layout boxes
<text>label, reading its content — and its selection, if any
<textinput>entry: editable, single-line, caret and selection live
<textarea>entry: editable, multi-line
<image> / <svg>image, named by alt
overflow: 'scroll'scroll pane (and a tab stop when it can scroll — see below)
<canvas> / <glarea>drawing area / canvas — name them if they carry meaning

Focus, enabled/disabled, checked, selected, expanded and the rest are read from the same live state the widgets draw from — the focusable rule, the disabled prop, the aria-* props — so what the screen reader says cannot drift from what the screen shows.

The props​

All of these are accepted on every host element and are inert (and harmless) when no assistive technology is listening.

propAT-SPI meaning
rolewhat the element is (ARIA role names; table below)
aria-labelthe accessible name, over every other source
aria-descriptionsupplementary description
aria-hiddenremove this subtree from the accessible tree
aria-checked (true/false/'mixed')CHECKABLE + CHECKED / INDETERMINATE
aria-selectedSELECTABLE + SELECTED
aria-expandedEXPANDABLE + EXPANDED / COLLAPSED
aria-pressedPRESSED (toggle buttons)
aria-busy, aria-modal, aria-required, aria-readonlythe states of the same names
aria-haspopupHAS_POPUP ('menu', 'listbox', 'dialog', …)
aria-orientationHORIZONTAL / VERTICAL
aria-valuenow / -valuemin / -valuemax / -valuetextthe Value interface (sliders, progress)
aria-level, aria-posinset, aria-setsize"level 2", "3 of 7" — headings, trees, lists
aria-keyshortcutsthe shortcut announced with the item ("Control+N")
onAccessibilityActionthe AT drove the control (below)

A subtree styled visibility: 'hidden' is removed from the accessible tree the way aria-hidden removes one, and a screen reader hears it leave and come back as the style changes (styling.md).

Roles: alert alertdialog article banner blockquote button caption cell checkbox columnheader combobox comment complementary contentinfo dialog document form grid gridcell group heading img link list listbox listitem log main marquee math menu menubar menuitem menuitemcheckbox menuitemradio meter navigation note option paragraph progressbar radio radiogroup region row rowheader scrollbar search searchbox separator slider spinbutton status switch tab table tablist tabpanel term textbox timer toolbar tooltip tree treegrid treeitem window, plus none/presentation to erase a wrapper from the accessible tree while keeping its children. An unknown role warns in development and falls back to the element default — a typo neither turns a button into a filler nor crashes.

There is no aria-disabled: use disabled, which blocks focus and drives :disabled style blocks, so the two cannot disagree. There is no aria-labelledby/aria-describedby either: there are no element ids to point them at — use aria-label, or lean on name-from-contents.

Names​

The accessible name resolves in order: aria-label → what the element itself carries (a window's title, an image's alt, an input's placeholder) → for roles whose contents are their label (buttons, checkboxes, menu items, tabs, links, options, tree items, …), the visible text inside. So <Button>Save</Button> is named "Save" with nobody writing a label.

AT-driven controls​

Activation needs no wiring: every element whose role promises it (button, checkbox, menu item, tab, …) or that has an onClick exposes an AT-SPI activate action. The bridge performs it as a synthetic click through the normal event path — capture, bubble, default actions, the discrete-priority commit — so Orca pressing a button is indistinguishable from a pointer. It is a full press gesture (mousedown, mouseup, click), because Select and MenuBar open their menus on the press.

The keyboard shares that click. Space and Enter on a focused element with an onClick dispatch the same gesture through the same function (events.md). A role advertises an action to an AT that cannot press a key; a role="button" with no onClick has no click for a key to trigger.

The one thing that needs a handler is a value write — Orca adjusting a slider through the Value interface. The handler receives one argument:

{ action: 'setValue', value: number }

'setValue' is the only action delivered today (action is a string so the shape can grow; activation never arrives here). Wire it into the same state setter as the pointer and the keyboard:

<box
role="slider"
aria-valuenow={value}
aria-valuemin={0}
aria-valuemax={100}
onAccessibilityAction={({ action, value }) => {
if (action === 'setValue') setValue(clamp(value));
}}
/>

Slider already does exactly this.

Announcements​

import { announce } from 'react-x11';

announce('Form saved');
announce('Connection lost', { assertive: true });

The explicit counterpart of an ARIA live region: say something through the screen reader without moving focus. Returns true when a bridge was live to carry it; false means nobody is listening and a visible fallback may be warranted. Orca ≥ 45 speaks these. There is no aria-live region tracking — an explicit call cannot fire on renders you did not mean.

What the built-in widgets announce​

Every widget in components.md is wired; none of them need anything from you beyond their ordinary props. What a screen reader hears:

widgetreads as
Buttonbutton, named by its label; disabled buttons say so
Checkboxcheck box, checked/unchecked, named by its label
Radio / RadioGroupradio buttons with the selected one checked
Switchswitch, on/off — give it an aria-label, it has no visible label of its own
Sliderhorizontal slider with value and range; Orca can adjust it
ProgressBarprogress bar, its value as a percentage of its range; indeterminate is busy
Selectcombo box named by the current choice, expanded/collapsed; its menu is a listbox
MenuBar / ContextMenumenu bar and menus (below)
Tooltiptooltip
Dialogmodal dialog, named by its title
Tabstab list with orientation; each tab selected or not; the panel is a tab panel
Tabletable with column headers and rows ("n of m", selected)
SplitPanethe divider is a separator with orientation and position (ARIA window splitter)

An indeterminate ProgressBar reports no value rather than 0%. A Select's options each read "n of m", the current one selected. Menu items carry their shortcut as the standard keyshortcuts attribute ("Control+N" — UI Events spelling, where the row draws "Ctrl+N" and the key path binds it), a toggleType as checked/radio, toggleState: -1 as mixed, and submenus as expandable. A virtualized Table has only its rendered rows in the tree — the same rows a sighted user can see.

Text controls​

<textinput> and <textarea> implement the AT-SPI Text and EditableText interfaces in full: character count, reading by character/word/line, caret position (offsets are code points, on both sides), selections, per-character screen rectangles for magnifiers and caret tracking, and edits — a screen reader can read, navigate, select, and type. Every edit and caret move emits the precise text-changed/text-caret-moved deltas Orca narrates from.

Limit: "line" granularity follows hard newlines, not the soft wraps of a <textarea>'s layout. Orca still reads everything; a line-by-line walk of one long wrapped paragraph is one stop, not several.

A <text> has the Text interface too — readable, with no caret — and reports its selection: a paragraph inside a selectable surface (elements.md) hands over the range the user has dragged across, and says so as it moves, so a magnifier's highlight and a braille display's cursor sit on the highlighted characters.

While a composition is open​

A dead key or a half-typed Compose sequence puts text on the screen that is in nobody's value yet (events.md). The Text interface answers with what is drawn, that preedit included, and its offsets index that string, so a magnifier tracking a character lands on the glyph a sighted user sees. CaretOffset is at the far end of the composition, where the next keystroke of the sequence appears.

The uncommitted part is marked twice:

  • as its own attribute run — underline: single (the registered AT-SPI attribute) plus composition: true (this renderer's own; AT-SPI registers nothing for a preedit);
  • as :system text — text-changed:insert:system when the accent appears, delete:system when it changes or is abandoned — which lets a reader stay quiet through a composition.

What the sequence commits is a plain insert of the finished character: é is spoken once, as one insertion — the same event an ordinary keystroke produces, and one undo step.

Nothing here goes through announce() (the screen reader would say the accent twice); an application that wants to narrate composition state can call announce() itself from onCompositionUpdate.

Text of your own​

A registered element that draws its own text — a code editor, a markdown viewer, a terminal, a canvas-backed table with cell editing — reports it through one method and is then read by everything above: character count, reading by character/word/line, caret, selection, the text-changed deltas, and an AT that can navigate and type.

class EditorNode extends Node {
a11yTextState() {
return {
value: this.text, // what is drawn, composition included
caret: this.caret, // code-point offsets into `value`
selectionStart: this.anchor,
selectionEnd: this.caret,
editable: true, // an editor rather than a viewer
multiline: true,
};
}

insert(text) {
/* …the edit… */ this.notifyA11yTextChanged();
}
}
member
a11yTextState(){ value, caret, selectionStart, selectionEnd, editable, multiline, preedit } — null for no text
notifyA11yTextChanged()the text moved. Free when nothing is listening, so no guard around it
a11ySetSelection(start, end)an AT moved the caret or made a selection
a11yReplaceText(start, end, text)an AT edited — insert, delete and replace all arrive as this
a11yRolethe role the element is when the app writes none; role still wins

Two tiers. { value, selectionStart, selectionEnd } alone is a document: the Text interface, a document role, a selection an AT reads and sets — all a markdown view with Ctrl+C needs. editable: true adds the EDITABLE state, an entry role and, once a11yReplaceText exists, the EditableText interface. An element that reports editable text but implements no write is announced as editable and reports its own edits, but is not offered to an AT to type into.

Offsets are code points and index the string the element draws — an open composition included, as with <textinput>. Report the uncommitted part as preedit: { offset, text } and its churn is marked :system, so a reader stays quiet through a dead key and speaks the character it commits. Offsets are clamped on the way out, so a stale caret cannot become an out-of-range answer.

Known limits, none of which stop a screen reader working: per-character screen rectangles fall back to the element's own rect (a magnifier tracks the element, not the glyph), and AT-driven paste and copy are the built-ins' — an element's own Ctrl+C/Ctrl+V is unaffected.

The test spy observes this seam exactly as it observes core's, so an editor in another package can assert what it says with no bus and no desktop — below.

A scene of your own​

A registered element that draws interactive things — a graph pane, a chart, a timeline, a seating plan — is one node, so it is one accessible: "Flow graph, group", with three hundred selectable nodes inside it that no assistive technology can see. It says what it drew and each one becomes a child:

class FlowNode extends Node {
a11yScene() {
return this.nodes.map((node) => ({
id: node.id, // stable while it is on screen: this is identity
role: 'listitem',
name: node.label,
rect: node.rect, // window coordinates
states: {
selected: this.selection.has(node.id),
focused: this.cursor === node.id,
},
}));
}

select(id) {
/* …the element's own selection… */ this.notifyA11ySceneChanged();
}
}
member
a11yScene()[{ id, rect, role, name, description, focusable, states, props, children }]
notifyA11ySceneChanged()the scene moved between commits — a drag, an animation, your own arrow keys
a11ySceneAction(id, action)an AT sent 'activate', 'focus' or 'scroll'. true claims it; anything else keeps core's answer

role and the aria-*-shaped states become the AT-SPI role and state set, rect becomes the Component extents a magnifier tracks and focus is drawn around, and a change to any of it is announced as that child changing rather than as the pane being replaced. Keyboard navigation between items stays the element's own — the window's focus manager holds the element, so states.focused is the only way the cursor inside it is reported.

Activation with no a11ySceneAction is a synthetic click at the item's own rect, which is what a mouse user does to it; the seam is for elements where that is not the truth. See extending.md for the whole contract.

The compatibility ladder​

createRoot() starts one climb per process, off the critical path:

  1. is dbus-native installed? (it is an optionalDependency; Node < 22.12 skips it)
  2. is there a session bus?
  3. does org.a11y.Bus answer GetAddress? (this also starts the a11y bus launcher on desktops where it is not running yet)
  4. connect, export the tree, Embed with the AT-SPI registry.

Every "no" is a normal, silent off: ssh sessions, CI, containers, bare startx, macOS and Windows X servers have no accessibility stack, and the app does not log, slow down or change behaviour because of it. The bridge holds no event-loop reference, so a process that is done exits; the registry sees the name drop and forgets the app.

Environment switches, in priority order:

  • REACT_X11_A11Y=0 — off. Any other value forces the climb and makes it loud: each rung prints why it stopped, which is the answer to "why does Orca not see my app", and the bridge says when it goes quiet and when it wakes (with nobody listening).
  • AT_SPI_BUS_ADDRESS — skip discovery and connect here. The seam sandboxes use, and the one the hermetic tests use.
  • REACT_X11_A11Y_INTERVAL=<ms> — how often changes are pushed to the platform, 500 by default; 0 pushes every change. See How often the bridge speaks, which also applies to the Windows bridge.
  • NO_AT_BRIDGE=1 — off; the ecosystem-wide switch every toolkit honours. react-x11/test sets it, so a test run on a desktop does not parade phantom applications through a running screen reader.

To turn the bridge off from the app itself, without a variable every child process inherits:

await createRoot({ desktop: { a11y: false } });

It outranks every switch above, REACT_X11_A11Y=1 included — see desktop.md, where desktop: false turns this off along with the other two integrations that talk to the session bus. Pass it on the first root: the climb happens once per process, and a second root's switch cannot unstart a bridge that is already up.

A dead accessibility bus is not redialled — the same contract as dbus.md: the app stops being accessible until restarted.

How often the bridge speaks​

Both bridges (AT-SPI and UI Automation) pace what they push, the way browsers do, so a scroll, a pan or a ticking value does not pay for a platform update on every commit:

  • A change after a quiet spell is pushed at once. A click or a key that changes one thing is heard immediately.
  • A stream of changes is pushed at most once per interval, 500 ms by default, and the last push carries the last state. A row that scrolls in and out between two pushes is never exported at all, and an AT that was never told of it is not told it left.
  • What the user is waiting on is pushed at once, whatever the interval: focus moving, an announcement, and a change to the focused element itself, such as the characters typed into it or its toggled state.
  • Reads are never paced (AT-SPI). A call from an AT first flushes whatever the pacer is holding, so every object and interface it asks about is as the tree has it now. Only the unsolicited signals wait.

REACT_X11_A11Y_INTERVAL sets the interval in milliseconds, and 0 goes back to a push per change.

With nobody listening​

An AT-SPI bus runs in most Linux sessions whether or not a screen reader does, so the bridge asks the registry who is listening. A listener is an AT that registered for events, or any client other than the registry that calls in.

  • With no listener and no caller, the bridge falls silent. Nothing is queued, pushed or exported, and what it had exported before the registry answered is withdrawn. The tree still answers every read, starting from the application root.
  • The first listener or the first call wakes it for good. The AT reads the tree as it is, which is where it would start anyway, and every change after that is pushed as above.
  • An announcement made during the silence goes unheard, and announce() returns false for it, as it does with no bridge at all.
  • A registry that cannot say who listens is taken to have someone listening, as GTK 4 does with one it cannot ask.

For component libraries​

There is nothing to integrate against. Set the standard props on the host elements you render, exactly as the built-in widgets do; the bridge mirrors whatever the tree says. The seams, in increasing order of involvement:

  • role + aria-* on any element — the whole story for almost everything.
  • disabled, focusable, tabIndex — already doubling as the ENABLED and FOCUSABLE states; nothing separate to maintain.
  • onAccessibilityAction — when an AT can set something on your control.
  • A registered element is a host element like any other: its instances take all of the above, and a class that should default to a role assigns this.a11yRole in its constructor (a role prop still wins).
  • a11yTextState() + notifyA11yTextChanged() — when your element draws text of its own, which is the one thing props cannot express (above).
  • a11yScene() + notifyA11ySceneChanged() — when your element draws interactive things of its own, which is the other one (above).

Testing and verifying​

Asserting what a screen reader would hear​

renderX11(element, { a11y: true }) hands back at, an in-process assistive-technology spy: the same semantic feed the AT-SPI bridge serves to Orca, observed before it becomes D-Bus — so it is synchronous, needs no bus, and runs everywhere the test suite runs (the mock backend, Node 20, macOS included). Input stays real: userEvent.tab() is a Tab through the in-process X server's focus machinery, not a shortcut around it.

import { renderX11, userEvent, cleanup, XK_SPACE } from 'react-x11/test';

const { at } = await renderX11(<Preferences />, { a11y: true });

await userEvent.tab();
assert.equal(at.focused().utterance, 'Save, button');

await userEvent.tab();
await userEvent.key(XK_SPACE);
assert.ok(at.since().some((e) => e.type === 'state' && e.state === 'checked'));

Every entry is a precise fact ({ type, state, on, node, … }) plus a one-line summary; at.transcript() is the summaries, made for deepEqual. at.focused() and at.focusables() describe nodes the way an AT would — name, role, states, and an utterance string following the model on this page (name, role, the states worth speaking, a value as a percentage). The utterance is this library's model, not an imitation of Orca, whose wording is presentation policy; a control nobody named renders as "(no accessible name)" instead of passing because no assertion happened to mention its name.

Four tests worth copying into any application — each one a check a sighted test author cannot make by looking (test/a11y-spy.test.js in the repository is the worked example of all four):

  • Focus order — tab through, deepEqual the focus summaries.
  • No keyboard trap — at.focusables().length + 1 Tabs lands back on the first stop, and Shift+Tab wraps the other way.
  • Nothing nameless — for each of at.focusables(), assert the utterance is not "(no accessible name)". The highest value per line on this page.
  • State changes are announced — press Space on a checkbox, assert a checked state entry arrived; a widget that repaints without one is the most common accessibility regression there is.

Testing the bridge itself​

The wire half is testable without a desktop too: point AT_SPI_BUS_ADDRESS at an in-process bus (dbus-native's broker), own org.a11y.atspi.Registry on it with a stub Embed, and walk your app with real D-Bus calls — test/atspi.test.js is the worked example, and the model half (roles, names, states as pure functions over the tree) needs no bus at all (test/a11y.test.js). Applications rarely need this layer.

Seeing it without learning a screen reader​

npm run a11y:probe, from a checkout of the react-x11 repository, is the client side of AT-SPI — the side Orca is on — with no GNOME libraries and nothing to configure:

npm run a11y:probe # what is on the accessibility bus
npm run a11y:probe -- widgets # dump that app's accessible tree
npm run a11y:probe -- widgets --watch # follow its events as you click
npm run a11y:probe -- widgets --watch --speak # ...said out loud
npm run a11y:probe -- nautilus # someone else's toolkit, for comparison

Point it at a GTK app and at yours: if the shapes match, a screen reader will treat them the same. The say: lines it prints are a toy model, not what Orca utters, but they make a missing accessible name audible instead of merely absent.

With a real screen reader​

orca --replace --debug-file=/tmp/orca.log # then Tab through your app
grep "SPEECH OUTPUT" /tmp/orca.log

The debug log is the ground truth, and it is easier to read than listening. For audio, speech-dispatcher does the talking (spd-say hello tests it on its own). Orca's own toggle is gsettings set org.gnome.desktop.a11y.applications screen-reader-enabled true; the GUI inspector, if you want one, is accerciser.

Scripting the AT side​

The AT stack is also scriptable through the same library Orca uses:

import gi; gi.require_version('Atspi', '2.0')
from gi.repository import Atspi
desktop = Atspi.get_desktop(0)
apps = [desktop.get_child_at_index(i) for i in range(desktop.get_child_count())]
app = next(a for a in apps if a and a.get_name() == 'myapp')
# walk it, read roles/names/states, do_action(0), set values…

Under react-x11/test the bridge is off by default (NO_AT_BRIDGE), so tests stay deterministic.

What is deliberately not there (yet)​

  • Relations (aria-labelledby, label-for) — use aria-label or name-from-contents.
  • The Selection interface on containers — per-item SELECTED states are exposed, and selection is driven through actions and focus.
  • The Table interface — Table exposes table/row/cell roles with positions, but not the row/column navigation calls; only rendered rows exist in the accessible tree.
  • Key-event forwarding (DeviceEventController.NotifyListenersSync) — typed characters arrive through text-changed events.
  • Soft-wrap line granularity in <textarea>, per above.
  • An input method — dead keys and Compose are the whole composition story, so a script that needs a candidate window (any CJK) cannot be typed.