Accessibility-first development is gaining steam. Nowhere is this more important than for mobile developers
Not only are more people using mobile devices as their primary way to engage on the internet, but mobile devices also present the biggest need for an accessibility-first mindset. Small screens and gesture-powered navigation present unique challenges. So what are the challenges facing developers, and why are some hesitant to encourage accessibility as a primary design priority?
Accessibility implementation takes time, especially when retrofitting an app– one of the reasons why accessibility first is so important. This time may sound expensive or unnecessary to a client or manager who doesn’t understand the benefits of an accessible app. Perhaps more fundamentally, however, accessibility is not part of standard developer training. Whether one is brought into app development via a computer science degree, self-teaching, or a boot camp, little-to-no time is spent discussing accessibility strategies, technologies, or best practices. So, in this post, I’m going to introduce three best practices and three potential gotchas for accessibility development on one of the most popular app-building frameworks, React Native.
* Examples below are given in TypeScript and are taken from this repo.
React Context provides a built-in state management tool that greatly simplifies passing top-level data throughout the component tree. This eliminates the necessity of rewriting the same checks or convenience functions in many children components.
For example, storing a boolean representing the state of the screen reader can be used whenever some custom accessibility logic is needed. In the AccessibilityProvider component, you can use a useEffect to create a listener for the changing screen-reader state. Then, pass the screenReaderIsEnabled boolean.
1a: Setup check for screen readers.
You can access this value using the useContext hook. I commonly use this when ensuring interactive text components are accessible or on carousel screens in order to pause automatic slides. You can also define other useful utilities or values and functions in accessibilityContext. Be sure to put the AccessibilityProvider component near the top of the component tree, probably in App.tsx, so as to have it accessible throughout the app.
const [screenReaderIsEnabled, setScreenReaderIsEnabled] = useState(false);
…
useEffect(() => {
// Create listener for when a screen reader is enable
// If it is, set a state variable to be used throughout the app
const screenReaderListener = AccessibilityInfo.addEventListener(
'screenReaderChanged',
isActive => {
updateScreenReaderStatus(isActive);
},
);
return () => {
screenReaderListener.remove();
};
Creating reusable components to be used throughout the app is essential for efficient development. This method can be leveraged to make your accessibility work easier as well. By placing accessibility props in shared components, you save yourself the trouble of writing the same roles/labels over and over again.
Two common examples, demonstrated below, are perfect use cases for putting accessibility information in a reusable component.
2a: Simplified Reusable Button
export const CustomButton = ({
onPress,
children,
isLoading,
isDisabled,
width,
accessibilityRole,
accessibilityLabel,
accessibilityElementsHidden,
importantForAccessibility,
}: Props) => {
return (
<Button
accessibilityElementsHidden={accessibilityElementsHidden}
importantForAccessibility={importantForAccessibility}
…
accessibilityRole={accessibilityRole ?? 'button'}
accessibilityLabel={isLoading ? 'Loading' : accessibilityLabel}>
{isLoading ? (
<Icon source={loadingIndicator} size={48} />
) : (
<CustomText>{children}</CustomText>
)}
</Button>
);
};
2b: Reusable Icon
const Icon = ({
…
accessibilityLabel,
accessibilityRole,
onPress,
...props
}: IconProps) => {
return (
<TouchableOpacity
…
accessibilityRole={
accessibilityRole ? accessibilityRole : onPress ? 'button' : undefined
}
accessibilityLabel={accessibilityLabel}>
<StyledIcon
…
/>
</TouchableOpacity>
);
};
It’s important to still allow the role/label to be passed to the reusable component as an optional prop. React Native’s accessibilityRole has a set of values it is able to receive, such as header and button, as shown above. Though it would seem that the role of button will always be a button, there are more specific values that can be useful to pass, such as link for when the button opens a browser or email client.
Announcing changes helps keep the UI current for those using screen readers. It can be very frustrating to submit a form or have an error message appear but not be aware of it because there was no announcement. Announcing these changes allows users to properly alter their expectations for the screen.
React Native has a couple of methods for announcing such changes, and the right one may depend on which platform you are developing and how aggressive you want the message to be. For Android, there is a handy prop called accessibilityLiveRegion which will automatically announce the changes whenever the component updates. Logic that dynamically updates the UI is required to appropriately display the message, and the same is true for announcing it. If the error has been resolved, it is likely the component displaying the error will animate out/close. We don’t want to alert the user to this all the time, so you can conditionally add the announcement depending on whether the element is visible or not.
3.1: Announcing an Error Message
const FormErrorMessage = ({
errorMessage,
isVisible,
…
accessibilityElementsHidden,
importantForAccessibility,
}: Props) => {
const growTranslation = useRef(new Animated.Value(0)).current;
…
return (
<Layout
accessibilityElementsHidden={accessibilityElementsHidden}
importantForAccessibility={importantForAccessibility}
accessibilityLiveRegion={isVisible ? 'polite' : 'none'}
style={grow}>
<ErrorContainer>
<Icon source={alert} size={22} />
<ErrorText accessibilityRole="alert">{errorMessage}</ErrorText>
</ErrorContainer>
</Layout>
);
};
FormErrorMessage.tsx
accessibilityLiveRegion can accept either ’polite’ (wait for any message being read to end before reading the announcement), ’assertive’ (interrupt any message being read and read the new message), or ’none’ (say nothing at all). For an error message which appears under an input when form validation fails, ’polite’ is probably the best option, but for a pop-up modal, ’assertive’ is likely to be more appropriate since that is now the only accessible content.
For iOS, a more imperative approach is required. Luckily, React Native has the AccessibilityInfo API. The announceForAccessibility and announceForAccessibilityWithOptions methods can be used to have VoiceOver, iOS’s screen reader, announce messages. We can add this useEffect to the code above to have it behave like accessibilityLiveRegion, but on iOS.
3.2: Announce Error Message on iOS
useEffect(() => {
if (IS_IOS && isVisible) {
announce({message: 'Error alert: ' + errorMessage, queue: true});
}
}, [isVisible, errorMessage, announce]);
FormErrorMessage.tsx
3.3: Announce Reusable Announce Method
const announce = ({message, queue = false, delay}: AnnounceOptions) => {
if (delay) {
setTimeout(() => {
// @ts-ignore for some unknown reason, this method isn't recognized.
AccessibilityInfo.announceForAccessibilityWithOptions(message, {
queue,
});
}, delay);
} else {
// @ts-ignore for some unknown reason, this method isn't recognized.
AccessibilityInfo.announceForAccessibilityWithOptions(message, {
queue,
});
}
};
accessibilityContext.tsx
If the queue option is set to true, then it behaves like ’polite’ for accessibilityLiveRegion. If it is set to false, it behaves like ’assertive’.
Because of the way React Native interprets nested text, in-line links become difficult to handle for accessibility. Essentially, React flattens the text into a string and then does some behind-the-scenes work to apply the appropriate styling. You can read more about it in the official documentation. This is not a problem when all that is changing is the styling, but when a link is nested in the Text component, the accessibility API has no way to access the inner Text component’s unique accessibility props, and the screen reader is unable to select and activate the link. This makes nested links inaccessible. Consider this example:
1.1: Nested Link
<Copy>
Want a list of all the mysteries?{'\n'} Checkout{' '}
<Link
accessibilityRole="link"
onPress={() => Linking.openURL('https://www.imdb.com/list/ls023545027/')}>
IMDB's
</Link>{' '}
ranked list
</Copy>;
Form.tsx
It looks pretty straightforward. There is a link nested inside some text which, when pressed, links out to IMDB. When the screen reader is off, you can tap the Link text. But when the screen reader is on, it can only access the Copy text block as a single string, making the onPress callback in Link unreachable and leaving the accessiblityRole unread. In some cases where the wording is ambiguous, the user may not even know that there is a link to be reached from the text block.
There are a number of solutions to this problem. Perhaps one early thought would be to try un-nesting the text components, putting them inside a container, and styling the container such that it has flexDirection: ‘row’ and justifies the content to make the text look right. Despite the initial appeal, this approach has at least two drawbacks. First, it can make the actual link hard to select since it is likely only a word or two surrounded by more text. Secondly, and more importantly, this solution is only feasible when a link comes either at the beginning or the end of the text block and the length lines up just right. Attempts to finagle such a solution are brittle and overly complicated, if not entirely unfeasible.
Luckily, there is a more practical solution. It’s helpful to remember that accessibility is a part of an app that, in React Native, is written in JavaScript. So we can use JS instantiated logic and utilize the setup explained in the “Best Practices” section to make a clean solution. First, we import the screenReaderIsActive boolean from our accessibilityContext. We then use this value to dynamically determine which text component has the onPress functionality and to set the accessibilityRole.
1.2: Accessible Nested Link
<Copy
accessibilityRole="link"
onPress={
screenReaderIsEnabled
? () => {
Linking.openURL('https://www.imdb.com/list/ls023545027/');
}
: undefined
}>
Want a list of all the mysteries?{'\n'} Checkout{' '}
<Link
onPress={() => Linking.openURL('https://www.imdb.com/list/ls023545027/')}>
IMDB's
</Link>{' '}
ranked list
</Copy>;
Form.tsx
The onPress value in Link can be left alone. It’s not going to hurt anything when the screen reader is enabled. The important part is to make the parent-level Copy component handle the onPress event only when the screen reader is enabled. If it is, the role of the text will be read as link, and selecting the text will open the link. If the screen reader is not enabled, then the parent-level Copy component will not be selectable, which is the expected behavior. The Link component will handle the onPress event.
There is one more important note about nested links which the following code block will demonstrate.
3.3: Doubly Nested Link
<Copy>
Want a list of all the mysteries?{'\n'} Checkout{' '}
<Link
accessibilityRole="link"
onPress={() => Linking.openURL('https://www.imdb.com/list/ls023545027/')}>
IMDB's
</Link>{' '}
ranked list{'\n'}
or{' '}
<Link
accessibilityRole="link"
onPress={() =>
Linking.openURL(
'https://www.tvguide.com/news/british-murdery-mystery-shows-watch-netflix-hulu-amazon-britbox/',
)
}>
TV Guide's
</Link>{' '}
ranked list
</Copy>;
Not In Demo App
As the above example shows, there are now two links in our text block, which is easy enough and straightforward when accessibility is not part of the picture. But this raises serious problems for implementing sensible accessibility solutions. The workaround suggested in this section does not work here because the parent Copy components onPress can only open and handle one link. So it can’t handle the logic of selecting multiple links nested inside.
Perhaps there could be some complicated solution, but from my research and attempts to find a solution to this problem, I think it is safe to consider this design an anti-pattern. The best approach is to talk to your app designer and/or any other stakeholders and explain the limitations of this design and why it’s particularly hard for screen-reader users to navigate. I’ve done this, and the designer clearly understood the issue and graciously refactored the design to be more accessibility friendly by separating the links into distinct text blocks. Problem solved!
Screen readers attempt to follow the flow of the elements on screens. Elements with position set to ‘absolute’ typically disrupt the pattern, and some, most notably Android’s TalkBack screen reader, simply cannot access such elements if they are outside of the space designated to their ‘relatively’ positioned ancestor. If your app is an iOS-only app, this isn’t as big of a concern.
There are a couple of strategies I’ve used to cope with this issue. The appropriate method will depend on the use case. Tooltips are often absolutely placed. Since tooltips typically don’t contain any elements with pressable content, not being able to access them with a screen reader should not be that big of a deal. We can use accessibilityLiveRegion on Android and use the announce method on iOS from accessibilityContext.
2.1: Tooltip Accessibility
if (IS_IOS && showTooltip) {
announce({message: text, delay: 100});
}
}, [showTooltip, text, announce]);
ToolTip.tsx
When the showToolTip boolean is true, announce function will fire. This means when the tooltip opens, the text will be read by the screen reader. Since nothing needs to be pressed, the problems caused by the fact that the element cannot be focused on by the screen reader are moot.
Other times when developers are inclined to use ‘absolute’ positioned elements, accessing the inner content of the element is required. As stated above, on iOS, this is generally fairly straightforward since VoiceOver can still access elements with ‘absolute’ position. But with TalkBack, this generally cannot be done. As such, a bit of creativity with styling is needed to give the appearance of an element that is absolutely positioned.
Consider a dropdown menu. Typically a dropdown menu on a form slides down, covering other inputs, allowing a user to select a dropdown item. This is the perfect scenario for an element with `position` `’absolute’`. But, that is not an option, since the dropdown item would not be able to be selected by TalkBack. Setting the bottom margin to a negative value will achieve the same effect, however, and it will still be accessible! Note, however, that component will still occupy the extra space if the parent container is set to `flex: 1`.
The idea of negative margins may make some devs' skin crawl, but it works well in such a scenario. An example can be found in the demo repo. The trick is to determine the height of the input bar and the dropdown menu. Then you can use the marginBottom styling value to offset the elements below into the position they would naturally occupy if the dropdown menu were set to ‘absolute’. Animation can then handle the sliding effect.
In brief, if the content of your element is interactive, try to avoid ‘absolute’ because it’s difficult for screen readers. There’s almost always a way to achieve the same UI and functionality in an accessible way.
Unfortunately, React Native’s accessibility API has its limitations. The UI isn’t entirely in sync with accessibility, and sometimes, when creating an accessibility announcement or setting the accessibility focus, it can be overridden when React’s accessibility catches up with UI updates. This can be particularly confusing when Android and iOS handle these situations differently.
In lieu of an integrated, uniform way of managing some important accessibility features, creative problem-solving is essential, even if a bit unsatisfying. For example, while Android has the accessibilityLiveRegion, there is no equivalent prop in iOS. An initial thought is to have a useEffect that uses the AccessibilityInfo.announceForAccessibility method. This approach, however, fails because when accessibility registers that the UI has updated, it cuts off the announcement. There are a number of legitimate workarounds, including setting a timeout to delay the announcement so that it trumps the standard accessibility flow. Below is an example of another workaround that ensures that the appropriate message is read, though it comes at the cost of overriding the proper accessibility label of a component.
3.1: Announce Modal
useEffect(() => {
const timer = setTimeout(() => {
if (IS_IOS && visible) {
setHasAnnounced(true);
}
}, announcementDuration);
return () => {
clearTimeout(timer);
setHasAnnounced(false);
};
}, [announcementDuration, visible]);
…
<Icon
size={50}
source={exitIcon}
onPress={handleClose}
accessibilityRole="none"
accessibilityLabel={
hasAnnounced || !IS_IOS ? 'exit modal: button' : accessibilityAnnouncement
}
/>
Modal.tsx
Icon is the component first selected by accessibility, so its accessibilityLabel is temporarily set to the desired announcement when the modal is open. After a certain duration, the hasAnnounced boolean is switched, and the regular accessibility label for Icon is set.
Another example comes from DropDownMenu in the example repo. The desired behavior is that when the DropDown is opened, accessibility focuses on the first option on that menu. When opening the dropdown, accessibility will only read the first option with a call to the AccessibilityInfo.setFocus method, but it won’t focus on it, and therefore it will not be selectable. Again, the solution isn’t ideal, but it’s functionally adequate to simply delay the focusing method.
3.2: Focus on first dropdown menu option
// handles opening and closing dropdown menu
const handlePress = (openStatus: boolean, option?: string) => {
…
// Auto focus on the first menu item
// Android requires a bit of time to properly focus
if (!isOpen && firstOptionRef.current) {
setFocus(firstOptionRef, IS_IOS ? 0 : 500);
}
…
};
DropDownMenu.tsx
// Create a setFocus function so you can simplify focus setting throughout the app
const setFocus = ({ref, delay}: SetFocusOptions) => {
const reactTag = findNodeHandle(ref.current);
if (reactTag) {
if (delay) {
setTimeout(() => {
AccessibilityInfo.setAccessibilityFocus(reactTag);
}, delay);
} else {
AccessibilityInfo.setAccessibilityFocus(reactTag);
}
}
};
accessibilityContext.tsx
iOS doesn’t need the timeout workaround, but Android does, so delaying the focus via this custom function from accessibilityContext.tsx achieves the desired effect and is unobtrusive from a UX perspective.
Creating an accessible app can be difficult, but doing so opens up your business to a broader audience. Accessibility is like every other dimension of development–imperfect, but with some familiarity with the tools available and a little ingenuity, creating a friendly UX for all users is possible.