SheetNative
A sheet presented by the operating system rather than simulated in JavaScript.
It runs on react-native-screens formSheet, the same engine React Navigation
uses for presentation: 'formSheet', so the scrim, the drag, the corner radius
and the dismiss animation all come from the platform.
SheetNative ships alongside the legacy Sheet and
will take its name once every call site has moved. Use SheetNative for new
work.
React Native
Setup
The host app needs @react-navigation/native, @react-navigation/native-stack
and react-native-screens. The last one is native code, so adding it to an app
that already has a dev build means rebuilding that build. A stale build fails
with a native prop type error such as
expected dynamic type 'boolean', but had type 'string', not with a missing
module error.
SheetNative owns no navigator, no provider and no portal. Presentation comes
from the app's own root navigator, which registers one route and carries an id
so a sheet anywhere in the tree can find it.
import {
SHEET_NAVIGATOR_ID,
sheetScreen,
} from '@hoverinc/design-system-react-native';
<RootStack.Navigator id={SHEET_NAVIGATOR_ID}>
{/* ...app screens... */}
<RootStack.Screen {...sheetScreen} />
</RootStack.Navigator>;Set the id. useNavigation() inside a nested navigator resolves to that
nested one, which has no sheet route, so SheetNative climbs to the navigator
carrying SHEET_NAVIGATOR_ID to find the route. Leave the id off and the
climb fails, falling back to the caller's own navigator — which often still
works, because the unhandled push bubbles up to an ancestor stack. That is luck
rather than design: it breaks as soon as no ancestor stack owns the route.
To override a screen option — animations off under e2e, for example — compose
sheetScreenOptions rather than spreading sheetScreen:
import {
SHEET_ROUTE,
SheetScreen,
sheetScreenOptions,
} from '@hoverinc/design-system-react-native';
<RootStack.Screen
name={SHEET_ROUTE}
component={SheetScreen}
options={props => ({ ...sheetScreenOptions(props), animation: 'none' })}
/>;Usage
Local state and a boolean, controlled only.
import { SheetNative } from '@hoverinc/design-system-react-native';
const [isOpen, setIsOpen] = useState(false);
<SheetNative
open={isOpen}
onOpenChange={setIsOpen}
testID="task-list-sheet"
trailingAction={
<SheetNative.CloseAction
label={t('Close')}
testID="task-list-sheet-close"
/>
}
>
<Heading size="small">Task options</Heading>
<YStack gap="$300">
{tasks.map(task => (
<TaskRow key={task.id} task={task} onPress={select} />
))}
</YStack>
</SheetNative>;Modes
Three modes replace the legacy size prop and its four geometry props. Corner
radius is set on Android only, to match iOS, which keeps its own.
mode | Presentation | Detents | Grabber | Top inset applied |
|---|---|---|---|---|
fullScreen | fullScreenModal | n/a | none | both platforms |
fitToScreen | formSheet | native default | yes | neither |
fitToContent (default) | formSheet | fitToContents | yes | neither |
The top inset is applied only where the surface actually reaches the status bar.
A formSheet starts below it on both platforms, so only fullScreen needs the
inset. The bottom inset is applied everywhere except iOS fitToContent, which
react-native-screens already pads for.
fitToContent sizes itself to its children and re-measures when they change, so
a sheet grows on its own at larger accessibility text sizes.
mode is read once, when the sheet is presented, because it travels in the
route params and those freeze at push time. Changing it on an already-open sheet
does nothing — every other prop is live.
fullScreen replaces size="full" paired with ignoreTopSafeArea, and is the
shape for capture and instruction screens:
<SheetNative
open={isOpen}
onOpenChange={setIsOpen}
mode="fullScreen"
isClosable={false}
>
<InstructionCard />
</SheetNative>Top bar slots
leadingAction and trailingAction are the only chrome. Neither has a default,
so a sheet with no slots renders no top bar at all.
SheetNative.CloseAction gives the standard close button and takes its own
label, which puts the screen-reader string at the call site where t() lives.
Anything else in trailingAction replaces the close button entirely.
<SheetNative
open={isOpen}
onOpenChange={setIsOpen}
leadingAction={
<IconButton
icon={iconHelpOutlined}
label={t('Help')}
onPress={openGuidance}
/>
}
trailingAction={<SheetNative.CloseAction label={t('Close')} />}
>
{children}
</SheetNative>Scrollable content
A scrollable region inside a formSheet needs a bounded viewport: an unbroken
flex chain from the sheet root plus overflow: 'hidden'.
SheetNative.ScrollView owns that rule, so reach for it instead of a bare
ScrollView.
<SheetNative open={isOpen} onOpenChange={setIsOpen} mode="fitToScreen">
<YStack flex={1} padding="$500" gap="$400">
<Heading size="small">Pick a room</Heading>
<SheetNative.ScrollView>
<YStack gap="$300">
{rooms.map(room => (
<RoomRow key={room.id} room={room} />
))}
</YStack>
</SheetNative.ScrollView>
</YStack>
</SheetNative>Every wrapper between SheetNative.ScrollView and the sheet root has to stay
flexed. Break the chain anywhere and the scroller sizes to its content and
paints over its siblings — there is no error, just wrong pixels.
Blocking dismissal
isClosable={false} blocks user dismissal and reports each blocked attempt
through onCloseRequest, so the caller can explain why nothing happened.
Setting open={false} always closes the sheet regardless.
<SheetNative
open={isOpen}
onOpenChange={setIsOpen}
isClosable={hasSavedDraft}
onCloseRequest={() => setShowUnsavedWarning(true)}
>
{children}
</SheetNative>Testing
Pass your own testID and match on that. Never match the design system's
internal ids, which are generated per instance, or its default English strings.
A formSheet route renders no sheet surface under Jest, so nothing inside a
sheet is queryable there. Assert on the host navigation stack, or move the
assertion to Detox.
Common Mistakes
- Forgetting
id={SHEET_NAVIGATOR_ID}on the root navigator. The sheet may still appear, because the unhandled push bubbles to an ancestor stack, so this misconfiguration hides until it reaches a screen whose ancestors include no stack owning the route. Set theideven when things look fine. - Expecting call-site React context to reach
children. Sheet content renders under the navigator, not where you wrote it, so a form provider or aThemeoverride wrapping the caller is invisible inside the body, and a hook that throws without its provider will crash on open. Pass what the body needs as props, or re-provide the context insidechildren. The legacy portal had the same hole, so this is not a regression. - Assuming
isClosable={false}leaves the close button working. WhileisClosableis false,SheetNative.CloseActiondoes nothing but fireonCloseRequest, on both platforms. Render your owntrailingActionif you want a close affordance that survives the block. - Assuming
isClosable={false}blocks a swipe on Android.react-native-screensdoes not honourpreventNativeDismissthere, so on Android a swipe down and the back button still close the sheet. The close button is blocked on both platforms, because that path is pure JS. Tracked in MC-10315. - Reaching for a bare
ScrollView. UseSheetNative.ScrollView, and only undermode="fitToScreen"ormode="fullScreen".fitToContentderives its height from its children, so there is no viewport to scroll inside. - Looking for the legacy props.
size,header,footer,ignoreTopSafeArea,isDraggable,shouldEnableBodyScrolling,closeAccessibilityLabel,onClose,defaultOpen,idandroledo not exist onSheetNativeand do not silently no-op — they are absent from the type, so a missed call site is a type error.headerandfooterbecome the first and last elements ofchildren;onClosefolds intoonOpenChange. - Treating
onOpenChange(false)as "the user dismissed it". It fires on every close whatever triggered it, including a close the caller drove itself. Track that distinction yourself if analytics depend on it. It never fires withtrue: opening is always caller-driven. - Overriding the corner radius on iOS.
SheetNativesetssheetCornerRadiuson Android only, to match the iOS look. Leave it unset on iOS: overriding it is how a sheet stops looking native.