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.

Open Source

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-sheet

Rebuild 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

app/index.tsx
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.

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.

app/_layout.tsx
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

PartWhat it is
BottomSheetState, sizing and behaviour. Renders no view
BottomSheet.TriggerOpens the sheet — a Pressable, or asChild to donate the press to a Button
BottomSheet.PortalWhere the sheet draws: the root host by default, in place with inline
BottomSheet.OverlayThe scrim. Omit it and no scrim is drawn
BottomSheet.ContainerThe panel that moves. Writes Background and Handle when you write neither
BottomSheet.BackgroundThe panel's surface — bg-popover, top corners rounded, every corner when detached
BottomSheet.HandleThe grabber's row and the pill inside it; the one accessibility-adjustable element
BottomSheet.ContentThe static body, measured for the content snap point
BottomSheet.ScrollView / FlatList / SectionList / LegendListA scrolling body; its content size is the content snap point
BottomSheet.Steps / BottomSheet.StepA body whose contents follow a machine, one Step per state
BottomSheet.FooterControls at the bottom. In the flow by default, sticky to pin
BottomSheet.CloseThe dismiss control, out of the content's flow in the top-right
BottomSheet.TitleThe heading — a Text.Header the panel is labelled by, with the close control's clearance
BottomSheet.DescriptionSupporting copy — a muted Text.Paragraph
BottomSheet.TextInputAn Input registered with the sheet's keyboard handling
BottomSheet.HostA teleport target inside a native modal
BottomSheetProviderThe 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.

On this page