Play
  1. Tyamka
  2. Devlog
  3. What react-native-web quietly drops for screen readers

What react-native-web quietly drops for screen readers

· 5 min read · Tyamka team

Three accessibility features that React Native for Web 0.21 accepts without a warning and ignores on the web, how our audit caught them, and the small fixes in our shared tap component and HUD slots.

In this article
  1. How we found it
  2. accessibilityState never reaches the DOM
  3. role text becomes a div with no name
  4. Announcements are empty functions
  5. What we did not check

React Native for Web 0.21 accepts some React Native accessibility props and does nothing with them, without a warning. accessibilityState never reaches the DOM, so a switch reaches the accessibility tree without its on or off state. A view with accessibilityRole="text" becomes a plain div, an element ARIA does not allow to carry a name, so its accessibilityLabel is lost. AccessibilityInfo.announceForAccessibility is an empty function. We ran into all three in an accessibility audit of the Tyamka web build on 26 September.

How we found it

Tyamka is one Expo and React Native codebase for the web, Telegram, iOS and Android, and an earlier entry explains why. The audit ran on the web build.

We used Playwright 1.63 with Chromium, a 402×874 window, in English and Ukrainian. For every screen, from home through all fifteen games to the results and the shop, we took the page's ARIA snapshot, listed every focusable element with its role, name and size, and read the live region for four seconds after each game started. Playwright builds that snapshot from the page by the ARIA rules for roles, names and states, which is close to what a screen reader gets, though not the same thing.

No control was smaller than 44 by 44 or missing a name, and every grid cell was its own button with its position and state in its label. The audit logged 16 problems, five of them P1. Three of those five came from the first two findings below, React Native accessibility props that the web build ignored.

accessibilityState never reaches the DOM

The sound and music toggles on the instructions screen are switches. Our shared Tap component passed their state the React Native way.

<Pressable
  …
  accessibilityRole={role}
  accessibilityLabel={label}
  accessibilityState={{ disabled, checked }}
  disabled={disabled}

In the snapshot each toggle was a switch with a name and no checked state, so a screen reader could say "Sounds, switch" but not whether it was on. The two purchase options are radio buttons, and neither was marked as selected.

The cause is in createDOMProps, the module that turns props into DOM attributes. It reads aria-checked but never accessibilityState, and View does not include it in the props it forwards, so the object is dropped before anything looks at it. The library has deprecation warnings for the old accessibility* props, but in the 0.21.3 source they are commented out, and nothing shows up in the console. The React Native for Web accessibility docs do say that components express semantics "exclusively via the aria-* props". Our disabled state survived only because Pressable turns its own disabled prop into aria-disabled.

The first fix was one line, aria-checked={checked}, and it was too broad. Tap also draws plain buttons, and ARIA does not allow aria-checked on the button role, so a commit three minutes later narrowed it.

accessibilityState={{ disabled, checked }}
aria-checked={role === 'switch' || role === 'radio' ? checked : undefined}

React Native 0.86 accepts aria-checked on iOS and Android too, so accessibilityState could go. We left it, because native already worked and the smallest diff was adding one prop. After the fix the snapshot showed switch "Sounds" [checked] and one radio marked [checked].

role text becomes a div with no name

The lives counter in the game HUD draws three shapes and shows no number. For screen readers it had a label.

<View style={…} accessible accessibilityRole="text" accessibilityLabel={label} testID="lives">

On the web the label was gone from the tree. React Native for Web maps React Native roles to ARIA roles in propsToAriaRole, and text maps to null, which means no role at all. With no role, View renders a div and still puts the label on it as aria-label. A div without a role is a generic element, and ARIA 1.2 lists generic among the roles that cannot be named, so browsers and screen readers are free to ignore that label. Our snapshot no longer had it.

The same thing hid two other labels. In Target Sums the row and column targets carry the current sum and the "done" mark only in their labels. In Word Grid the word panel carries the seconds left.

The fix was the image role for views that speak only through their label.

<View style={…} accessible accessibilityRole="image" accessibilityLabel={label} testID="lives">

image maps to img, which can have a name, so the tree now shows img "Lives 3 of 3". The cost is that a screen reader will most likely call the counter an image. We took that over a counter that says nothing. The animated score on the results screen got the same role later, because its label was dropped the same way while the number counted up.

Announcements are empty functions

The third one was not a P1, because the game shell had worked around it from the start. In React Native for Web's AccessibilityInfo, announceForAccessibility and setAccessibilityFocus have empty bodies, and isScreenReaderEnabled always resolves to true. There is no screenReaderChanged event either, so a web app cannot tell whether a screen reader is running.

The shell announces in two ways.

if (Platform.OS === 'web') setAnnouncement((shown) => nextAnnouncement(shown, message));
else AccessibilityInfo.announceForAccessibilityWithOptions(message, { queue });

On the web the message goes into a visually hidden Text with accessibilityLiveRegion="polite", which becomes aria-live="polite". A live region only speaks when its content changes, and React does not touch the DOM when the new text equals the old. In Pipes every solved puzzle announces the same word, "Solved", so the second one was silent. That part is browser and React behavior, not the library. When a message equals the one on screen, nextAnnouncement appends a no-break space, and the region gets new text.

What we did not check

The fixes went into Tap, the slots of the default skin and three other skins that draw their own lives counter. The next morning the same snapshots on the test build showed every fix. We have not tested with a live screen reader. VoiceOver, NVDA and TalkBack were not part of the audit, so everything above is what Playwright's snapshot showed in Chromium, not what a person hears.

If you ship React Native to the web, compare the ARIA snapshot of one screen with what the same screen says on a phone, starting with every prop whose name begins with accessibility.

The games with the switches, lives counter and grids from this entry are on the games page. Target Sums and Pipes are among the logic games, and Word Grid is with the word games.