Sheet
A bottom sheet that slides up
import { ChevronDown, ChevronUp } from '@tamagui/lucide-icons'import type { SheetProps } from '@tamagui/sheet'import { Sheet } from '@tamagui/sheet'import React from 'react'import { Button, H2, Input, Paragraph, XStack, YStack } from 'tamagui'const spModes = ['percent', 'constant', 'fit', 'mixed'] as constexport const SheetDemo = () => {const [position, setPosition] = React.useState(0)const [open, setOpen] = React.useState(false)const [modal, setModal] = React.useState(true)const [innerOpen, setInnerOpen] = React.useState(false)const [snapPointsMode, setSnapPointsMode] =React.useState<(typeof spModes)[number]>('percent')const [mixedFitDemo, setMixedFitDemo] = React.useState(false)const isPercent = snapPointsMode === 'percent'const isConstant = snapPointsMode === 'constant'const isFit = snapPointsMode === 'fit'const isMixed = snapPointsMode === 'mixed'const snapPoints = isPercent? [85, 50, 25]: isConstant? [256, 190]: isFit? undefined: mixedFitDemo? ['fit', 110]: ['80%', 256, 190]return (<><YStack gap="$4"><XStack gap="$4" $sm={{ flexDirection: 'column', alignItems: 'center' }}><Button onPress={() => setOpen(true)}>Open</Button><Button onPress={() => setModal((x) => !x)}>{modal ? 'Type: Modal' : 'Type: Inline'}</Button><Button onPress={() => setSnapPointsMode( (prev) => spModes[(spModes.indexOf(prev) + 1) % spModes.length] ) } >{`Mode: ${ { percent: 'Percentage', constant: 'Constant', fit: 'Fit', mixed: 'Mixed' }[ snapPointsMode ] }`}</Button></XStack>{isMixed ? (<Button onPress={() => setMixedFitDemo((x) => !x)}>{`Snap Points: ${JSON.stringify(snapPoints)}`}</Button>) : (<XStack paddingVertical="$2.5" justifyContent="center"><Paragraph>{`Snap Points: ${isFit ? '(none)' : JSON.stringify(snapPoints)}`}</Paragraph></XStack>)}</YStack><Sheet forceRemoveScrollEnabled={open} modal={modal} open={open} onOpenChange={setOpen} snapPoints={snapPoints} snapPointsMode={snapPointsMode} dismissOnSnapToBottom position={position} onPositionChange={setPosition} zIndex={100_000} animation="medium" ><Sheet.Overlay animation="lazy" enterStyle={{ opacity: 0 }} exitStyle={{ opacity: 0 }} /><Sheet.Handle /><Sheet.Frame padding="$4" justifyContent="center" alignItems="center" gap="$5"><Button size="$6" circular icon={ChevronDown} onPress={() => setOpen(false)} /><Input width={200} />{modal && isPercent && (<><InnerSheet open={innerOpen} onOpenChange={setInnerOpen} /><Button size="$6" circular icon={ChevronUp} onPress={() => setInnerOpen(true)} /></>)}</Sheet.Frame></Sheet></>)}function InnerSheet(props: SheetProps) {return (<Sheet animation="medium" modal snapPoints={[90]} dismissOnSnapToBottom {...props}><Sheet.Overlay animation="medium" enterStyle={{ opacity: 0 }} exitStyle={{ opacity: 0 }} /><Sheet.Handle /><Sheet.Frame flex={1} justifyContent="center" alignItems="center" gap="$5"><Sheet.ScrollView><YStack p="$5" gap="$8"><Button size="$6" circular alignSelf="center" icon={ChevronDown} onPress={() => props.onOpenChange?.(false)} /><H2>Hello world</H2>{[1, 2, 3, 4, 5, 6, 7, 8].map((i) => (<Paragraph key={i} size="$8">Eu officia sunt ipsum nisi dolore labore est laborum laborum in esse adpariatur. Dolor excepteur esse deserunt voluptate labore ea. Exercitationipsum deserunt occaecat cupidatat consequat est adipisicing velitcupidatat ullamco veniam aliquip reprehenderit officia. Officia laboreculpa ullamco velit. In sit occaecat velit ipsum fugiat esse aliqua dolorsint.</Paragraph>))}</YStack></Sheet.ScrollView></Sheet.Frame></Sheet>)}
Features
Lightweight implementation with dragging support.
Multiple snap point points and a handle.
Automatically adjusts to screen size.
Accepts animations, themes, size props and more.
Installation
Sheet is already installed in tamagui
, or you can install it independently:
yarn add @tamagui/sheet
PortalProvider
When rendering into root of app instead of inline, you'll first need to install the @tamagui/portal
package:
yarn add @tamagui/portal
Then add PortalProvider
to the root of your app:
App.tsx
import { PortalProvider } from '@tamagui/portal'import YourApp from './components/YourApp'function App() {return (<PortalProvider shouldAddRootHost><YourApp /></PortalProvider>)}export default App
Props
shouldAddRootHost
boolean
Defines whether to add a default root host or not.
Anatomy
import { Sheet } from 'tamagui' // or '@tamagui/sheet'export default () => (<Sheet><Sheet.Overlay /><Sheet.Handle /><Sheet.Frame>{/* ...inner contents */}</Sheet.Frame></Sheet>)
Snap points
By default, snap points are treated as percentages.
<Sheet snapPoints={[85, 50]}> // 85% and 50%
The behavior of snap points can be changed by setting the snapPointsMode
prop to any of these values:
- percent (default) - Snap points are percentages of the parent container or screen as numbers
- constant - Snap points are raw pixel values as numbers
- fit - The sheet is constrained to the content's natural height without the
snapPoints
prop - mixed - Snap points can be either numbers (pixels), percentages as strings (ex:
"50%"
), or"fit"
for fit behavior
Snap points should be ordered from largest to smallest (most visible to least visible). When using mixed
mode with the "fit"
as a snap point, it must be the first and largest snap point.
Unstyled
Adding the unstyled
prop to your Handle, Overlay or Frame will turn off the default styles allowing you to customize without having to override any of the built-in styling.
Headless with createSheet
Using the createSheet
export, you can create a fully custom sheet without using any of the default styles. This is similar to unstyled
, but it lets you also control the open
variant.
Here's an example:
import { Stack, styled } from '@tamagui/core'import { createSheet } from '@tamagui/sheet'const Handle = styled(Stack, {variants: {open: {true: {opacity: 0.35,},false: {opacity: 0.5,},},} as const,})const Overlay = styled(Stack, {variants: {open: {true: {opacity: 1,pointerEvents: 'auto',},false: {opacity: 0,pointerEvents: 'none',},},} as const,})const Frame = styled(Stack, {backgroundColor: '$background',// can add open variant as well})export const Sheet = createSheet({Frame,Handle,Overlay,})
Native support
Sheets now support rendering to a native iOS sheet, while still rendering any of your React Native content inside of them.
Because Metro doesn't support conditional imports and we don't want to make tamagui
enforce installing native dependencies in order to get started, there's an install step.
As of the time of writing, we are using the new 3.0.x
branch which is in beta. Until ready, it does require a bit more setup.
yarn add react-native-ios-modal@next react-native-ios-utilities@next @dominicstop/ts-event-emitter
Then, rebuild your native iOS app so it picks up the new native dependencies. This is done either through Expo or plain React Native.
Finally, set it up:
import { Sheet, setupNativeSheet } from '@tamagui/sheet'import * as NativeModal from 'react-native-ios-modal'setupNativeSheet('ios', NativeModal)// now you can use the `native` prop:export default (<Sheet native>{/* ... the rest of your sheet */}</Sheet>)
API Reference
Sheet
Contains every component for the sheet.
Props
open
boolean
Set to use as controlled component.
defaultOpen
boolean
Uncontrolled open state on mount.
onOpenChange
(open: boolean) => void
Called on change open, controlled or uncontrolled.
position
number
Controlled position, set to an index of snapPoints.
defaultPosition
number
Uncontrolled default position on mount.
snapPoints
(number | string)[] | undefined
Default:
[80]
Array of values representing different sizes for the sheet to snap to. Not used in 'fit' mode. See docs above for usage information.
snapPointsMode
"percent" | "constant" | "fit" | "mixed"
Default:
"percent"
Alters the behavior of the 'snapPoints' prop. See docs above for usage information.
onPositionChange
(position: number) => void
Called on change position, controlled or uncontrolled.
dismissOnOverlayPress
boolean
Default:
true
Controls tapping on the overlay to close, defaults to true.
animationConfig
Animated.SpringAnimationConfig
Default:
true
Customize the spring used, passed to react-native Animated.spring().
native
boolean | "ios"[]
(iOS only) Render to a native sheet, must install native dependency first.
disableDrag
boolean
Disables all touch events to drag the sheet.
modal
boolean
Renders sheet into the root of your app instead of inline.
dismissOnSnapToBottom
boolean
Adds a snap point to the end of your snap points set to "0", that when snapped to will set open to false (uncontrolled) and call onOpenChange with false (controlled).
forceRemoveScrollEnabled
boolean
Default:
false
By default. Tamagui uses react-remove-scroll to prevent anything outside the sheet scrolling. This can cause some issues so you can override the behavior with this prop (either true or false).
portalProps
Object
YStack props that can be passed to the Portal that sheet uses when in modal mode.
moveOnKeyboardChange
boolean
Default:
false
Native-only flag that will make the sheet move up when the mobile keyboard opens so the focused input remains visible.
unmountChildrenWhenHidden
boolean
Default:
false
Flag to enable unmounting the children after the exit animation has completed.
If using modal={true}
(which is true
by default), refer to the PortalProvider installation for more information.
Sheet.Overlay
Displays behind Frame. Extends YStack.
Sheet.Frame
Contains the content. Extends YStack.
Props
disableHideBottomOverflow
boolean
Disables Sheet cloning the Frame and positioning it below the frame, which helps to hide content that may appear underneath when spring animations bounce past 100%.
Sheet.Handle
Shows a handle above the frame by default, on tap it will cycle between snapPoints
but this can be overridden with onPress
.
Extends XStack.
Sheet.ScrollView
Allows scrolling within Sheet. Extends ScrollView.
useSheet
Use this to control the sheet programatically.
Props
open
boolean
Set to use as controlled component.
setOpen
Function
Control the open state of the sheet.
setPosition
(index: number) => void
Control the position of the sheet.
Notes
For Android you need to manually re-propagate any context when using modal
. This is because React Native doesn't support portals yet.