Munchable is a gut-health scanner: point it at a barcode and it tells you whether that product fits the conditions you manage, with the reasons under the verdict. The conditions are described at munchable.app/conditions and the same reasoning is published as pages at munchable.app/answers. The app itself is React Native through Expo.
I have audited the website's accessibility twice and never audited the app, so I ran the greps. Here is the whole picture, in numbers, including the bits that are wrong.
The counts
Across app/ and src/:
<Pressable 49
accessibilityRole 36
accessibilityLabel 44
accessibilityState 18
accessibilityHint 3
hitSlop 15
accessibilityElementsHidden 6
importantForAccessibility 6
accessibilityLiveRegion 0
announceForAccessibility 0
Thirty-one of the 36 roles are button, three are image, one is computed. There are more labels than roles, which sounds backwards and is not: a label belongs on a control whose visible content is an icon, and a role belongs on anything tappable, so the sets overlap without matching.
Two of those numbers are the interesting ones. The matching sixes, and the zeroes.
The matching sixes are the one idiom you cannot do with one prop
Hiding something from a screen reader in React Native takes two props, because the platforms disagree:
<View
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
>
accessibilityElementsHidden is iOS. importantForAccessibility="no-hide-descendants" is Android, and the value matters: "no" hides the view and leaves its children exposed, which on a decorative wrapper is the bug you were trying to fix. Six sites in the app hide a subtree, and all six set both props. Two of the six are pure decoration, a glow and a biscuit-textured card, and the other four are controls that compose their own announcement.
Here is the most useful of the six. The verdict screen's hero is a big coloured disc with an icon in it, above the verdict word:
{/* Decorative. The verdict is already a visible word in the Text below,
and labelling the disc as well made VoiceOver read the answer twice,
once as an image and once as text. */}
<View
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
>
<Icon name={VERDICT_ICON[verdict]} color={c.on} size={58} />
</View>
The instinct with an icon that carries meaning is to label it. But the meaning is already on the screen as text directly underneath, and a screen reader does not know those two nodes are the same statement. It reads both. Hearing your answer twice is not twice as clear, it is a stutter that makes you wonder if you misheard the first one.
The row that reads out as one sentence
The best accessibility code in the app is a settings row. On screen it is a wrapped set of chips with an "Edit" link on the right. To a screen reader, chip-by-chip, it would be eleven stops that each say one word.
So it is one stop instead:
<Pressable
onPress={onPress}
accessibilityRole="button"
accessibilityLabel={`${accessibilityLabel}: ${
empty ? 'none listed' : chips.map((c) => c.label).join(', ')
}`}
accessibilityHint={empty ? 'Add' : 'Edit'}
style={{ /* ... */ minHeight: 44 }}
>
with both of its halves hidden from the screen reader underneath. The label is composed from the same data the chips render, so it cannot drift from what is on screen, and the component's prop for it is documented as a question rather than a string: "What the row is a list of, for the screen reader: Your allergies". That framing is why the output reads as English. "Your allergies: milk, soya, mustard" and a hint of "Edit" is the row, spoken.
One of the app's three accessibilityHint uses is here. A hint is for an action whose consequence is not obvious from its label, and almost nothing in this app qualifies, which is why three is the right number rather than a gap. The other two are on the shared selectable row, where the hint is the explanatory line under the row's title, and on the crop canvas, where the gesture genuinely needs explaining.
That selectable row carries the one other thing in this audit worth copying. It is used for conditions, for shopping preferences, for onboarding and inside the multi-picker, and it has to work in the app's web build too:
accessibilityRole={role}
accessibilityState={{ checked: selected }}
// `accessibilityState` alone is dropped on react-native-web, so a row
// announced as a checkbox never said whether it was ticked in the browser
// build. The ARIA prop is understood on every platform, so both go on.
aria-checked={selected}
A checkbox that never says whether it is checked is worse than a plain button, because the user has no way to find out what they already selected. Two props for one fact, because the cross-platform layer only forwards one of them.
The zeroes: the answer arrives and nothing says so
Now the uncomfortable half.
Scanning is asynchronous. You point at a barcode, the screen shows a resolving state, and a moment later a verdict lands. Visually that is a fade in with a haptic tap alongside it, and the haptic is chosen to match the answer rather than being one generic buzz.
Nothing announces it. There is no accessibilityLiveRegion, and no call to AccessibilityInfo.announceForAccessibility anywhere in the app. A sighted user sees the answer appear; a VoiceOver user is sitting on whatever element they last focused, and the screen has silently changed underneath them.
The haptic is a genuine non-visual channel, and the comment on it says as much:
The haptic stays. A tap that matches the verdict is the non-visual half of the
answer, which is worth more to somebody with Reduce Motion on, not less.
Three different taps for good, caution and avoid is more than most apps give you. But a vibration pattern is not a word. The product is a sentence about a food, and the sentence has to be speakable.
This is the fix I would most like in the app, and it is small: one announcement when the verdict transitions from resolving to resolved, saying the verdict word and the condition it belongs to, fired once per answer rather than once per render. The render-count part is not hypothetical, the result screen already guards its haptic that way because an unchanged answer was buzzing the phone on every pass.
The two controls that announce nothing
Of the 49 Pressable call sites, the ones that worry me are not the many: the app's Button component sets accessibilityRole="button" and accessibilityState={{ disabled }} in one place, so every call site through it is correct by construction, and its label is the visible text.
The worry is the hand-rolled ones. Two of them are icon-only controls with no role and no label at all:
// onboarding: step back
<Pressable onPress={() => setI(Math.max(0, idx - 1))} style={{ marginBottom: t.spacing.sm }}>
<Icon name="chevron-left" color={t.colors.textMuted} size={26} />
</Pressable>
// sign-in: close
<Pressable onPress={goBack} style={{ marginBottom: t.spacing.sm }}>
<Icon name="x" color={t.colors.textMuted} size={26} />
</Pressable>
A screen reader reaching either of those finds a view with nothing in it worth saying. Not "button, unlabelled". Nothing to stop on at all, in the one place a new user needs an escape hatch, because both of these are the top-left control on an early screen.
What makes it a process failure rather than an oversight is that the app already does this correctly twice. The camera chrome's icon buttons and the modal header's close button both carry a role, a label and a state:
<Pressable
onPress={onClose}
hitSlop={10}
accessibilityRole="button"
accessibilityLabel="Close"
>
Same control, same icon, correct in the component and missing in the screen. Components get reviewed as components. A chevron added inline while building a flow gets reviewed as part of the flow.
There are two softer versions of the same bug. First, controls with a label and no role: the capture screen's close button and the re-consent banner's dismiss both say "Close" and "Dismiss" without saying "button", so they are findable but do not read as actionable.
Second, text-only controls with no role at all. The profile screen has four in a row:
<Pressable onPress={() => void exportData()} style={{ paddingVertical: t.spacing.md }}>
<Text variant="bodyStrong" color={t.colors.link}>
Download my data
</Text>
</Pressable>
"Sign out", "Get help", "Download my data" and "Delete my account", each a bare Pressable around a coloured line of text. A screen reader reads the words, so the user hears "Delete my account" and has no indication that it is something they can activate, or that activating it is the destructive one. Text alone does not make a control, and a link colour is not available to somebody who is not looking. accessibilityRole="button" is what turns a sentence into an affordance.
What I would enforce, and what I would not
The honest state: 15 hitSlop declarations, so the small controls are at least hittable; 18 accessibilityState uses, so disabled and selected are spoken; a settings row that reads as one clean sentence; two unlabelled escape hatches; four unannounced actions on the one screen that can delete your account; and silence when a verdict arrives.
The rule worth writing into review is narrow enough to be checkable: a Pressable must carry a role, and if its only child is an Icon it must also carry a label. That is a lint rule over JSX rather than a judgement call, and it catches every case above, including the four on the profile screen that a human reviewer reads straight past because the words are right there on the screen.
The rule not worth writing is a label on everything. Four of the six hidden subtrees in this app exist precisely because labelling every node made the screen reader say more and communicate less. Accessibility work on a screen whose whole job is to deliver one short answer is mostly about deleting announcements until one clear one is left.
Look at the output a different way
If you want to see what the app is actually trying to say, the same reasoning is published as text: munchable.app/answers has a page per ingredient and condition, and munchable.app/hidden-gluten-ingredients is the reference list. Those pages are plain server-rendered HTML with a heading structure, so they are the version a screen reader has an easy time with, and they were the first thing I checked after the app.
The scan flow itself, with the verdict hero described above, is demonstrated on the landing page at munchable.app. Turn VoiceOver or TalkBack on for a minute on any app you ship and move through one flow with the screen off. It is a faster audit than any grep, and the grep is only useful because it tells you which file to open afterwards.
Top comments (0)