Skip to content
Native Animation

React Native Sliding Up Panel: Bottom Sheet Snap Points

By Native Animation · Updated

A React Native sliding up panel reveals details from the bottom while leaving context visible behind it. It is often called a persistent bottom sheet. This tutorial builds collapsed and expanded snap points using one Reanimated progress value, a vertical handle drag, velocity-aware release, and labeled controls for people who do not drag.

This is an inline, nonmodal sheet. The background remains available, and there is no dismissible overlay or focus trap. That makes it a useful starting point for a map preview or compact detail panel. A modal dialog, a form with a keyboard, or a sheet containing a scrolling list requires additional behavior explained below.

Slide up panel or bottom sheet: choose the behavior first

A slide up panel describes motion; bottom sheet describes the surface. For a map preview, keep a collapsed portion visible and let users move between measured anchors. For a blocking decision, use modal behavior with background touch interception, accessibility isolation, focus management, and a dismissal path. Sliding a view upward alone does not provide those behaviors.

This tutorial uses two persistent anchors and gives the handle ownership of the pan. It does not arbitrate scrolling content or manage keyboard avoidance. Decide these requirements before adding an input or long list, then test interrupted drags, short containers, and changes in height.

Snap Sheet Backdrop adds a specific three-anchor scene: 18%, 50%, and 90% of its measured container, a selectable drawn map, and backdrop recentering, scaling, and dimming. Its ref exposes snapTo, next, and selectLocation. It is a place-detail composition with application callbacks, not a general modal manager.

Setup

The example targets Expo SDK 58, React Native 0.88.0, Reanimated 4.7.1, Worklets 0.13.0, and Gesture Handler 3.3:

npx expo install react-native-reanimated react-native-worklets react-native-gesture-handler

Use bunx in a Bun project. See the Expo Reanimated setup and Gesture Handler reference. Create SnapSheet.tsx outside your route directory. Your host screen owns safe-area padding; measure the usable container inside that padding rather than using the full device height.

Build a measured sheet

The Gesture.* builder API remains available in Gesture Handler 3.3, but is deprecated in favor of its hook API. The example retains the builders.

import { useState } from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { Gesture, GestureDetector, GestureHandlerRootView } from 'react-native-gesture-handler';
import Animated, {
  cancelAnimation, useAnimatedStyle, useReducedMotion,
  useSharedValue, withSpring,
} from 'react-native-reanimated';
import { scheduleOnRN } from 'react-native-worklets';

export function SnapSheet({ height }: { height: number }) {
  const sheetHeight = height * 0.75;
  const peekHeight = Math.min(120, sheetHeight * 0.5);
  const travel = Math.max(1, sheetHeight - peekHeight);
  const [expanded, setExpanded] = useState(false);
  const progress = useSharedValue(0);
  const origin = useSharedValue(0);
  const committed = useSharedValue(0);
  const reduceMotion = useReducedMotion();

  function report(next: number) {
    setExpanded(next === 1);
  }

  function choose(next: number) {
    committed.set(next);
    progress.set(reduceMotion ? next : withSpring(next, {
      damping: 26, stiffness: 240, overshootClamping: true,
    }));
    report(next);
  }

  const pan = Gesture.Pan()
    .activeOffsetY([-6, 6])
    .failOffsetX([-20, 20])
    .onStart(() => {
      cancelAnimation(progress);
      origin.set(progress.get());
    })
    .onUpdate((event) => {
      progress.set(Math.max(0, Math.min(1, origin.get() - event.translationY / travel)));
    })
    .onEnd((event) => {
      const projected = progress.get() - event.velocityY * 0.15 / travel;
      const next = projected >= 0.5 ? 1 : 0;
      committed.set(next);
      progress.set(reduceMotion ? next : withSpring(next, {
        damping: 26, stiffness: 240, overshootClamping: true,
        velocity: -event.velocityY / travel,
      }));
      scheduleOnRN(report, next);
    })
    .onFinalize((_event, success) => {
      if (!success) {
        progress.set(reduceMotion ? committed.get() : withSpring(committed.get(), {
          damping: 26, stiffness: 240, overshootClamping: true,
        }));
      }
    });

  const sheetStyle = useAnimatedStyle(() => ({
    transform: [{ translateY: (1 - progress.get()) * travel }],
  }));

  return (
    <Animated.View style={[styles.sheet, { height: sheetHeight }, sheetStyle]}>
      <GestureDetector gesture={pan} touchAction="none">
        <Animated.View
          accessible
          accessibilityRole="adjustable"
          accessibilityLabel="Details panel"
          accessibilityValue={{ min: 0, max: 1, now: expanded ? 1 : 0,
            text: expanded ? 'Expanded' : 'Collapsed' }}
          accessibilityActions={[{ name: 'increment' }, { name: 'decrement' }]}
          onAccessibilityAction={({ nativeEvent }) => {
            if (nativeEvent.actionName === 'increment') choose(1);
            if (nativeEvent.actionName === 'decrement') choose(0);
          }}
          style={styles.handle}
        >
          <View style={styles.grip} />
        </Animated.View>
      </GestureDetector>
      <Pressable accessibilityRole="button" accessibilityState={{ expanded }}
        onPress={() => choose(expanded ? 0 : 1)} style={styles.button}>
        <Text>{expanded ? 'Collapse details' : 'Expand details'}</Text>
      </Pressable>
      <View accessibilityElementsHidden={!expanded}
        importantForAccessibility={expanded ? 'auto' : 'no-hide-descendants'}
        style={styles.content}>
        <Text style={styles.heading}>Your selected place</Text>
        <Text>Keep a short summary here. Open a full screen for longer content.</Text>
      </View>
    </Animated.View>
  );
}

const styles = StyleSheet.create({
  sheet: { position: 'absolute', bottom: 0, left: 0, right: 0,
    backgroundColor: '#F6F2FD', borderTopLeftRadius: 24, borderTopRightRadius: 24,
    borderColor: '#C9BEDD', borderWidth: 1, overflow: 'hidden' },
  handle: { height: 48, alignItems: 'center', justifyContent: 'center' },
  grip: { width: 40, height: 5, borderRadius: 3, backgroundColor: '#665478' },
  button: { minHeight: 48, alignItems: 'center', justifyContent: 'center', paddingHorizontal: 16 },
  content: { padding: 24, gap: 12 },
  heading: { fontSize: 24, color: '#292136' },
});

export function BottomSheetExample() {
  const [height, setHeight] = useState(0);
  return (
    <GestureHandlerRootView style={{ flex: 1 }}>
      <View onLayout={({ nativeEvent }) => setHeight(nativeEvent.layout.height)}
        style={{ flex: 1, overflow: 'hidden', backgroundColor: '#E4ECE8' }}>
        <Text style={{ padding: 24 }}>Your map or main screen stays available.</Text>
        {height >= 320 ? <SnapSheet key={height} height={height} /> : (
          <Text style={{ padding: 24 }}>Open details in a full screen at this height.</Text>
        )}
      </View>
    </GestureHandlerRootView>
  );
}

Derive snap points from the container

The sheet occupies seventy-five percent of the measured usable height. Its collapsed state shows at most 120 points, leaving the handle and Expand button available in the usual phone layout. Progress zero translates the remaining sheet below the container; progress one brings it fully into view. The host clips offscreen content.

When the available height changes, the example remounts the sheet using height as its key. That intentionally resets it to collapsed and discards an in-flight gesture. A production controlled sheet can preserve its semantic anchor across resizing, but should recalculate geometry and cancel gestures before restoring that anchor. Below 320 points of usable height, this example offers a full-screen alternative rather than shrinking essential controls.

Interrupt and settle the same progress

On activation, cancel the current spring and capture its actual progress. Upward translation increases progress; downward translation decreases it. Project release velocity for 150 milliseconds and pick the nearest of two anchors. The spring receives velocity in normalized units per second, matching the shared value's coordinate system.

The committed anchor is distinct from the in-flight visual position. A canceled gesture returns to that anchor; the UI never waits for a JavaScript render to follow the finger. scheduleOnRN reports the chosen destination once, so React can update labels and content accessibility. That report means a snap was selected, not that a spring has finished.

Keep gestures on the handle

Only the forty-eight-point handle owns the vertical pan. Buttons and content below it can receive ordinary touches without a sheet-level pan competing with them. For long content, adding a ScrollView is only part of the work: decide when a downward gesture scrolls content and when it moves the sheet, coordinate recognizers, and test interrupted flings. This sample deliberately keeps its body short.

It also does not solve keyboard avoidance or dismissal. For an input-heavy sheet, measure keyboard changes and keep the focused field visible. For a modal, add background accessibility isolation, initial focus, focus return, platform back behavior, dismissal controls, and an overlay that intercepts background touches. An accessibility modal flag alone is not a complete modal implementation. Prefer a maintained sheet or dialog implementation when these requirements dominate the interaction.

Accessibility and verification

The handle exposes adjustable increment/decrement actions and a collapsed or expanded value. The visible button offers the same snap without dragging. Collapsed details are hidden from assistive technology so users cannot land on offscreen content. This nonmodal pattern keeps the background accessible.

Reduced Motion applies snap destinations immediately while retaining direct drag tracking. Verify canceled gestures, rapid reversals, container resizing, large text, small landscape heights, screen-reader actions, and web keyboard activation. A compile check does not establish gesture feel or device frame rate.

What the paid component adds

The published Snap Sheet Backdrop is a complete three-anchor place-detail scene. Peek, middle, and full anchors reveal eighteen, fifty, and ninety percent of the container. Its map recenters at the middle anchor, scales and darkens at full height, and remains selectable through accessible markers. The glass surface, gallery, details, and marker handoff share the sheet's motion.

It adds rubber-band resistance, velocity-based settling, onSnapChange, location callbacks, and a ref with snapTo, next, and selectLocation. Supply local photos and your own place content. Choose it for coordinated map and sheet choreography rather than assuming it is a generic modal manager. Its Skia, blur, image, and gradient dependencies go beyond this view-only tutorial; check the current installation instructions and platform badges.

For outside-touch dismissal, Android back, and the difference between local layers and Modal, see the overlay and popover guide. A dimmed backdrop alone does not make a sheet modal.

FAQ

Is a React Native sliding up panel different from a bottom sheet?

The names can describe the same interaction. Choose based on behavior: a persistent panel keeps context available, while a modal sheet must isolate the background and manage focus and dismissal. This sample is persistent.

Can this slide up panel contain a scrollable form?

The sample has a short body and handle-only dragging. A scrolling form needs gesture coordination, keyboard avoidance, and focus visibility. Those are additional integration tasks; neither a translation animation nor snap points supplies them automatically.

Can I build a bottom sheet with Reanimated 4?

Yes. Measure its container, derive anchor translations, and let a gesture and spring update the same shared value. Keep semantic snap state in React separately.

Does this bottom sheet support a scrolling list?

The example has a short static body and handle-only dragging. A scrolling list needs scroll and pan coordination, especially when handing a downward drag from content at offset zero to the sheet.

Is this a modal bottom sheet?

No. It is an inline persistent panel. Modal focus, background isolation, keyboard behavior, and dismissal are separate requirements.