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
createNativeBottomTabNavigator and three tabs, each with a tabBarIcon.
- 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
tabBarIcon and 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.
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:tabBarStyleand notabBarIcontabBarStyleStep 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
createNativeBottomTabNavigatorfrom@bottom-tabs/react-navigation1.4.0, unpatched, on the same iPhone running iOS 27. One misbehaves, the other does not — and the one that does not declares notabBarIconon any tab.Where I think it comes from
ios/TabViewImpl.swiftreconfigures the bar from a SwiftUI introspection that runs on every layout pass, and it writes to theUITabBarItems twice, once synchronously and once deferred:Inside
configureTabBarItemImages, the part that only runs when an icon exists:Reassigning
image/selectedImagemakes UIKit rebuild that tab's button. That was harmless before, but on iOS 26/27 the bar is driven byUITabobjects and the newshouldSelectTab(_:)delegate — the one this library already had to special-case inios/TabItemEventModifier.swift:Rebuilding the buttons behind that mechanism, twice per pass, looks like where the
.selectedtrait 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
Steps to reproduce
createNativeBottomTabNavigatorand three tabs, each with atabBarIcon.Actual: tabs 1 and 2 are both announced as selected.
tabBarIconand repeat — the trait now moves correctly.Reproducible sample code
No custom tint colors or
tabBarStyleare needed to reproduce it; I had both at first and removing them changed nothing on its own.Notes