diff --git a/.changeset/tidy-scene-insets.md b/.changeset/tidy-scene-insets.md
new file mode 100644
index 00000000..d8f4c0ec
--- /dev/null
+++ b/.changeset/tidy-scene-insets.md
@@ -0,0 +1,5 @@
+---
+'react-native-bottom-tabs': minor
+---
+
+Expose `useTabSceneInsets()` with per-scene, four-edge native safe-area insets on iOS. Apps can inset foreground content around vertical tab bars while preserving edge-to-edge backgrounds.
diff --git a/docs/docs/docs/guides/standalone-usage.mdx b/docs/docs/docs/guides/standalone-usage.mdx
index d9f02c43..ee8f9d71 100644
--- a/docs/docs/docs/guides/standalone-usage.mdx
+++ b/docs/docs/docs/guides/standalone-usage.mdx
@@ -319,3 +319,51 @@ Function to get the style for a tab scene.
Function to determine if a tab should prevent default switching behavior when pressed.
- Default: Uses `route.preventsDefault`
+
+### Scene safe-area insets (iOS)
+
+`useTabSceneInsets()` exposes the native scene's safe area as physical `top`,
+`right`, `bottom`, and `left` distances in points. It updates when the scene's
+safe area changes, including when the system moves the tab bar to a side.
+Scenes retain their full bounds so backgrounds can extend behind system bars.
+Apply the insets to foreground content when needed:
+
+```tsx
+import { useTabSceneInsets } from 'react-native-bottom-tabs';
+import { I18nManager, ScrollView, Text } from 'react-native';
+
+function Article() {
+ const insets = useTabSceneInsets();
+
+ return (
+
+ Content stays clear of the native bars.
+
+ );
+}
+```
+
+The hook must be used in a rendered tab scene. It returns `undefined` before
+native measurement, outside a scene, with a custom tab bar, and on unsupported
+platforms. Insets belong to each scene separately, including lazily mounted
+scenes. They describe the scene's total native safe area, not just the tab bar:
+do not add them to an existing safe-area inset or automatic scroll adjustment.
+The left and right values remain physical edges in RTL layouts. The example maps
+them to logical start/end padding because React Native can swap left/right
+styles in RTL. If the content overrides its layout direction, use that direction
+for the mapping.
+
+This hook does not resize scenes, change `useBottomTabBarHeight()`, or provide
+keyboard avoidance. Apps using React Navigation can import it from
+`react-native-bottom-tabs` inside a native tab screen as well.
diff --git a/packages/example-shared/src/Examples/FourTabs.tsx b/packages/example-shared/src/Examples/FourTabs.tsx
index 774d6679..bedb2ca7 100644
--- a/packages/example-shared/src/Examples/FourTabs.tsx
+++ b/packages/example-shared/src/Examples/FourTabs.tsx
@@ -1,5 +1,6 @@
-import TabView, { SceneMap } from 'react-native-bottom-tabs';
+import TabView, { SceneMap, useTabSceneInsets } from 'react-native-bottom-tabs';
import { useState, type ComponentProps } from 'react';
+import { I18nManager } from 'react-native';
import { Article } from '../Screens/Article';
import { Albums } from '../Screens/Albums';
import { Contacts } from '../Screens/Contacts';
@@ -17,8 +18,28 @@ interface Props {
activeIndicatorColor?: TabViewProps['activeIndicatorColor'];
}
+function InsetArticle(props: ComponentProps) {
+ const insets = useTabSceneInsets();
+ return (
+
+ );
+}
+
const renderScene = SceneMap({
- article: Article,
+ article: InsetArticle,
albums: Albums,
contacts: Contacts,
chat: Chat,
diff --git a/packages/react-native-bottom-tabs/ios/Bridge/BottomTabsBridge.h b/packages/react-native-bottom-tabs/ios/Bridge/BottomTabsBridge.h
index 798e2cf0..2a225712 100644
--- a/packages/react-native-bottom-tabs/ios/Bridge/BottomTabsBridge.h
+++ b/packages/react-native-bottom-tabs/ios/Bridge/BottomTabsBridge.h
@@ -10,6 +10,7 @@
- (void)onPageSelectedWithKey:(NSString *)key reactTag:(NSNumber *)reactTag;
- (void)onLongPressWithKey:(NSString *)key reactTag:(NSNumber *)reactTag;
- (void)onTabBarMeasuredWithHeight:(NSInteger)height reactTag:(NSNumber *)reactTag;
+- (void)onSceneInsetsChangeWithKey:(NSString *)key top:(CGFloat)top right:(CGFloat)right bottom:(CGFloat)bottom left:(CGFloat)left;
- (void)onLayoutWithSize:(CGSize)size reactTag:(NSNumber *)reactTag;
@end
diff --git a/packages/react-native-bottom-tabs/ios/RCTTabViewComponentView.mm b/packages/react-native-bottom-tabs/ios/RCTTabViewComponentView.mm
index 8e9cae58..18575df4 100644
--- a/packages/react-native-bottom-tabs/ios/RCTTabViewComponentView.mm
+++ b/packages/react-native-bottom-tabs/ios/RCTTabViewComponentView.mm
@@ -288,6 +288,19 @@ - (void)onTabBarMeasuredWithHeight:(NSInteger)height reactTag:(NSNumber *)reactT
}
}
+- (void)onSceneInsetsChangeWithKey:(NSString *)key top:(CGFloat)top right:(CGFloat)right bottom:(CGFloat)bottom left:(CGFloat)left {
+ auto eventEmitter = std::static_pointer_cast(_eventEmitter);
+ if (eventEmitter) {
+ eventEmitter->onSceneInsetsChange(RNCTabViewEventEmitter::OnSceneInsetsChange {
+ .key = [key cStringUsingEncoding:kCFStringEncodingUTF8],
+ .top = top,
+ .right = right,
+ .bottom = bottom,
+ .left = left
+ });
+ }
+}
+
- (void)onLayoutWithSize:(CGSize)size reactTag:(NSNumber *)reactTag {
auto eventEmitter = std::static_pointer_cast(_eventEmitter);
if (eventEmitter) {
diff --git a/packages/react-native-bottom-tabs/ios/RepresentableView.swift b/packages/react-native-bottom-tabs/ios/RepresentableView.swift
index 09adaca4..6f204924 100644
--- a/packages/react-native-bottom-tabs/ios/RepresentableView.swift
+++ b/packages/react-native-bottom-tabs/ios/RepresentableView.swift
@@ -7,6 +7,7 @@ import SwiftUI
*/
struct RepresentableView: PlatformViewRepresentable {
var view: PlatformView
+ var onInsetsChange: ((CGFloat, CGFloat, CGFloat, CGFloat) -> Void)? = nil
#if os(macOS)
@@ -21,12 +22,42 @@ struct RepresentableView: PlatformViewRepresentable {
#else
func makeUIView(context: Context) -> PlatformView {
- let wrapper = UIView()
+ let wrapper = SceneInsetsView()
+ wrapper.onInsetsChange = onInsetsChange
wrapper.addSubview(view)
return wrapper
}
- func updateUIView(_ uiView: PlatformView, context: Context) {}
+ func updateUIView(_ uiView: PlatformView, context: Context) {
+ guard let wrapper = uiView as? SceneInsetsView else { return }
+ wrapper.onInsetsChange = onInsetsChange
+ wrapper.reportInsets()
+ }
#endif
}
+
+#if !os(macOS)
+private class SceneInsetsView: UIView {
+ var onInsetsChange: ((CGFloat, CGFloat, CGFloat, CGFloat) -> Void)?
+ private var lastInsets: UIEdgeInsets?
+
+ override func safeAreaInsetsDidChange() {
+ super.safeAreaInsetsDidChange()
+ reportInsets()
+ }
+
+ override func layoutSubviews() {
+ super.layoutSubviews()
+ reportInsets()
+ }
+
+ func reportInsets() {
+ guard window != nil, !bounds.isEmpty, let onInsetsChange else { return }
+ let insets = safeAreaInsets
+ guard lastInsets != insets else { return }
+ lastInsets = insets
+ onInsetsChange(insets.top, insets.right, insets.bottom, insets.left)
+ }
+}
+#endif
diff --git a/packages/react-native-bottom-tabs/ios/TabView/LegacyTabView.swift b/packages/react-native-bottom-tabs/ios/TabView/LegacyTabView.swift
index 188ddfde..831fdb76 100644
--- a/packages/react-native-bottom-tabs/ios/TabView/LegacyTabView.swift
+++ b/packages/react-native-bottom-tabs/ios/TabView/LegacyTabView.swift
@@ -3,6 +3,7 @@ import SwiftUI
struct LegacyTabView: AnyTabView {
@ObservedObject var props: TabViewProps
+ var onSceneInsetsChange: (String, CGFloat, CGFloat, CGFloat, CGFloat) -> Void
var onLayout: (CGSize) -> Void
var onSelect: (String) -> Void
var updateTabBarAppearance: () -> Void
@@ -49,7 +50,9 @@ struct LegacyTabView: AnyTabView {
onSelect: onSelect
)
- RepresentableView(view: child)
+ RepresentableView(view: child, onInsetsChange: { top, right, bottom, left in
+ onSceneInsetsChange(tabData.key, top, right, bottom, left)
+ })
.ignoresSafeArea(.container, edges: .all)
.tabItem {
TabItem(
diff --git a/packages/react-native-bottom-tabs/ios/TabView/NewTabView.swift b/packages/react-native-bottom-tabs/ios/TabView/NewTabView.swift
index 2f18f210..72b8e33f 100644
--- a/packages/react-native-bottom-tabs/ios/TabView/NewTabView.swift
+++ b/packages/react-native-bottom-tabs/ios/TabView/NewTabView.swift
@@ -5,6 +5,7 @@ import SwiftUI
struct NewTabView: AnyTabView {
@ObservedObject var props: TabViewProps
+ var onSceneInsetsChange: (String, CGFloat, CGFloat, CGFloat, CGFloat) -> Void
var onLayout: (CGSize) -> Void
var onSelect: (String) -> Void
var updateTabBarAppearance: () -> Void
@@ -39,7 +40,9 @@ struct NewTabView: AnyTabView {
)
Tab(value: tabData.key, role: tabData.role?.convert()) {
- RepresentableView(view: child.view)
+ RepresentableView(view: child.view, onInsetsChange: { top, right, bottom, left in
+ onSceneInsetsChange(tabData.key, top, right, bottom, left)
+ })
.ignoresSafeArea(.container, edges: .all)
.tabAppear(using: context)
.hideTabBar(props.tabBarHidden)
diff --git a/packages/react-native-bottom-tabs/ios/TabViewImpl.swift b/packages/react-native-bottom-tabs/ios/TabViewImpl.swift
index 68053e1f..210db0b7 100644
--- a/packages/react-native-bottom-tabs/ios/TabViewImpl.swift
+++ b/packages/react-native-bottom-tabs/ios/TabViewImpl.swift
@@ -17,6 +17,7 @@ struct TabViewImpl: View {
if #available(iOS 18, macOS 15, visionOS 2, tvOS 18, *) {
NewTabView(
props: props,
+ onSceneInsetsChange: onSceneInsetsChange,
onLayout: onLayout,
onSelect: onSelect
) {
@@ -27,6 +28,7 @@ struct TabViewImpl: View {
} else {
LegacyTabView(
props: props,
+ onSceneInsetsChange: onSceneInsetsChange,
onLayout: onLayout,
onSelect: onSelect
) {
@@ -39,6 +41,7 @@ struct TabViewImpl: View {
var onSelect: (_ key: String) -> Void
var onLongPress: (_ key: String) -> Void
+ var onSceneInsetsChange: (String, CGFloat, CGFloat, CGFloat, CGFloat) -> Void
var onLayout: (_ size: CGSize) -> Void
var onTabBarMeasured: (_ height: Int) -> Void
diff --git a/packages/react-native-bottom-tabs/ios/TabViewProvider.swift b/packages/react-native-bottom-tabs/ios/TabViewProvider.swift
index e5663453..b5b10afc 100644
--- a/packages/react-native-bottom-tabs/ios/TabViewProvider.swift
+++ b/packages/react-native-bottom-tabs/ios/TabViewProvider.swift
@@ -48,6 +48,7 @@ public final class TabInfo: NSObject {
func onPageSelected(key: String, reactTag: NSNumber?)
func onLongPress(key: String, reactTag: NSNumber?)
func onTabBarMeasured(height: Int, reactTag: NSNumber?)
+ func onSceneInsetsChange(key: String, top: CGFloat, right: CGFloat, bottom: CGFloat, left: CGFloat)
func onLayout(size: CGSize, reactTag: NSNumber?)
}
@@ -220,6 +221,8 @@ public final class TabInfo: NSObject {
self.delegate?.onPageSelected(key: key, reactTag: self.reactTag)
} onLongPress: { key in
self.delegate?.onLongPress(key: key, reactTag: self.reactTag)
+ } onSceneInsetsChange: { key, top, right, bottom, left in
+ self.delegate?.onSceneInsetsChange(key: key, top: top, right: right, bottom: bottom, left: left)
} onLayout: { size in
self.delegate?.onLayout(size: size, reactTag: self.reactTag)
} onTabBarMeasured: { height in
diff --git a/packages/react-native-bottom-tabs/src/TabView.tsx b/packages/react-native-bottom-tabs/src/TabView.tsx
index 0acfb842..7f9bfe83 100644
--- a/packages/react-native-bottom-tabs/src/TabView.tsx
+++ b/packages/react-native-bottom-tabs/src/TabView.tsx
@@ -1,5 +1,10 @@
+import {
+ TabSceneInsetsContext,
+ type TabSceneInsets,
+} from './utils/TabSceneInsetsContext';
import React, { useLayoutEffect, useRef } from 'react';
import type {
+ OnSceneInsetsChange,
OnNativeLayout,
OnPageSelectedEventData,
OnTabBarMeasured,
@@ -282,6 +287,9 @@ const TabView = ({
// @ts-ignore
const focusedKey = navigationState.routes[navigationState.index].key;
const customTabBarWrapperRef = useRef(null);
+ const [sceneInsets, setSceneInsets] = React.useState<
+ Record
+ >({});
const [tabBarHeight, setTabBarHeight] = React.useState(0);
const [measuredDimensions, setMeasuredDimensions] = React.useState<
{ width: DimensionValue; height: DimensionValue } | undefined
@@ -435,6 +443,30 @@ const TabView = ({
[setTabBarHeight]
);
+ const handleSceneInsetsChange = React.useCallback(
+ ({
+ nativeEvent: { key, top, right, bottom, left },
+ }: {
+ nativeEvent: OnSceneInsetsChange;
+ }) => {
+ const insets = { top, right, bottom, left };
+ setSceneInsets((previous) => {
+ const current = previous[key];
+ if (
+ current &&
+ current.top === insets.top &&
+ current.right === insets.right &&
+ current.bottom === insets.bottom &&
+ current.left === insets.left
+ ) {
+ return previous;
+ }
+ return { ...previous, [key]: insets };
+ });
+ },
+ []
+ );
+
const handleNativeLayout = React.useCallback(
({ nativeEvent: { width, height } }: { nativeEvent: OnNativeLayout }) => {
setMeasuredDimensions({ width, height });
@@ -469,6 +501,7 @@ const TabView = ({
onPageSelected={handlePageSelected}
onTabBarMeasured={handleTabBarMeasured}
onNativeLayout={handleNativeLayout}
+ onSceneInsetsChange={handleSceneInsetsChange}
hapticFeedbackEnabled={hapticFeedbackEnabled}
layoutDirection={layoutDirection}
activeTintColor={activeTintColor}
@@ -510,12 +543,16 @@ const TabView = ({
focused ? 'auto' : 'no-hide-descendants'
}
>
-
- {renderScene({
- route,
- jumpTo,
- })}
-
+
+
+ {renderScene({
+ route,
+ jumpTo,
+ })}
+
+
);
})}
diff --git a/packages/react-native-bottom-tabs/src/TabViewNativeComponent.ts b/packages/react-native-bottom-tabs/src/TabViewNativeComponent.ts
index 50ecf7c4..c679b36f 100644
--- a/packages/react-native-bottom-tabs/src/TabViewNativeComponent.ts
+++ b/packages/react-native-bottom-tabs/src/TabViewNativeComponent.ts
@@ -17,6 +17,14 @@ export type OnTabBarMeasured = Readonly<{
height: Int32;
}>;
+export type OnSceneInsetsChange = Readonly<{
+ key: string;
+ top: Double;
+ right: Double;
+ bottom: Double;
+ left: Double;
+}>;
+
export type OnNativeLayout = Readonly<{
width: Double;
height: Double;
@@ -44,6 +52,7 @@ export interface TabViewProps extends ViewProps {
onPageSelected?: DirectEventHandler;
onTabLongPress?: DirectEventHandler;
onTabBarMeasured?: DirectEventHandler;
+ onSceneInsetsChange?: DirectEventHandler;
onNativeLayout?: DirectEventHandler;
icons?: ReadonlyArray;
focusedIcons?: ReadonlyArray;
diff --git a/packages/react-native-bottom-tabs/src/index.tsx b/packages/react-native-bottom-tabs/src/index.tsx
index 19544834..324ccdab 100644
--- a/packages/react-native-bottom-tabs/src/index.tsx
+++ b/packages/react-native-bottom-tabs/src/index.tsx
@@ -8,6 +8,11 @@ export default TabView;
/**
* Utilities
*/
+export {
+ useTabSceneInsets,
+ TabSceneInsetsContext,
+} from './utils/TabSceneInsetsContext';
+export type { TabSceneInsets } from './utils/TabSceneInsetsContext';
export { SceneMap } from './SceneMap';
export { useBottomTabBarHeight } from './utils/useBottomTabBarHeight';
export { BottomTabBarHeightContext } from './utils/BottomTabBarHeightContext';
diff --git a/packages/react-native-bottom-tabs/src/utils/TabSceneInsetsContext.ts b/packages/react-native-bottom-tabs/src/utils/TabSceneInsetsContext.ts
new file mode 100644
index 00000000..688305e6
--- /dev/null
+++ b/packages/react-native-bottom-tabs/src/utils/TabSceneInsetsContext.ts
@@ -0,0 +1,17 @@
+import * as React from 'react';
+
+export type TabSceneInsets = Readonly<{
+ top: number;
+ right: number;
+ bottom: number;
+ left: number;
+}>;
+
+export const TabSceneInsetsContext = React.createContext<
+ TabSceneInsets | undefined
+>(undefined);
+
+/** Native scene safe-area insets, or undefined before measurement/on unsupported platforms. */
+export function useTabSceneInsets() {
+ return React.useContext(TabSceneInsetsContext);
+}