Repository navigation
[iOS 26/27] VoiceOver keeps the selected trait on every visited tab when tabs have icons #590
Description
Bug summary
On iOS 27, VoiceOver keeps the selected accessibility trait on every tab that has been visited, instead of moving it to the active one. After visiting all three tabs of my app, swiping across the tab bar announces all three as "selected". The visual state is always correct — only the accessibility trait is wrong, so this is invisible unless you actually listen to it.
The trigger is tabBarIcon. With icons removed (text-only tabs) the trait behaves correctly; putting the icons back brings the bug straight back. I verified this on a real device with three over-the-air updates over the same binary, changing one thing at a time:
| # | Change | Result |
|---|---|---|
| 1 | no tabBarStyle and no tabBarIcon |
correct |
| 2 | icons back, still no tabBarStyle |
bug returns |
| 3 | same app on a different tab library, icons present | correct |
Step 2 is what isolates it: with the background already gone, the icons were the only variable left.
What first pointed me at the icons was comparing two of my own apps: both use createNativeBottomTabNavigator from @bottom-tabs/react-navigation 1.4.0, unpatched, on the same iPhone running iOS 27. One misbehaves, the other does not — and the one that does not declares no tabBarIcon on any tab.
Where I think it comes from
ios/TabViewImpl.swift reconfigures the bar from a SwiftUI introspection that runs on every layout pass, and it writes to the UITabBarItems twice, once synchronously and once deferred:
private func updateTabBarItemImages(props: TabViewProps, tabBar: UITabBar?) {
guard let tabBar, let items = tabBar.items else { return }
configureTabBarItemImages(items: items, props: props)
DispatchQueue.main.async { [weak tabBar] in
guard let tabBar, let items = tabBar.items else { return }
configureTabBarItemImages(items: items, props: props)
}
}Inside configureTabBarItemImages, the part that only runs when an icon exists:
if let icon {
item.image = renderTabBarIcon(icon, …)
item.selectedImage = renderTabBarIcon(selectedIcon, …)
}Reassigning image / selectedImage makes UIKit rebuild that tab's button. That was harmless before, but on iOS 26/27 the bar is driven by UITab objects and the new shouldSelectTab(_:) delegate — the one this library already had to special-case in ios/TabItemEventModifier.swift:
if #available(iOS 27.0, *) {
// iOS 27 routes SwiftUI TabView selection through shouldSelectTab.
return true
}Rebuilding the buttons behind that mechanism, twice per pass, looks like where the .selected trait is left on the old button rather than cleared. It also matches when it shows up: the trait gets stuck the first time each tab is opened, which is when the lazy scene mounts and triggers another layout pass.
I could not find any way to avoid it from JavaScript: that block runs whenever an icon is present, SF Symbol or image.
Library version
react-native-bottom-tabs 1.4.0 / @bottom-tabs/react-navigation 1.4.0
Environment info
- iOS 27 (real device, not simulator — VoiceOver traits cannot be checked in the simulator)
- react-native 0.86.2, New Architecture
- Expo SDK 57 (~57.0.15), react 19.2.3
- react-native-screens ~4.26.0
Steps to reproduce
- Build an app with
createNativeBottomTabNavigatorand three tabs, each with atabBarIcon. - Run it on a device with iOS 27 and turn VoiceOver on.
- Swipe through the tab bar: only the first tab is announced as selected. Correct so far.
- Double-tap the second tab, then swipe back across the bar.
- Expected: only tab 2 is announced as selected.
Actual: tabs 1 and 2 are both announced as selected. - Repeat with the third tab: all three are announced as selected.
- Remove every
tabBarIconand repeat — the trait now moves correctly.
Reproducible sample code
const Tab = createNativeBottomTabNavigator();
// Bug present. Remove the three `tabBarIcon` lines and it goes away.
<Tab.Navigator>
<Tab.Screen
name="Home"
component={HomeScreen}
options={{ tabBarLabel: 'Today', tabBarIcon: () => ({ sfSymbol: 'sun.max.fill' }) }}
/>
<Tab.Screen
name="Places"
component={PlacesScreen}
options={{ tabBarLabel: 'Places', tabBarIcon: () => ({ sfSymbol: 'list.bullet' }) }}
/>
<Tab.Screen
name="Alerts"
component={AlertsScreen}
options={{ tabBarLabel: 'Alerts', tabBarIcon: () => ({ sfSymbol: 'bell.fill' }) }}
/>
</Tab.Navigator>No custom tint colors or tabBarStyle are needed to reproduce it; I had both at first and removing them changed nothing on its own.
Notes
- Not verified beyond this shape: more than five tabs (system "More" tab), iPad sidebar, Android.
- Happy to test a patch on a real iOS 27 device if that helps.
Activity
Pestañas: VoiceOver dejaba "seleccionada" cada pestaña ya visitada
Pestañas: VoiceOver dejaba "seleccionada" cada pestaña ya visitada
teddyxfire1992 commentedon Oct 22, 2026
Hey @jmortizsilva, could you please try with the latest version? I've tried reproducing it on the iPhone 15 Pro, running the iOS v27.0.1 - it seems to be working fine. Please check the recording and let me know if I'm doing something different than expected to properly reproduce the issue.
tabs-icons-voiceover.mov
jmortizsilva commentedon Oct 25, 2026
Thanks for checking! I've now tested 1.5.0 on a real device (iPhone 17 Pro, iOS 27.0.1) with VoiceOver, using a minimal app: three tabs, each with a
tabBarIcon(SF Symbols), and nothing else — the same shape as the sample in the issue.It works correctly now: only the active tab is announced as selected, and the trait moves when switching tabs, including when going back to the first one.
Looking at the diff, #585 stops reassigning
imageon every layout pass and only setsselectedImagewhen it changes, which is the code path I suspected, so that's most likely what fixed it.Fine to close from my side. Thanks!
thiagobrez commentedon Oct 26, 2026
Thanks @teddyxfire1992 and @jmortizsilva! Closing.