BottomSheet
A sheet that rises from the bottom edge — snap points, keyboard, sticky footer, scrollables and multi-step bodies, on @delacour/react-native-bottom-sheet.
Installation
BottomSheet ships with the library — install it once, then import:
import { BottomSheet } from "@delacour/react-native-ui/bottom-sheet";Or copy the source into your project, to own and edit it:
bunx delacour@latest add bottom-sheetRebuild the dev client
react-native-teleport is a native module and needs the new architecture, so after installing,
rebuild the dev client (npx expo run:ios, or an EAS development build). It does not run in Expo
Go. Then mount BottomSheetProvider once, inside DelacourProvider.
Usage
import { BottomSheet } from "@/components/ui/bottom-sheet";
import { Button } from "@/components/ui/button";
import { Text } from "@/components/ui/text";
import { View } from "react-native";
export default function Example() {
return (
<View className="flex-1 items-center justify-center p-4">
<BottomSheet>
<BottomSheet.Trigger asChild>
<Button>Open</Button>
</BottomSheet.Trigger>
<BottomSheet.Portal>
<BottomSheet.Overlay />
<BottomSheet.Container>
<BottomSheet.Content>
<BottomSheet.Close />
<BottomSheet.Title>Filters</BottomSheet.Title>
<BottomSheet.Description>Narrow the list down.</BottomSheet.Description>
<Text.Paragraph>Nothing is filtered yet.</Text.Paragraph>
</BottomSheet.Content>
</BottomSheet.Container>
</BottomSheet.Portal>
</BottomSheet>
</View>
);
}Container is the panel that moves; Content is the static body inside it. Container writes a
Background and a Handle for you unless you write either yourself.
Controlled
onOpenChange fires on every open and close — a trigger, a swipe down, a press on the overlay,
BottomSheet.Close, the ref, or a controlled isOpen.
<BottomSheet isOpen={isOpen} onOpenChange={setIsOpen}>…</BottomSheet>A ref gives the imperative surface — open, close, snapToIndex, expand, collapse — and
useBottomSheet() reads the same state from inside the tree.
const sheet = useRef<BottomSheetRef>(null);
<BottomSheet ref={sheet} snapPoints={["40%", "90%"]}>…</BottomSheet>;
sheet.current?.snapToIndex(1);Sizing
Sizing is the root's: by default a sheet is as tall as its content, and snapPoints add
snap points to rest at. Heights are pixels above the safe-area bottom, or a percentage of the window.
<BottomSheet>…</BottomSheet>
<BottomSheet maxDynamicContentSize={420}>…</BottomSheet>
<BottomSheet snapPoints={["40%", "90%"]}>…</BottomSheet>
<BottomSheet snapPoints={[320]} dynamicSizing={false}>…</BottomSheet>maxDynamicContentSize caps the content snap point, and dynamicSizing={false} drops it so the
sheet rests only on the snap points you named. The rules — height space, the three kinds of snap point,
the index — are on Snap points.
Scrolling
A list as the body needs nothing else: its content size is the dynamic snap point, capped by
maxDynamicContentSize, and a finger at the top of the list moves the sheet while one anywhere
else scrolls.
<BottomSheet.Container>
<BottomSheet.ScrollView>{rows}</BottomSheet.ScrollView>
</BottomSheet.Container>
<BottomSheet.FlatList data={items} keyExtractor={(item) => item.id} renderItem={renderRow} />
<BottomSheet.SectionList sections={sections} renderItem={renderRow} renderSectionHeader={renderHeader} />
<BottomSheet.LegendList data={items} estimatedItemSize={56} renderItem={renderRow} />ScrollView, FlatList and SectionList are the engine's; LegendList is built here because
@legendapp/list is this library's optional peer. Which of the two a drag moves is on
Scrollables.
Footer
A footer is in the flow by default — write it inside the body. sticky pins it over the body,
standing on the safe-area band and riding the keyboard's edge; write that one inside Container,
after the body.
<BottomSheet.Container>
<BottomSheet.ScrollView>{rows}</BottomSheet.ScrollView>
<BottomSheet.Footer sticky>
<Button onPress={apply}>Apply</Button>
</BottomSheet.Footer>
</BottomSheet.Container>A pinned footer brings its own surface and a top hairline, because the body scrolls under it. The band it stands on, and how it collapses when the keyboard is up, is on Footer.
Do not put a KeyboardStickyView inside a sheet. The sticky footer already moves with the
keyboard, and a second mechanism on the same view fights it every frame.
Text input
BottomSheet.TextInput is this library's Input already registered with the sheet. For a field
of your own, spread useBottomSheetInput() onto it.
import { BottomSheet, useBottomSheetInput } from "@/components/ui/bottom-sheet";
<BottomSheet.TextInput placeholder="Search" value={query} onChangeText={setQuery} />;
function Field() {
const input = useBottomSheetInput();
return <Input {...input} value={query} onChangeText={setQuery} />;
}Registration is what makes moving focus between two fields one resize rather than a close and a
reopen. By default the sheet also claims any keyboard opened by a field inside its frame; the
keyboardBehavior and keyboardScope props on the root choose what it does with one —
interactive lifts the sheet by the keyboard, extend snaps to the next snap point, fillParent
takes the sheet to the top. See Keyboard.
Detached
detached floats the sheet as a card, inset from the sides and the bottom, with every corner
rounded.
<BottomSheet detached>…</BottomSheet>
<BottomSheet detached={{ horizontalMargin: 24, bottomOffset: 32 }}>…</BottomSheet>Background rounds all four corners on its own when the root is detached; the card is fully
off-screen when closed and a tap in the gap under it closes it. See
Detached.
Multi-step
Steps replaces Content as the body: a typed machine from defineSheetMachine says which step
is current, useSheetMachine runs it, and the sheet's height glides between steps rather than
jumping.
import { BottomSheet, defineSheetMachine, useSheetMachine, useSheetStep } from "@/components/ui/bottom-sheet";
const machine = defineSheetMachine({
id: "signup",
initial: "details",
context: {},
states: {
details: { on: { NEXT: "confirm" } },
confirm: { dismissible: false, on: { BACK: "details", SUBMIT: "success" } },
success: { snapPoints: ["35%"] },
},
});
function SignUp() {
const controller = useSheetMachine(machine);
return (
<BottomSheet>
<BottomSheet.Portal>
<BottomSheet.Overlay />
<BottomSheet.Container>
<BottomSheet.Steps controller={controller}>
<BottomSheet.Step name="details">
<BottomSheet.Title>Your details</BottomSheet.Title>
</BottomSheet.Step>
<BottomSheet.Step name="confirm">
<BottomSheet.Title>Confirm</BottomSheet.Title>
</BottomSheet.Step>
<BottomSheet.Step name="success">
<BottomSheet.Title>You are in</BottomSheet.Title>
</BottomSheet.Step>
</BottomSheet.Steps>
<BottomSheet.Footer sticky>
<StepActions />
</BottomSheet.Footer>
</BottomSheet.Container>
</BottomSheet.Portal>
</BottomSheet>
);
}
function StepActions() {
const step = useSheetStep();
return <Button disabled={!step.can({ type: "NEXT" })} onPress={() => step.send({ type: "NEXT" })}>Next</Button>;
}useSheetStep() reads the controller anywhere inside the sheet — a footer beside the body
included. A step's snapPoints replace the root's while it is current, and dismissible: false
switches off pan-down-to-close and the overlay press. The machine, the transitions and the height
are on Steps.
Inline or modal
A Portal is modal by default: it teleports to the app's root host, draws over the navigator
and the tab bar, and unmounts its children when closed. inline renders it where it is written,
as an absolute fill of the nearest positioned parent, and never unmounts — a persistent drawer,
a map's result list.
<View className="flex-1">
<MapView />
<BottomSheet snapPoints={["25%", "60%"]} dynamicSizing={false} enablePanDownToClose={false} defaultOpen>
<BottomSheet.Portal inline>
<BottomSheet.Container>…</BottomSheet.Container>
</BottomSheet.Portal>
</BottomSheet>
</View>With no Overlay, the view around an inline sheet stays interactive. A Portal with no provider
above it is inline whether or not it says so.
Inside a native modal
A native Modal, or a screen a navigator presents as one, is its own window, and a sheet
teleported to the root host draws behind it. Wrap that screen's content in
<BottomSheet.Host name="modal"> and every sheet written inside targets it.
<Modal animationType="slide" visible={visible}>
<BottomSheet.Host name="modal" style={{ flex: 1 }}>
<BottomSheet>
<BottomSheet.Trigger asChild>
<Button>Open</Button>
</BottomSheet.Trigger>
<BottomSheet.Portal>
<BottomSheet.Overlay />
<BottomSheet.Container>…</BottomSheet.Container>
</BottomSheet.Portal>
</BottomSheet>
</BottomSheet.Host>
</Modal>Written with no children, <BottomSheet.Host name="modal" /> is an absolute fill instead: put it
last in the screen, and a sheet elsewhere names it with hostName="modal". Who is on top when two
sheets are open is on Portal and host.
Provider
Mount BottomSheetProvider once, inside DelacourProvider and around the navigator. It is the
root host every modal Portal teleports to, and DelacourProvider cannot mount it: the engine
is an optional peer, and an import there would make every app build react-native-teleport,
sheet or no sheet.
import { BottomSheetProvider } from "@/components/ui/bottom-sheet";
import { DelacourProvider } from "@/components/ui/provider";
<DelacourProvider>
<BottomSheetProvider>{children}</BottomSheetProvider>
</DelacourProvider>;Without it, every Portal is inline.
API Reference
Requirements
@delacour/react-native-bottom-sheet and react-native-teleport are optional peers — installed
only by an app that imports this subpath — and the app mounts
DelacourProvider with BottomSheetProvider inside it.
Anatomy
| Part | What it is |
|---|---|
BottomSheet | State, sizing and behaviour. Renders no view |
BottomSheet.Trigger | Opens the sheet — a Pressable, or asChild to donate the press to a Button |
BottomSheet.Portal | Where the sheet draws: the root host by default, in place with inline |
BottomSheet.Overlay | The scrim. Omit it and no scrim is drawn |
BottomSheet.Container | The panel that moves. Writes Background and Handle when you write neither |
BottomSheet.Background | The panel's surface — bg-popover, top corners rounded, every corner when detached |
BottomSheet.Handle | The grabber's row and the pill inside it; the one accessibility-adjustable element |
BottomSheet.Content | The static body, measured for the content snap point |
BottomSheet.ScrollView / FlatList / SectionList / LegendList | A scrolling body; its content size is the content snap point |
BottomSheet.Steps / BottomSheet.Step | A body whose contents follow a machine, one Step per state |
BottomSheet.Footer | Controls at the bottom. In the flow by default, sticky to pin |
BottomSheet.Close | The dismiss control, out of the content's flow in the top-right |
BottomSheet.Title | The heading — a Text.Header the panel is labelled by, with the close control's clearance |
BottomSheet.Description | Supporting copy — a muted Text.Paragraph |
BottomSheet.TextInput | An Input registered with the sheet's keyboard handling |
BottomSheet.Host | A teleport target inside a native modal |
BottomSheetProvider | The root host, mounted once inside DelacourProvider |
Every part renders where it is written. The engine provides its contexts once at the root and teleport moves the native view, not the React tree, so a context provided around the trigger reads inside the sheet.
BottomSheet
The engine's root with this library's defaults: bottomInset from the safe area, selection
haptics on a snap point and impactLight on a close, and the stale-keyboard guard on mount.
Prop
Type
The root takes no ViewProps — it renders no view. Every engine prop passes through; the full
list, with onAnimate, overDragResistanceFactor, overrideReduceMotion and the rest, is on
Composition.
BottomSheet.Trigger
This library's Pressable, so it takes its whole vocabulary. With asChild the child element
is the trigger and the engine's onPress is donated to it rather than wrapping it.
Prop
Type
BottomSheet.Portal
Prop
Type
BottomSheet.Overlay
The scrim, in bg-overlay at opacity 1 — the token carries its own alpha. Its opacity follows
the index, never the keyboard, so a lift does not dim the app.
Prop
Type
BottomSheet.Container
The panel. Extends ViewProps; sizing and behaviour are the root's.
Prop
Type
The three *ClassName props reach only the parts it writes. Write a Background or a Handle
yourself and it writes neither.
BottomSheet.Background
An absolute fill under everything in the panel — bg-popover, top corners rounded, every corner
when the root is detached. Extends ViewProps.
Prop
Type
BottomSheet.Handle
The grabber's row and the pill inside it. The sheet's one adjustable accessibility element —
increment and decrement move a snap point, escape closes. Extends ViewProps.
Prop
Type
BottomSheet.Content
The static body, with the gutter and gap on the box the content snap point measures. Extends
ViewProps.
Prop
Type
BottomSheet.ScrollView
Extends ScrollViewProps. The content classes land on an inner View, so the engine's one style
on the content container — the gap above a pinned footer — has a single writer.
Prop
Type
BottomSheet.FlatList / SectionList / LegendList
Extend the list's own props — FlatListProps<ItemT>, SectionListProps<ItemT, SectionT>,
AnimatedLegendListProps<ItemT> — plus focusHook above.
Prop
Type
BottomSheet.Footer
Extends ViewProps. Inline by default; a pinned footer takes padding through the engine so it
counts in the sheet's height, and carries a surface and a top hairline.
Prop
Type
BottomSheet.Close
This library's Pressable with feedback="fade" and an 8pt hit slop, holding an IconCrossSmall.
Takes PressableProps except asChild, busy, children and disabled.
Prop
Type
BottomSheet.Title / Description
Text.Header and a muted Text.Paragraph, taking Text's preset props. Title publishes the
nativeID the panel is labelled by and carries pr-8 for the close control.
Prop
Type
BottomSheet.TextInput
This library's Input with useBottomSheetInput() spread on it. Takes InputProps.
Prop
Type
BottomSheet.Steps / Step
Steps replaces Content as the body and extends ViewProps; Step is one state's body.
Prop
Type
BottomSheet.Host / BottomSheetProvider
Prop
Type
Hooks
useBottomSheet() reads isOpen, open, close, setOpen and the ref methods inside a sheet.
useBottomSheetInput() returns { ref, onFocus, onBlur } for a field of your own — the engine's
useBottomSheetTextInput under the name this library shipped. useSheetMachine, useSheetStep,
defineSheetMachine and useBottomSheetRegistry are re-exported from the engine.
The behaviour is @delacour/react-native-bottom-sheet, a headless engine
with no tokens and no className of its own, documented under its own tab. It installs alongside
this component and is where to go for a sheet this one does not skin, and for how the snap points,
the keyboard and the footer are computed.