Mobile-native accessibility APIs in 2026:
UIAccessibility, AccessibilityNode, and the web
iOS and Android each expose a fully-featured accessibility tree to the platform screen reader — and the two trees do not agree on anything past the label-and-role basics. We mapped every primitive that VoiceOver and TalkBack actually consume in 2026, the way React Native, Flutter, and Kotlin Multiplatform try to bridge them, and the place where mobile WebView accessibility quietly falls off a cliff.
1. iOS UIAccessibility — labels, traits, hints, values
Every visible thing on an iOS screen has, or can have, an accessibility representation. Apple ships that representation through the UIAccessibility informal protocol, implemented by UIView and every system control, and through UIAccessibilityElement, a lightweight class you allocate for the bits of your interface that are drawn but are not themselves views — characters in a custom-drawn chart, glyphs inside a Core Graphics scene, regions inside a CALayer. VoiceOver, Switch Control, Full Keyboard Access, and Voice Control all consume the same protocol; learning it once buys you four assistive technologies.
The protocol exposes four primitives that matter for almost every screen. The accessibility label is the short, human-readable name of the element — “Send”, “Profile photo of Asha”, “Back”. The accessibility traits are a bitmask of role-like flags — .button, .header, .image, .selected, .adjustable, .staticText, .updatesFrequently — that tell VoiceOver how to behave around the element and which gestures to enable. The accessibility value is a string representation of the current state (“On”, “75%”, “Thursday, May 22”). The accessibility hint is the longer, optional explanation (“Double-tap to open the photo viewer”) that VoiceOver speaks after a delay if the user does not act on the label alone.
The four primitives compose. A switch reads as label + trait + value: “Wi-Fi, switch button, On”. A slider reads as label + trait + value + hint: “Volume, adjustable, 60 percent, swipe up or down to adjust”. A custom drawn chart bar reads as a chain of UIAccessibilityElements, each one with a label, a value, and a frame inside its container. The chain is the API surface — VoiceOver walks it linearly when the user swipes right, and respects the order in which you publish the elements through the container’s accessibilityElements array.
The .accessibilityLabel(), .accessibilityValue(), .accessibilityHint(), and .accessibilityAddTraits() view modifiers in SwiftUI compile down to the same UIAccessibility properties on the underlying UIView. SwiftUI also adds .accessibilityElement(children:), which solves the “characters in a chart” problem more declaratively than the UIKit approach — but the runtime contract VoiceOver sees is identical. Learning the UIKit names is still worth your time, because every Apple sample, every Stack Overflow answer, and every accessibility audit speaks in them.
2. Android AccessibilityNodeInfo — roles, actions, importantForAccessibility
Android takes a different route. Where iOS hangs accessibility off a flat protocol on every view, Android serializes the entire accessibility tree as a graph of AccessibilityNodeInfo objects, each one a snapshot of a view at the moment a TalkBack query arrives. The framework constructs the snapshots lazily; a View publishes its node by overriding onInitializeAccessibilityNodeInfo() (or, in Compose, by setting semantics modifiers), and the platform stitches the parent-child relationships into a tree that mirrors the view hierarchy.
The primitives differ from iOS in three meaningful ways. First, Android exposes a role via a string-typed className field — android.widget.Button, android.widget.CheckBox, android.widget.EditText. TalkBack reads the class name and decides how to announce (“button”, “checkbox”, “edit box”). Compose translates its Role.Button, Role.Checkbox, Role.RadioButton semantics into the same field. The role is more granular than an iOS trait bitmask, but also more rigid — there is no “all the way custom” role unless you accept the announcement as “view”.
Second, Android represents interactivity as a set of actions attached to the node: ACTION_CLICK, ACTION_LONG_CLICK, ACTION_SCROLL_FORWARD, ACTION_SET_TEXT, ACTION_FOCUS, and a long list of custom actions you can register with AccessibilityNodeInfo.AccessibilityAction. TalkBack surfaces the custom actions via the “actions” rotor — the user swipes up with one finger and hears each custom action by name. iOS has the same concept (UIAccessibilityCustomAction), but on Android the action list is the surface; on iOS the gesture vocabulary is.
Third, Android has importantForAccessibility, a per-view enum (auto, yes, no, noHideDescendants) that controls whether the node appears in the tree at all. noHideDescendants is the single most powerful tool in Android accessibility and the one most often forgotten — it removes the entire subtree from TalkBack’s traversal, the equivalent of aria-hidden=“true” on the web. iOS has no exact analog; the closest is setting accessibilityElements to an empty array on the container, which only removes the container’s direct children, not the whole subtree.
Android exposes ViewCompat.setAccessibilityLiveRegion() with three values: none, polite, assertive. The vocabulary mirrors ARIA — almost. TalkBack honors the politeness levels reliably. iOS has nothing comparable at the protocol level: you announce updates by calling UIAccessibility.post(notification: .announcement, argument: “Saved”), an imperative one-shot that does not attach to a view. Cross-platform bridges have to fake one of these on top of the other, and the impedance mismatch shows up in every framework reviewed in section 3.
3. Cross-platform bridges — React Native, Flutter, Kotlin Multiplatform
Every cross-platform mobile framework has to take the two APIs above and present a single, framework-shaped surface. None of them succeeds entirely. The three approaches dominate the market in 2026 — React Native, Flutter, and Kotlin Multiplatform with Compose Multiplatform — each one a slightly different bargain between leakage and abstraction.
accessibilityLabel, accessibilityHint, accessibilityRole, accessibilityState on Pressable and View map almost 1:1 to UIAccessibility — but the role names are the React Native vocabulary, not the iOS one.accessibilityRole=“button” sets className to android.widget.Button.accessibilityLiveRegion prop is Android-only — on iOS it silently does nothing, and you have to call AccessibilityInfo.announceForAccessibility() manually.UIAccessibilityElement instances on the Flutter view, with traits mapped from the SemanticsAction and SemanticsFlag sets.SemanticsFlag.isLiveRegion.Modifier.semantics { } defines roles and actions once; each target translates the same semantics block to its own native a11y API.The pattern across all three is the same: a synthetic, framework-shaped semantic tree on one side, two platform-shaped accessibility trees on the other, and a translator in between that handles the simple cases well and the complex ones with a noticeable loss of fidelity. The simple cases — a button with a label, an image with alt text, a heading — round-trip with no loss. The complex cases — a custom gesture with two finger swipes, a chart whose elements should be a focusable group, a live region that has to fire on iOS without a view-bound politeness setting — leak the underlying platform’s vocabulary up into the cross-platform code, or simply fail to translate.
”The first 80 percent of mobile accessibility is identical across every framework. The last 20 percent is where every framework reveals which native API it secretly thinks in.”
4. The WebView gap — when mobile-web accessibility quietly fails
Both iOS and Android render web content through a system WebView — WKWebView on iOS, android.webkit.WebView (or Chrome Custom Tabs) on Android. In each case, the WebView is a black box from the host app’s perspective: the app sees a single view, but the screen reader sees the entire DOM accessibility tree inside it. The bridge between the two trees is the place where a surprising amount of mobile accessibility goes silently wrong.
The mechanism is, on its face, straightforward. When a screen reader’s focus enters a WebView, the platform reads the document’s accessibility tree directly from the browser engine — WebKit on iOS, Blink on Android — and traverses it as a sub-tree of the host app’s tree. The web’s roles, labels, and ARIA attributes are translated into the platform’s vocabulary in real time. A button element with no explicit role inside the WebView reads as a button on both platforms; an aria-live=“polite” region announces correctly on both; an aria-label on a link surfaces as the link’s accessibility label. For the first three years of mobile-web life, this just worked.
The cliff appears in three places. First, custom gestures defined in the host app — a two-finger swipe to dismiss, a magic-tap to play and pause — are invisible to the WebView’s content; they fire on the wrong target or do not fire at all when focus is inside the document. Second, the host app’s UIAccessibilityElements drawn over the WebView (a floating action button, a custom toolbar) compete with the WebView’s tree for traversal order, and the resulting reading order is non-deterministic across iOS versions. Third — and this is the largest single failure mode in mobile-web accessibility — the WebView on iOS does not honor aria-live politeness levels the way Safari does in a tab: WKWebView’s announcement plumbing drops the polite versus assertive distinction, so every live update is treated as polite regardless of the markup.
<div role="alert" aria-live="assertive">
Connection lost — retrying.
</div>VoiceOver in a normal Safari tab interrupts the current utterance and speaks the message immediately. The assertive politeness is honored end-to-end through WebKit.
<div role="alert" aria-live="assertive">
Connection lost — retrying.
</div>Same markup, same browser engine — but the WKWebView’s accessibility bridge to UIKit demotes the announcement to a deferred polite message. The user hears it after a delay, sometimes after they’ve already typed into the now-broken form.
For announcements inside a WebView, the only reliable cross-platform pattern in 2026 is to expose a JavaScript bridge into the host app — a tiny postMessage handler — and route assertive announcements out of the DOM, into the host app, and back through UIAccessibility.post(notification: .announcement, …) on iOS or announceForAccessibility() on Android. The web’s aria-live survives only for genuinely polite messages where a few seconds of latency are acceptable.
5. The mapping table — what corresponds to what
We mapped 28 primitives that VoiceOver and TalkBack actually consume in practice — the union of the iOS UIAccessibility protocol surface, the Android AccessibilityNodeInfo surface, and the most-used React Native and Flutter cross-platform props. The table below captures only the contested rows: the primitives where the mapping is incomplete, asymmetric, or surprising. Rows where the mapping is clean (label, button role, image role, heading) have been omitted for length.
| Capability | iOS UIAccessibility | Android AccessibilityNodeInfo | React Native 0.76 | Flutter 3.27 |
|---|---|---|---|---|
| Hint text (longer explanation) | accessibilityHint | tooltipText (API 28+) | accessibilityHint (iOS only) | SemanticsProperties.hint |
| Live region politeness | N/A — imperative post only | setAccessibilityLiveRegion() | accessibilityLiveRegion (Android only) | SemanticsFlag.isLiveRegion |
| Hide subtree from a11y | accessibilityElementsHidden (children only) | importantForAccessibility=“noHideDescendants” | accessibilityElementsHidden / importantForAccessibility | ExcludeSemantics widget |
| Custom action (rotor / menu) | UIAccessibilityCustomAction | AccessibilityNodeInfo.AccessibilityAction | accessibilityActions + onAccessibilityAction | SemanticsAction with custom label |
| Adjustable / slider semantics | UIAccessibilityTraitAdjustable + accessibilityIncrement | RangeInfo + ACTION_SCROLL_FORWARD | accessibilityRole=“adjustable” + handlers | Slider exposes SemanticsAction.increase |
| Heading level | UIAccessibilityTraitHeader (no level) | setHeading(true) (no level) | accessibilityRole=“header” (no level) | SemanticsProperties.headingLevel (1–6) |
| Selected / toggled state | UIAccessibilityTraitSelected | setSelected(true) + setCheckable() | accessibilityState={selected, checked} | SemanticsFlag.isSelected |
| Group / container semantics | shouldGroupAccessibilityChildren | setScreenReaderFocusable(true) | accessible={true} on parent | MergeSemantics widget |
| Announce one-shot message | UIAccessibility.post(.announcement, …) | view.announceForAccessibility() | AccessibilityInfo.announceForAccessibility() | SemanticsService.announce() |
Three patterns leap out of the table. First, the asymmetry around live regions is the single biggest source of cross-platform divergence — Android has a per-view politeness setting, iOS has only a global imperative post, and every framework above is forced to lie about the difference. Second, heading levels are the one place Flutter genuinely improves on both native platforms; the iOS and Android primitives only know “this is a heading”, not “this is an H3 under an H2”. Third, the “hide from accessibility” primitive is more flexible on Android than on iOS — noHideDescendants hides an entire subtree in one move, while iOS requires you to hide each container’s children individually.
6. The mobile-native playbook
Learn the native vocabulary before the framework vocabulary
Every cross-platform bridge — React Native, Flutter, Compose Multiplatform — has its own naming for accessibility props, and every one of those names is a slight lie about what the underlying platform actually does. When a screen reader does not announce correctly, the bug almost always lives in the native API the framework translated to, not in the framework prop you set. Read the UIAccessibility docs and the AccessibilityNodeInfo docs at least once; the framework docs make sense only afterward.
Test live announcements on iOS specifically
The live-region asymmetry from section 2 means that any code which assumes aria-live=“assertive” or accessibilityLiveRegion=“assertive” works is going to silently degrade on iOS. Build a small test harness that fires both a polite and an assertive announcement on both platforms, with VoiceOver and TalkBack on real devices, before shipping any feature whose UX depends on the user hearing a state change.
Bridge out of WebViews for anything assertive
The WKWebView demotion of assertive announcements is not a bug Apple will fix soon — it has been the same in every iOS release from 14 onward. If you ship a hybrid app where the user can encounter a fatal error inside a WebView, route the announcement through a JS bridge to the host and let the host fire the platform announcement. Web alone is not enough.
Use the framework’s “merge” or “group” semantic, not children-by-children
Both iOS (shouldGroupAccessibilityChildren), Android (setScreenReaderFocusable), and Flutter (MergeSemantics) provide a way to collapse a visual cluster — an icon plus a label plus a value — into a single accessibility element. Use it. The default “every leaf is a focusable element” behavior turns a six-element navigation chip into six VoiceOver swipes.
Audit with Accessibility Inspector and TalkBack Developer Settings
Both platforms ship a free, official inspector for the live accessibility tree — Accessibility Inspector on macOS (paired with the connected iOS simulator or device), and the “Show accessibility focus” plus “Developer settings” overlay on Android. Use them to read your own app’s tree the way the screen reader sees it; do not assume the framework’s debug logging shows you the same thing the platform shows TalkBack.
Conclusion: the framework is downstream of the platform
It is tempting to believe — and the framework documentation encourages this belief — that a cross-platform accessibility API is a single, unified abstraction over two equivalent native APIs. The mapping table in section 5 disproves the unification. The two native APIs were designed independently, by two different teams, around two different mental models of how the screen reader should walk a document; the differences are real, they leak through every framework, and the leakage shows up in the parts of the user experience that matter most — live updates, custom gestures, hidden subtrees, heading hierarchies.
The good news, after that paragraph: the basics map. A button with a label, an image with alt text, a heading at the top of a section — those round-trip through every framework and announce correctly on both platforms. If you ship only those primitives, you do not need to think about UIAccessibility or AccessibilityNodeInfo; the framework’s defaults are honest. The trouble starts when the user interface starts to do something interesting, which is also when accessibility starts to matter most.
The playbook in section 6 is the shortest version of the argument that gets the most disabled users to a working experience: think in native primitives first, test on real devices on both platforms, bridge out of WebViews when you mean it, group leaf nodes deliberately, and use the official inspectors. The framework you chose helps with the first 80 percent and gets out of your way for the last 20 percent. That last 20 percent is where the screen-reader user lives.
”VoiceOver and TalkBack are reading two different documents from the same source code. Whether the user notices the difference is a measure of how well you understood the platform underneath your framework.”