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.

modePresentationDetentsGrabberTop inset applied
fullScreenfullScreenModaln/anoneboth platforms
fitToScreenformSheetnative defaultyesneither
fitToContent (default)formSheetfitToContentsyesneither

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 the id even 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 a Theme override 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 inside children. The legacy portal had the same hole, so this is not a regression.
  • Assuming isClosable={false} leaves the close button working. While isClosable is false, SheetNative.CloseAction does nothing but fire onCloseRequest, on both platforms. Render your own trailingAction if you want a close affordance that survives the block.
  • Assuming isClosable={false} blocks a swipe on Android. react-native-screens does not honour preventNativeDismiss there, 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. Use SheetNative.ScrollView, and only under mode="fitToScreen" or mode="fullScreen". fitToContent derives 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, id and role do not exist on SheetNative and do not silently no-op — they are absent from the type, so a missed call site is a type error. header and footer become the first and last elements of children; onClose folds into onOpenChange.
  • 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 with true: opening is always caller-driven.
  • Overriding the corner radius on iOS. SheetNative sets sheetCornerRadius on Android only, to match the iOS look. Leave it unset on iOS: overriding it is how a sheet stops looking native.