Skill: Bottom Sheet Best Practices
Optimize @gorhom/bottom-sheet for smooth 60 FPS by keeping gesture/scroll-driven state on the UI thread.
Quick Pattern
Incorrect (can re-enter JS repeatedly during interaction — full subtree re-render):
const handleAnimate = useCallback((fromIndex, toIndex) => {
setIsExpanded(toIndex > 0); // re-renders entire tree
}, []);
<BottomSheet onAnimate={handleAnimate}>
<ExpensiveContent isExpanded={isExpanded} />
</BottomSheet>Correct (stays on UI thread — zero re-renders):
const animatedIndex = useSharedValue(0);
const overlayStyle = useAnimatedStyle(() => ({
opacity: interpolate(
animatedIndex.value,
[0, 1],
[0, 0.5],
Extrapolation.CLAMP
),
}));
<BottomSheet animatedIndex={animatedIndex}>
<ExpensiveContent />
</BottomSheet>
<Animated.View style={[styles.overlay, overlayStyle]} />When to Use
- Implementing or optimizing a bottom sheet with
@gorhom/bottom-sheet - Bottom sheet gestures cause jank or dropped frames
- Scroll inside bottom sheet triggers excessive re-renders
- Context provider wrapping bottom sheet re-renders the entire subtree
- Visual-only state (shadow, opacity, footer visibility) managed with
useState - Need to choose between
BottomSheetandBottomSheetModal - Scrollable content inside bottom sheet doesn't coordinate with gestures
- Keyboard doesn't interact properly with the sheet
Prerequisites
- Check the official
@gorhom/bottom-sheetversioning / compatibility table first. - If your app is on
@gorhom/bottom-sheetbelow v5, upgrade to v5 before applying the patterns in this skill. @gorhom/bottom-sheetv5 is the current maintained line and is built forreact-native-reanimatedv3.react-native-reanimatedv4 may work in some apps, but the bottom-sheet docs do not officially guarantee it. Decide explicitly whether to stay on v3 or try v4 and validate thoroughly on device.react-native-gesture-handlerv2+
npm install @gorhom/bottom-sheet@^5 react-native-reanimated@^3 react-native-gesture-handlerNote: In v5,
enableDynamicSizingdefaults totrue. If you need fixed snap-point indexing or do not want the library to insert a dynamic snap point based on content height, setenableDynamicSizing={false}explicitly.
Problem Description
Bottom-sheet gesture, animation, and scroll callbacks that update React state can re-render the sheet subtree during interaction. In practice, callbacks like onAnimate may run repeatedly as the sheet retargets animations, which can cause visible jank if they drive expensive React updates.
Step-by-Step Instructions
1. Convert Gesture-Driven State to SharedValue
Avoid React state for gesture-driven visual state. Update a shared value and consume it via useAnimatedStyle.
Before:
const [shadowOpacity, setShadowOpacity] = useState(0);
const handleAnimate = useCallback((fromIndex, toIndex) => {
setShadowOpacity(toIndex > 0 ? 0.3 : 0);
}, []);
<BottomSheet onAnimate={handleAnimate}>
<View style={{ shadowOpacity }}>
<HeavyContent />
</View>
</BottomSheet>After:
const animatedIndex = useSharedValue(0);
const shadowStyle = useAnimatedStyle(() => ({
shadowOpacity: interpolate(
animatedIndex.value,
[0, 1],
[0, 0.3],
Extrapolation.CLAMP
),
}));
<BottomSheet animatedIndex={animatedIndex}>
<Animated.View style={shadowStyle}>
<HeavyContent />
</Animated.View>
</BottomSheet>2. Drive Sheet-Index Visibility via useAnimatedReaction
Toggling content based on sheet index via {showFooter && <Footer/>} causes mount/unmount cycles on every snap. Instead, always mount, animate visibility from animatedIndex, and bridge only the minimal boolean needed for pointerEvents/accessibility — scoped to a wrapper so the full tree doesn't re-render.
Before:
const [showFooter, setShowFooter] = useState(false);
// re-mounts footer on every toggle
{showFooter && <Footer />}After:
const SheetVisibilityWrapper = ({ animatedIndex, threshold = 1, children }) => {
const [isInteractive, setIsInteractive] = useState(false);
const style = useAnimatedStyle(() => {
const progress = interpolate(
animatedIndex.value,
[threshold - 0.01, threshold],
[0, 1],
Extrapolation.CLAMP
);
return {
opacity: progress,
transform: [{ translateY: interpolate(progress, [0, 1], [50, 0]) }],
};
});
useAnimatedReaction(
() => animatedIndex.value >= threshold,
(visible, prev) => {
if (visible !== prev) runOnJS(setIsInteractive)(visible);
}
);
return (
<Animated.View
style={style}
pointerEvents={isInteractive ? 'auto' : 'none'}
accessibilityElementsHidden={!isInteractive}
importantForAccessibility={isInteractive ? 'auto' : 'no-hide-descendants'}
>
{children}
</Animated.View>
);
};
// Usage:
<SheetVisibilityWrapper animatedIndex={animatedIndex}>
<Footer />
</SheetVisibilityWrapper>3. Keep Scroll-Driven Logic off the JS Thread
BottomSheetScrollView ignores scrollEventThrottle, so setting it is not an optimization. Keep JS onScroll work minimal, or move scroll-driven logic to useAnimatedScrollHandler (see js-animations-reanimated.md) so it stays on the UI thread:
const scrollHandler = useAnimatedScrollHandler((event) => {
scrollY.value = event.contentOffset.y;
});
<BottomSheetScrollView onScroll={scrollHandler}>
<Content />
</BottomSheetScrollView>4. Use Library-Provided Components and Props
Scrollables — always use these instead of React Native built-ins inside a bottom sheet:
import {
BottomSheetScrollView,
BottomSheetFlatList,
BottomSheetSectionList,
} from '@gorhom/bottom-sheet';
// FlashList v2: BottomSheetFlashList is deprecated.
// Create the scroll component, then pass it to FlashList.
import { useBottomSheetScrollableCreator } from '@gorhom/bottom-sheet';
import { FlashList } from '@shopify/flash-list';
const BottomSheetFlashListScrollComponent = useBottomSheetScrollableCreator();
<BottomSheet snapPoints={snapPoints} enableDynamicSizing={false}>
<FlashList
data={data}
keyExtractor={(item) => item.id}
renderItem={renderItem}
renderScrollComponent={BottomSheetFlashListScrollComponent}
/>
</BottomSheet>Key props:
| Prop | Purpose |
|---|---|
containerHeight |
Provide to skip extra measurement re-render on mount |
enableDynamicSizing={false} |
Use when you want fixed snap-point indexing and do not want a dynamic content-height snap point inserted |
animatedIndex |
SharedValue for continuous index tracking on UI thread |
animatedPosition |
SharedValue for continuous position tracking on UI thread |
onChange |
Fires on snap completion only (discrete) — use for analytics/side effects |
onAnimate |
Fires before each animation start/retarget — use sparingly, because it can run repeatedly during interaction |
5. BottomSheetModal Setup
import {
BottomSheetModal,
BottomSheetModalProvider,
} from '@gorhom/bottom-sheet';
const App = () => (
<BottomSheetModalProvider>
<BottomSheetModal
ref={modalRef}
snapPoints={snapPoints}
enableDismissOnClose={true}
>
<Content />
</BottomSheetModal>
</BottomSheetModalProvider>
);iOS layering fix — use FullWindowOverlay to render above native navigation:
import { FullWindowOverlay } from 'react-native-screens';
<BottomSheetModal
containerComponent={(props) => <FullWindowOverlay>{props.children}</FullWindowOverlay>}
>6. Keyboard Handling
<BottomSheet
snapPoints={snapPoints}
enableDynamicSizing={false}
keyboardBehavior="interactive" // 'extend' | 'fillParent' | 'interactive'
keyboardBlurBehavior="restore" // reset sheet position when keyboard dismisses
enableBlurKeyboardOnGesture={true} // dismiss keyboard on drag
>
<BottomSheetTextInput
placeholder="Type here..."
style={styles.input}
/>
</BottomSheet>keyboardBehavior |
Effect |
|---|---|
extend |
Sheet grows to accommodate keyboard |
fillParent |
Sheet fills parent when keyboard appears |
interactive |
Sheet follows keyboard position interactively |
Prefer
BottomSheetTextInputinside a bottom sheet. If you need a custom input, copy the focus/blur handlers from the library'sBottomSheetTextInputimplementation so keyboard handling still works correctly.
Derived Animations with animatedPosition
Use the animatedPosition shared value for smooth derived UI that stays on the UI thread:
const animatedPosition = useSharedValue(0);
const backdropStyle = useAnimatedStyle(() => ({
opacity: interpolate(
animatedPosition.value,
[0, 300],
[0.5, 0],
Extrapolation.CLAMP
),
}));
<BottomSheet animatedPosition={animatedPosition} snapPoints={snapPoints}>
<Content />
</BottomSheet>
<Animated.View style={[StyleSheet.absoluteFill, backdropStyle]} pointerEvents="none" />Native Alternative: react-native-true-sheet
If your app already runs on New Architecture (Fabric) and needs a standard native-feeling sheet, evaluate @lodev09/react-native-true-sheet. Keep @gorhom/bottom-sheet when you need fine-grained Reanimated customization, custom gestures, or a mature cross-platform fallback.
| Scenario | Recommendation |
|---|---|
| Need deep JS customization (custom gestures, animated derived UI) | @gorhom/bottom-sheet |
| Standard sheet with native feel + accessibility | react-native-true-sheet |
| Legacy Architecture (no Fabric) | @gorhom/bottom-sheet (true-sheet v3+ requires Fabric) |
| Web support needed | Either (true-sheet uses @gorhom/bottom-sheet on web internally) |
npm install @lodev09/react-native-true-sheetCommon Pitfalls
- Using
onChangefor continuous position tracking — it fires on snap completion only (discrete). UseanimatedPositionoranimatedIndexshared values instead. - Starting timing animations inside sheet-index style worklets — derive gesture-linked visuals with
interpolate; reservewithTimingfor explicit state transitions. - Forgetting
pointerEvents='none'on always-mounted hidden elements — invisible elements still capture touches. - Missing accessibility attributes on hidden elements — add
accessibilityElementsHiddenandimportantForAccessibility='no-hide-descendants'. - Bundling independent state values in one context — see js-atomic-state.md for splitting patterns.
- Assuming
enableDynamicSizingmust be disabled whenever you passsnapPoints— it does not have to be, but leaving it enabled can insert an additional snap point and change indexing. - Using React Native
ScrollView/FlatListinside bottom sheet — gestures won't coordinate. UseBottomSheetScrollView,BottomSheetFlatList, etc. - Gesture conflicts with React Native touchables — when touches do not respond inside the sheet, use the touchable components exported by
@gorhom/bottom-sheet, especially on Android. - Not providing
containerHeight— causes an extra re-render on mount for measurement. - Using a custom
TextInputwithout porting the library's focus/blur handlers — keyboard handling will be incomplete. PreferBottomSheetTextInputunless you need a custom input.
Related Skills
- js-animations-reanimated.md — SharedValue and useAnimatedStyle fundamentals
- js-atomic-state.md — Context splitting and atomic state patterns
- js-profile-react.md — Profiling to measure re-render reduction
- js-measure-fps.md — Verify FPS improvement after optimization