BottomSheet
Compound bottom sheet with snap points, gestures and scrollable content.
Installation
npx axiom add bottom-sheetpnpm dlx axiom add bottom-sheetyarn dlx axiom add bottom-sheetbun x axiom add bottom-sheetAlso copies: portal, overlay, tappable, slot, title, icon-button.
Requires the close icon in your icon registry.
The sheet follows the keyboard with react-native-keyboard-controller. It’s a native module: rebuild your development build after installing it, and wrap your app in its KeyboardProvider:
import { KeyboardProvider } from "react-native-keyboard-controller";
<GestureHandlerRootView style={{ flex: 1 }}>
<KeyboardProvider>
<PortalProvider>{/* … */}</PortalProvider>
</KeyboardProvider>
</GestureHandlerRootView>;Installs: react-native-reanimated, react-native-worklets, react-native-gesture-handler, react-native-safe-area-context.
The sheet renders in the root PortalHost, and its gestures need GestureHandlerRootView above it. Wrap your root layout once:
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { PortalHost, PortalProvider } from "@/components/core/portal";
export default function RootLayout() {
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<PortalProvider>
<Stack />
<PortalHost />
</PortalProvider>
</GestureHandlerRootView>
);
}Usage
import { BottomSheet } from "@/components/ui/bottom-sheet";
<BottomSheet.Root>
<BottomSheet.Trigger asChild>
<Button>Share</Button>
</BottomSheet.Trigger>
<BottomSheet.Content snapPoints={["50%", "90%"]}>
<BottomSheet.Handle />
<BottomSheet.Header title="Share" />
{/* content */}
</BottomSheet.Content>
</BottomSheet.Root>;Props
BottomSheet.Root
| Prop | Type | Default | Description |
|---|---|---|---|
open | boolean | — | Controlled visibility. |
defaultOpen | boolean | false | Initial visibility when the sheet manages its own state. |
onOpenChange | (open: boolean) => void | — | Called when the sheet opens or closes, including by swipe, backdrop press or a back action (Android back button, back gesture with a navigator). |
children | ReactNode | — | Trigger and Content. |
BottomSheet.Trigger
| Prop | Type | Default | Description |
|---|---|---|---|
asChild | boolean | false | Opens the sheet from the child element instead of rendering a pressable. |
children | ReactNode | — | The element that opens the sheet. |
BottomSheet.Content
| Prop | Type | Default | Description |
|---|---|---|---|
snapPoints | Array<number | string> | ['content'] | Heights the sheet rests at, from lowest to highest: points (320), a percentage of the screen ('50%'), or 'content' to size it to its children. |
index | number | 0 | Controlled snap point. |
onIndexChange | (index: number) => void | — | Called when the sheet settles on another snap point. |
overlay | boolean | true | Dims the screen behind the sheet through Overlay. The dimming follows the sheet position. |
dismissible | boolean | true | Closes on swipe down, backdrop press and back actions. false for a step the user must complete. |
detached | boolean | false | Floats above the bottom edge with all corners rounded, for short confirmations. |
stack | boolean | true | Scales the sheet underneath when this one opens over it, and restores it on close. See Stacking. |
haptic | HapticKind | false | — | Haptic played when a drag lands the sheet on another snap point. Off unless you pass a kind. |
keyboardBehavior | 'interactive' | 'extend' | 'none' | 'interactive' | How the sheet reacts when an input inside it opens the keyboard. interactive rises with the keyboard frame by frame, without going past the top safe area. extend also moves to the highest snap point. none leaves the sheet in place. Closing the sheet dismisses the keyboard. |
footer | ReactNode | — | Floating actions pinned above the bottom safe area, visible at every snap point. |
onDismiss | () => void | — | Called after the close animation ends. |
style | StyleProp<ViewStyle> | — | Extra styles for the sheet surface. |
BottomSheet.Handle, BottomSheet.Header, BottomSheet.ScrollView
| Part | Props | Description |
|---|---|---|
Handle | style | The grab bar. Drag it to resize. Announced as “Resize sheet” with up and down actions. |
Header | title, leading, trailing, closeButton, style | Title row. closeButton adds an IconButton that closes the sheet, with the close icon of your registry. |
ScrollView / FlatList | same as React Native | Scrollable content that hands the gesture back to the sheet when scrolled to the top. |
Haptics
Pass a kind to BottomSheet.Content, usually "light", to play a haptic when the user drags the sheet onto another snap point. Opening, closing and index changes made in code play nothing: the haptic answers the finger. Nothing plays until your app connects a haptics library: see Haptics.
Use cases
Actions on an item
snapPoints={['content']}: the sheet takes the height it needs and no more.
<BottomSheet.Content detached>
<BottomSheet.Handle />
<FileSummary file={file} />
<OptionItem label="Share" trailing={<Icon name="share" />} onPress={share} />
<OptionItem label="Delete" destructive onPress={confirmDelete} />
</BottomSheet.Content>Short form without leaving the screen
The sheet rises above the keyboard; the main button lives in footer so it stays reachable.
<BottomSheet.Content snapPoints={["content"]} keyboardBehavior="extend">
<BottomSheet.Header
leading={
<Button variant="ghost" onPress={close}>
Cancel
</Button>
}
title="New list"
trailing={
<Button variant="ghost" disabled={!name} onPress={create}>
Create
</Button>
}
/>
<Input autoFocus value={name} onChangeText={setName} accessibilityLabel="List name" />
</BottomSheet.Content>Map with a draggable list
Several snap points over content that stays interactive. The list scrolls once the sheet is fully open.
<BottomSheet.Root open>
<BottomSheet.Content
snapPoints={[120, "45%", "90%"]}
overlay={false}
dismissible={false}
>
<BottomSheet.Handle />
<SearchBar />
<BottomSheet.FlatList data={places} renderItem={renderPlace} />
</BottomSheet.Content>
</BottomSheet.Root>Stacking
Open a sheet from inside another one and the first doesn’t just sit there under the second. It scales down and stays behind, dimmed by the new sheet’s overlay, while the new one rises in front of it.
There is nothing to install and nothing to turn on. Write two sheets and open the second one:
<BottomSheet.Root open={filtersOpen} onOpenChange={setFiltersOpen}>
<BottomSheet.Content snapPoints={["60%"]}>
<BottomSheet.Handle />
<BottomSheet.Header title="Filters" />
<OptionItem label="Sort by" value={sort} onPress={() => setSortOpen(true)} />
</BottomSheet.Content>
</BottomSheet.Root>
<BottomSheet.Root open={sortOpen} onOpenChange={setSortOpen}>
<BottomSheet.Content>
<BottomSheet.Handle />
<BottomSheet.Header title="Sort by" closeButton />
<SortOptions value={sort} onChange={setSort} />
</BottomSheet.Content>
</BottomSheet.Root>Both roots sit at screen level, side by side. Nesting the second root inside the first one’s content works too, and ties their lifetimes together: closing the filters sheet unmounts the sort sheet with it.
With a single sheet open, none of this runs and nothing about the sheet changes.
The movement
Opening and closing are not mirror images of each other.
One sheet. Nothing about it differs from a sheet with no stack behind it.
Both springs start together: the one underneath shrinks, the new one rises.
Settled. The sheet underneath is narrower, dimmed by the new overlay, and inert.
The top sheet slides out without scaling, while the one underneath grows back.
The sheet underneath only ever scales. It doesn’t move up, it doesn’t move
down; it shrinks by metrics.stackScale and comes back to full size later, from
where it was.
The sheet on top only ever slides. It rises into place when it opens and drops off the bottom when it closes, at full size the whole way. Nothing about it says it is part of a stack.
Both animations start on the same frame and use the same spring, so the sheet underneath is already growing back while the one above is still on its way out.
A third sheet scales the second and leaves the first where it is: depth stops
counting at two, so a sheet never shrinks past stackScale².
What the top sheet takes over
Being covered isn’t only a matter of scale. A covered sheet hands over everything that can be aimed at it:
| Concern | Behavior |
|---|---|
| Drag | Only the sheet on top responds, so a buried sheet can’t be resized or swiped away by accident. |
| Touches | A covered sheet stops taking presses. A button half-visible behind the new sheet can’t be hit. |
| Back action | The Android back button and the back gesture close the sheet on top. |
| Backdrop | Pressing it closes the sheet on top, not the pile. |
| Screen reader | The sheet on top is the only modal. Covered sheets leave the accessibility tree. |
Overlays stay per sheet. The new sheet brings its own, which is what dims the
one underneath and gives the recession its depth — the price is that the app
behind two sheets is dimmed twice. overlay={false} on the second sheet removes
that second layer, along with the dimming of the first sheet.
Leaving the stack
A sheet with stack={false} opens over the current one without scaling it.
Use it when the second sheet interrupts rather than continues — a confirmation
over the sheet that asked for it:
<BottomSheet.Content stack={false} detached snapPoints={["content"]}>
<Text>Delete this list?</Text>
</BottomSheet.Content>It still takes the drag, the backdrop and the back action while it’s on top. What it doesn’t do is touch the sheet underneath.
Dialog and Menu
A Dialog and a Menu join the same stack, with nothing to set up.
A dialog opened from a sheet pushes it back one level, like a second sheet would. A menu takes the top spot but leaves the sheet at full size: it is anchored to a trigger on that sheet, and scaling the sheet would slide the trigger out from under it. Either way, the sheet underneath hands over the drag, the touches, the back action and the screen reader until the dialog or menu closes.
When to use it
Use a sheet when the user needs to act without losing the current screen: pick an option, fill a short form, confirm details. See Mobile design guidelines.