How to make a bottom sheet in React Native

By the LumioUI team · Updated

A bottom sheet is a panel that slides up from the bottom of the screen over a dimmed backdrop. To build one in React Native, move it with a Reanimated shared value (translateY). A gesture-handler pan lets people drag it down, and a flick or a long drag closes it. The quickest route is the LumioUI Action Sheet, shown below, or the Pro Bottom Sheet when you need snap heights.

Live demo of the LumioUI Action Sheet component. It's interactive, so try it.

How a bottom sheet works

  1. Position. One shared value holds how far the sheet is pushed down: 0 when open, the screen height when closed. Animate it with a spring to open and a short timing to close, so leaving is quicker than arriving.
  2. Backdrop. Derive the backdrop's opacity from the same value, so it dims in step with the sheet and never gets out of sync.
  3. Drag to dismiss. A pan gesture adds the finger's movement to the value. On release, a fast flick (around 800 points per second) or a drag past a threshold closes it; anything else springs back.
  4. Accessibility. Give the backdrop a "Close" label, move focus into the sheet when it opens, and let screen-reader users close it without dragging.

Here is that idea in plain Reanimated and gesture-handler:

TSX Sheet.tsx
import * as React from "react";
import { Dimensions, Pressable, StyleSheet, View } from "react-native";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import Animated, {
  runOnJS, useAnimatedStyle, useSharedValue, withSpring, withTiming,
} from "react-native-reanimated";

const H = Dimensions.get("window").height;

export function Sheet({ open, onClose, children }) {
  // 0 = fully open, H = off screen
  const y = useSharedValue(H);

  React.useEffect(() => {
    y.value = open ? withSpring(0, { damping: 22, stiffness: 220 }) : withTiming(H, { duration: 200 });
  }, [open]);

  const drag = Gesture.Pan()
    .onChange((e) => { y.value = Math.max(0, y.value + e.changeY); })
    .onEnd((e) => {
      // A fast flick down or a drag past a third closes it
      if (e.velocityY > 800 || y.value > 200) runOnJS(onClose)();
      else y.value = withSpring(0);
    });

  const sheet = useAnimatedStyle(() => ({ transform: [{ translateY: y.value }] }));
  const backdrop = useAnimatedStyle(() => ({ opacity: 1 - y.value / H }));

  return (
    <View style={StyleSheet.absoluteFill} pointerEvents={open ? "auto" : "none"}>
      <Animated.View style={[StyleSheet.absoluteFill, { backgroundColor: "rgba(0,0,0,.4)" }, backdrop]}>
        <Pressable style={{ flex: 1 }} onPress={onClose} accessibilityLabel="Close" />
      </Animated.View>
      <GestureDetector gesture={drag}>
        <Animated.View style={[{ position: "absolute", left: 0, right: 0, bottom: 0 }, sheet]}>
          {children}
        </Animated.View>
      </GestureDetector>
    </View>
  );
}

It works, but a production sheet needs more: safe-area padding, a keyboard that doesn't cover inputs, reduced-motion support, haptics, and a backdrop that blocks touches only while open.

The quick way: LumioUI Action Sheet

The Action Sheet handles all of that. It's a detached sheet of actions that rises over a fading backdrop. Your pick stays highlighted while the other rows dim, then it closes. Drag down or tap outside to dismiss. It's free and runs in Expo Go.

TSX PhotoMenu.tsx
import * as React from "react";
import { ActionSheet } from "@/components/ui/action-sheet/action-sheet";
import { Button } from "@/components/ui/button/button";

export function PhotoMenu() {
  const [open, setOpen] = React.useState(false);
  return (
    <>
      <Button onPress={() => setOpen(true)}>Edit photo</Button>
      <ActionSheet
        open={open}
        onOpenChange={setOpen}
        title="Edit photo"
        message="Changes apply to this photo only."
        actions={[
          { label: "Crop", onPress: () => {} },
          { label: "Replace", onPress: () => {} },
          { label: "Delete photo", destructive: true, onPress: () => {} },
        ]}
      />
    </>
  );
}
  • open and onOpenChange make it controlled. Use defaultOpen to let it manage itself.
  • Actions take destructive and disabled. cancelLabel renames the Cancel row.
  • material="glass" switches to the glass surface over photos or video.

Need a sheet that settles at different heights, holds forms and keeps clear of the keyboard? Use the Pro Bottom Sheet.

Add it to your app

LumioUI's CLI copies the component's source into your project, so it's yours to read and change. Run init once per project:

npx @lumioui/cli init
npx @lumioui/cli add action-sheet

It works in Expo (SDK 54+) and bare React Native, with Reanimated 4 and NativeWind. See the installation guide and the full Action Sheet API.

More React Native guides