A thumb pressing a tactile accessibility switch with a scarlet-red rubber dome on a Scandinavian oak surface, headphones blurred beside — the visual marker for mobile-native accessibility-API comparison.
Image description: A thumb pressing a tactile accessibility switch with a scarlet-red rubber dome on a Scandinavian oak surface, headphones blurred beside — the visual marker for mobile-native accessibility-API comparison.

Engineering primer · Mobile a11y APIs

Mobile-native accessibility APIs in 2026: UIAccessibility, AccessibilityNode, and the web

A head-to-head primer on iOS UIAccessibility, Android AccessibilityNodeInfo, and the cross-platform bridges that try to reconcile them — what maps cleanly, what doesn't, and where the mobile web fits in.

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.

2
native APIs compared
3
cross-platform bridges
28
primitives mapped
13 min read
Updated May 2026

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.

SwiftUI is the same protocol, with a friendlier facade

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.

The “live region” mismatch

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.

React Native 0.76
JS bridge to native UIKit and Android View
The most explicit mapping — and the leakiest
iOS bridgeaccessibilityLabel, 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.
Android bridgeThe same JS props map to AccessibilityNodeInfo via a Yoga-side adapter; accessibilityRole=“button” sets className to android.widget.Button.
GotchaThe accessibilityLiveRegion prop is Android-only — on iOS it silently does nothing, and you have to call AccessibilityInfo.announceForAccessibility() manually.
Flutter 3.27
Custom rendering · synthetic a11y tree
The most uniform — and the most opaque
ApproachFlutter renders everything on a Skia canvas, so it builds its own SemanticsNode tree and serializes it to the platform on demand.
iOS pathSemanticsNodes are translated into UIAccessibilityElement instances on the Flutter view, with traits mapped from the SemanticsAction and SemanticsFlag sets.
Android pathThe same SemanticsNode tree is serialized into AccessibilityNodeInfo nodes by Flutter’s Android view; actions become AccessibilityActions; live region becomes SemanticsFlag.isLiveRegion.
Kotlin Multiplatform · Compose Multiplatform
Shared Compose runtime · per-target a11y
The newest, with the most platform-shaped seams
ApproachCompose’s Modifier.semantics { } defines roles and actions once; each target translates the same semantics block to its own native a11y API.
iOS targetThe Compose-for-iOS runtime walks the semantics tree and constructs UIAccessibilityElements — but the iOS implementation is younger than Android’s and still missing several semantic kinds.
Android targetThe mature path: semantics become AccessibilityNodeInfo via the same compose-ui-semantics layer Android-native Compose uses.

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

— Disability World engineering desk, May 2026

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.

Two views of the same DOM
In a Mobile Safari tab
<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.

Inside the same DOM in a WKWebView
<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.

The cross-platform fix that actually works

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.

CapabilityiOS UIAccessibilityAndroid AccessibilityNodeInfoReact Native 0.76Flutter 3.27
Hint text (longer explanation)accessibilityHinttooltipText (API 28+)accessibilityHint (iOS only)SemanticsProperties.hint
Live region politenessN/A — imperative post onlysetAccessibilityLiveRegion()accessibilityLiveRegion (Android only)SemanticsFlag.isLiveRegion
Hide subtree from a11yaccessibilityElementsHidden (children only)importantForAccessibility=“noHideDescendants”accessibilityElementsHidden / importantForAccessibilityExcludeSemantics widget
Custom action (rotor / menu)UIAccessibilityCustomActionAccessibilityNodeInfo.AccessibilityActionaccessibilityActions + onAccessibilityActionSemanticsAction with custom label
Adjustable / slider semanticsUIAccessibilityTraitAdjustable + accessibilityIncrementRangeInfo + ACTION_SCROLL_FORWARDaccessibilityRole=“adjustable” + handlersSlider exposes SemanticsAction.increase
Heading levelUIAccessibilityTraitHeader (no level)setHeading(true) (no level)accessibilityRole=“header” (no level)SemanticsProperties.headingLevel (1–6)
Selected / toggled stateUIAccessibilityTraitSelectedsetSelected(true) + setCheckable()accessibilityState={selected, checked}SemanticsFlag.isSelected
Group / container semanticsshouldGroupAccessibilityChildrensetScreenReaderFocusable(true)accessible={true} on parentMergeSemantics widget
Announce one-shot messageUIAccessibility.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

1

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.

2

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.

3

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.

4

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.

5

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

— Disability World engineering desk, May 2026