Building Custom Adapters
You can create an adapter for any overlay library. An adapter is a React component that bridges a library's open/close API with the stack manager's lifecycle.
Adapter Contract
Your adapter must:
- Implement
SheetAdapterRefviauseImperativeHandle— the coordinator callsexpand()andclose()on your ref - Call
SheetAdapterEvents— notify the store when animations complete or the user dismisses
import type { SheetAdapterRef, SheetAdapterEvents } from 'react-native-bottom-sheet-stack';
// The coordinator calls these on your ref:
interface SheetAdapterRef {
expand(): void; // Show the overlay
close(): void; // Hide the overlay
}
// You call these back to the store:
interface SheetAdapterEvents {
handleOpened(): void; // Show animation done
handleDismiss(): void; // User wants to close (swipe, backdrop, back button)
handleClosed(): void; // Hide animation done
}
Step-by-Step Guide
1. Create the Adapter Component
import React, { useImperativeHandle } from 'react';
import type { SheetAdapterRef } from 'react-native-bottom-sheet-stack';
import {
createSheetEventHandlers,
useAdapterRef,
useAnimatedIndex,
useBottomSheetContext,
} from 'react-native-bottom-sheet-stack';
interface MyAdapterProps {
children: React.ReactNode;
// ... your library's props
}
export const MyAdapter = React.forwardRef<SheetAdapterRef, MyAdapterProps>(
({ children, ...props }, forwardedRef) => {
// 1. Get sheet context and adapter ref
const { id } = useBottomSheetContext();
const ref = useAdapterRef(forwardedRef);
// 2. Get event handlers for this sheet
const { handleDismiss, handleOpened, handleClosed } =
createSheetEventHandlers(id);
// 3. Get animated index (for backdrop/scale integration)
const animatedIndex = useAnimatedIndex();
// 4. Expose expand/close to the coordinator
useImperativeHandle(ref, () => ({
expand: () => {
// Call your library's "show" method
myLibraryRef.current?.show();
},
close: () => {
// Call your library's "hide" method
myLibraryRef.current?.hide();
},
}), []);
// 5. Wire up callbacks
const onShown = () => {
animatedIndex.set(0);
handleOpened();
};
const onUserDismiss = () => {
handleDismiss();
};
const onHidden = () => {
animatedIndex.set(-1);
handleClosed();
};
// 6. Render your library's component
return (
<MyLibrarySheet
ref={myLibraryRef}
onShow={onShown}
onDismiss={onUserDismiss}
onHide={onHidden}
{...props}
>
{children}
</MyLibrarySheet>
);
}
);
2. Use Your Adapter
// As inline content
const { open } = useBottomSheetManager();
open(
<MyAdapter someProp="value">
<View><Text>Custom adapter content</Text></View>
</MyAdapter>,
{ mode: 'push' }
);
// As portal
<BottomSheetPortal id="my-overlay">
<MyAdapter someProp="value">
<MyOverlayContent />
</MyAdapter>
</BottomSheetPortal>
Lifecycle Flow
Understanding the correct order of events is critical:
┌─────────────────────────────────────────────────────────┐
│ 1. Store: status → 'opening' │
│ 2. Coordinator: calls ref.expand() │
│ 3. Your adapter: starts show animation │
│ 4. Your adapter: animation completes → handleOpened() │
│ 5. Store: status → 'open' │
│ │
│ --- User interacts with sheet --- │
│ │
│ 6a. User swipe/tap → handleDismiss() │
│ OR │
│ 6b. API close() → coordinator calls ref.close() │
│ │
│ 7. Store: status → 'closing' │
│ 8. Your adapter: starts hide animation │
│ 9. Your adapter: animation completes → handleClosed() │
│ 10. Store: removes sheet (or sets 'hidden' if persistent)│
└─────────────────────────────────────────────────────────┘
Important Details
Animated Index
The useAnimatedIndex() hook returns the animatedIndex shared value for the current sheet. It drives backdrop opacity and scale animations — BottomSheetBackdrop interpolates it in the range [-1, 0] to opacity [0, 1].
import { useAnimatedIndex } from 'react-native-bottom-sheet-stack';
const animatedIndex = useAnimatedIndex();
No need to pass the sheet id — the hook reads it from context automatically.
Binary strategy (CustomModalAdapter, ReactNativeModalAdapter, ActionsSheetAdapter)
Set to 0 when the sheet becomes visible, -1 when hidden. The backdrop snaps between transparent and opaque. Simple and works for any library.
const animatedIndex = useAnimatedIndex();
useImperativeHandle(ref, () => ({
expand: () => {
animatedIndex.set(0); // backdrop fully opaque
// ... show your overlay
},
close: () => {
animatedIndex.set(-1); // backdrop fully transparent
// ... hide your overlay
},
}), [animatedIndex]);
Continuous/dynamic strategy (GorhomSheetAdapter)
Pass the shared value directly to the underlying library as a prop. The library updates it continuously during swipe gestures (intermediate values between -1 and 0), so the backdrop smoothly interpolates during user interaction.
const animatedIndex = useAnimatedIndex();
// The library writes to the shared value during gestures:
<BottomSheet animatedIndex={animatedIndex} />
Adapter Ref
Use useAdapterRef(forwardedRef) to get the ref for useImperativeHandle. The hook resolves the correct ref automatically — your adapter works in all three modes (inline, portal, persistent) without any extra logic:
const ref = useAdapterRef(forwardedRef);
useImperativeHandle(ref, () => ({ expand: ..., close: ... }));
Prop-Controlled vs Ref-Controlled Libraries
Ref-controlled (e.g., TrueSheet with present()/dismiss()):
useImperativeHandle(ref, () => ({
expand: () => libraryRef.current?.present(),
close: () => libraryRef.current?.dismiss(),
}), []);
Prop-controlled (e.g., react-native-modal with isVisible):
const [visible, setVisible] = useState(false);
useImperativeHandle(ref, () => ({
expand: () => setVisible(true),
close: () => setVisible(false),
}), []);
Libraries Without Separate Dismiss/Close Phases
Some libraries fire a single onClose for both user dismissal and animation completion. In that case, call both:
const onClose = () => {
handleDismiss();
handleClosed();
};
Optional Dependencies
If publishing your adapter as a separate package, use lazy require() to keep the wrapped library optional:
// Lazy import — won't crash if the library isn't installed
const ThirdPartySheet = require('third-party-sheet').default;
Full Example: Simple Slide-Up Modal
A complete, minimal adapter — a slide-up modal using react-native-reanimated:
import React, { useEffect, useImperativeHandle, useState } from 'react';
import { BackHandler, Pressable, StyleSheet } from 'react-native';
import Animated, {
useAnimatedStyle,
useSharedValue,
withSpring,
withTiming,
runOnJS,
} from 'react-native-reanimated';
import type { SheetAdapterRef } from 'react-native-bottom-sheet-stack';
import {
createSheetEventHandlers,
useAdapterRef,
useAnimatedIndex,
useBottomSheetContext,
} from 'react-native-bottom-sheet-stack';
interface SlideUpModalProps {
children: React.ReactNode;
}
export const SlideUpModal = React.forwardRef<SheetAdapterRef, SlideUpModalProps>(
({ children }, forwardedRef) => {
const { id } = useBottomSheetContext();
const ref = useAdapterRef(forwardedRef);
const animatedIndex = useAnimatedIndex();
const [visible, setVisible] = useState(false);
const progress = useSharedValue(0);
const { handleDismiss, handleOpened, handleClosed } =
createSheetEventHandlers(id);
useImperativeHandle(ref, () => ({
expand: () => {
setVisible(true);
animatedIndex.set(0);
progress.value = withSpring(1, { damping: 20, stiffness: 300 }, (finished) => {
if (finished) runOnJS(handleOpened)();
});
},
close: () => {
animatedIndex.set(-1);
progress.value = withTiming(0, { duration: 250 }, (finished) => {
if (finished) {
runOnJS(setVisible)(false);
runOnJS(handleClosed)();
}
});
},
}), [progress, animatedIndex]);
// Android back button
useEffect(() => {
if (!visible) return;
const sub = BackHandler.addEventListener('hardwareBackPress', () => {
handleDismiss();
return true;
});
return () => sub.remove();
}, [visible, handleDismiss]);
const sheetStyle = useAnimatedStyle(() => ({
transform: [{ translateY: (1 - progress.value) * 600 }],
}));
if (!visible) return null;
return (
<Pressable style={styles.backdrop} onPress={handleDismiss}>
<Animated.View style={[styles.sheet, sheetStyle]}>
<Pressable>{children}</Pressable>
</Animated.View>
</Pressable>
);
}
);
const styles = StyleSheet.create({
backdrop: { ...StyleSheet.absoluteFillObject, justifyContent: 'flex-end' },
sheet: {
backgroundColor: '#1c1c1e',
borderTopLeftRadius: 20,
borderTopRightRadius: 20,
padding: 24,
minHeight: 200,
},
});
This adapter works with all three sheet modes (inline, portal, persistent) and participates in push/switch/replace navigation — no extra wiring needed.