Gesture-driven bottom sheet with snap points, swipe-to-dismiss, drag handle and position-interpolated backdrop
components/ui/bottom-sheet/bottom-sheet.tsx@lumioui/core@latest@lumioui/ui@latestreact-native-reanimated@^4.0.0react-native-gesture-handler@^2.20.0react-native-safe-area-context@*expo-haptics@*| BottomSheet | ||
| open | boolean | Controlled open state. |
| onOpenChange | (open: boolean) => void | |
| snapPoints? | number[] | Snap point heights in px, ascending. Sheet opens at the first point. Default: [320]. |
| defaultSnap? | number | Index into `snapPoints` (sorted ascending) where the sheet opens. Default 0 — the lowest point. |
| onSnapChange? | (index: number) => void | Called with the snap-point index when a drag settles on a different snap point. |
| dragHandleOnly? | boolean | Restrict drag-to-dismiss to the grabber handle so sheet content keeps its own gestures (scrolling, sliders). Default true; set false to let the whole surface drag. |
| enableDismiss? | boolean | Allow swipe-down past the lowest snap point to dismiss. Default: true. |
| keyboardAware? | boolean | Wrap content in a KeyboardAvoidingView. Default: true (inputs stay visible). |
| children? | React.ReactNode | |
| BottomSheetContent | ||
| className? | string | |
Gesture-driven bottom sheet with snap points, swipe-to-dismiss, drag handle and position-interpolated backdrop
import { BottomSheet } from "@/components/ui/bottom-sheet/bottom-sheet";
export function Example() {
return (
<BottomSheet />
);
} import * as React from "react";
import { forwardRef, useMemo } from "react";
import { KeyboardAvoidingView, Modal, Platform, Pressable, View, useWindowDimensions, type LayoutChangeEvent, type ViewProps, } from "react-native";
import Animated, {
runOnJS,
useAnimatedStyle,
useSharedValue,
withDelay,
withSpring,
withTiming,
} from "react-native-reanimated";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import { cn, spring, timing, useHaptic, useReducedMotion } from "@lumioui/core";
import { Glass } from "@lumioui/ui";
import { useSafeAreaInsets } from "react-native-safe-area-context";
export interface BottomSheetProps {
/** Controlled open state. */
open: boolean;
onOpenChange: (open: boolean) => void;
/** Snap point heights in px, ascending. Sheet opens at the first point. Default: [320]. */
snapPoints?: number[];
/** Index into `snapPoints` (sorted ascending) where the sheet opens. Default 0 — the lowest point. */
defaultSnap?: number;
/** Called with the snap-point index when a drag settles on a different snap point. */
onSnapChange?: (index: number) => void;
/** Restrict drag-to-dismiss to the grabber handle so sheet content keeps
* its own gestures (scrolling, sliders). Default true; set false to let
* the whole surface drag. */
dragHandleOnly?: boolean;
/** Allow swipe-down past the lowest snap point to dismiss. Default: true. */
enableDismiss?: boolean;
/** Wrap content in a KeyboardAvoidingView. Default: true (inputs stay visible). */
keyboardAware?: boolean;
children?: React.ReactNode;
}
interface SheetCtx {
open: boolean;
onOpenChange: (open: boolean) => void;
}
const Ctx = React.createContext<SheetCtx | null>(null);
function useSheet() {
const ctx = React.useContext(Ctx);
if (!ctx) throw new Error("BottomSheet.* must be inside <BottomSheet>");
return ctx;
}
/**
* BottomSheet — PRO. Gesture-driven sheet with snap points,
* swipe-to-dismiss, drag handle and position-interpolated backdrop.
* Requires react-native-gesture-handler (dev build, not Expo Go).
*/
export const BottomSheet = forwardRef<View, BottomSheetProps>(function BottomSheet(
{ open, onOpenChange, snapPoints = [320], defaultSnap = 0, onSnapChange, dragHandleOnly = true, enableDismiss = true, keyboardAware = true, children },
ref
) {
const haptics = useHaptic();
const reduce = useReducedMotion();
const { height: screenH } = useWindowDimensions();
const sorted = useMemo(() => [...snapPoints].sort((a, b) => a - b), [snapPoints]);
const maxSnap = sorted[sorted.length - 1] ?? 320;
const minSnap = sorted[0] ?? 320;
// Keeps the Modal mounted while the exit animation runs — `visible={open}`
// unmounts instantly and the slide-down never gets a frame on screen.
const [rendered, setRendered] = React.useState(open);
// translateY is an OFFSET from the sheet's bottom-anchored park position
// (KeyboardAvoidingView justify-end lays it out at the screen's bottom
// edge, not at y=0): sheetH = fully hidden below the edge, 0 = fully
// expanded. Treating it as an absolute screen Y parked the sheet a full
// screen below the viewport — invisible at every snap point.
const sheetH = useSharedValue(0);
const [measuredH, setMeasuredH] = React.useState(0);
// Snap point currently settled at — lets a content re-measure while open
// re-anchor to the same point instead of collapsing to the first.
const currentSnap = useSharedValue(0);
const translateY = useSharedValue(screenH);
const ctxStartY = useSharedValue(screenH);
const closing = useSharedValue(false);
// Content rise+fade layered inside the sheet so children settle a beat
// after the surface — the sheet lands, then the content arrives.
const contentIn = useSharedValue(0);
// Backdrop press feedback — a brief extra dim dip signals the tap landed
// (the backdrop is full-screen, so scale travel would reveal edges).
const backdropPress = useSharedValue(1);
const close = React.useCallback(() => onOpenChange(false), [onOpenChange]);
// Stable JS callback the pan worklet can runOnJS — reports the snap
// index a drag settled on.
const notifySnap = React.useCallback((index: number) => onSnapChange?.(index), [onSnapChange]);
// Snap the sheet opens at — index into the sorted points, clamped.
const openSnap = Math.min(Math.max(defaultSnap, 0), sorted.length - 1);
// Resting offset for a snap point: park the sheet so `snap` px of it
// stay above the bottom edge. A snap taller than the sheet just means
// "fully expanded" — the surface stays bottom-anchored at 0.
const restFor = (snap: number, h: number) => Math.max(h - snap, 0);
// Distinguishes a fresh open transition from a re-measure while already
// open — the latter must not restart the entrance or collapse the snap.
const wasOpen = React.useRef(false);
React.useEffect(() => {
const h = measuredH;
// Unmeasured (first open frame): park a full screen below. Once onLayout
// reports the real height this effect re-runs and springs to the snap.
const hidden = h > 0 ? h : screenH;
if (open) {
if (!wasOpen.current) {
setRendered(true);
haptics.light();
closing.value = false;
currentSnap.value = sorted[openSnap] ?? minSnap;
if (reduce) {
contentIn.value = 1;
} else {
// 80ms hold so the surface visibly leads its contents.
contentIn.value = 0;
contentIn.value = withDelay(80, withTiming(1, timing.normal));
}
}
const rest = h > 0 ? restFor(currentSnap.value, h) : hidden;
translateY.value = reduce ? rest : withSpring(rest, spring.surface);
} else if (wasOpen.current || rendered) {
if (reduce) {
translateY.value = hidden;
setRendered(false);
} else {
// Exits run faster than entrances: timing.fast slide-down vs the
// spring.surface entrance, so dismissal feels snappy, not floaty.
translateY.value = withTiming(hidden, timing.fast, (finished) => {
if (finished) runOnJS(setRendered)(false);
});
}
}
wasOpen.current = open;
}, [open, screenH, minSnap, measuredH, translateY, closing, contentIn, currentSnap, reduce, rendered, haptics, sorted, openSnap]);
const pan = useMemo(
() =>
Gesture.Pan()
.activeOffsetY([-10, 10])
.onStart(() => {
ctxStartY.value = translateY.value;
})
.onUpdate((e) => {
"worklet";
const next = ctxStartY.value + e.translationY;
const top = Math.max(sheetH.value - maxSnap, 0);
const bottom = sheetH.value > 0 ? sheetH.value : screenH;
// Rubber-band above the tallest snap point, clamp at fully closed.
translateY.value = next < top ? top + (next - top) * 0.3 : Math.min(next, bottom);
})
.onEnd((e) => {
"worklet";
const h = sheetH.value > 0 ? sheetH.value : screenH;
const visible = h - translateY.value;
const projected = visible - e.velocityY * 0.15;
// beui thresholds: a light flick (not only a hard fling) dismisses.
if (enableDismiss && (projected < minSnap * 0.55 || e.velocityY > 800)) {
closing.value = true;
// withSpring + release velocity keeps the fling's momentum —
// the sheet accelerates off-screen instead of decelerating out
// on a flat timing curve.
translateY.value = reduce
? h
: withSpring(h, { ...spring.dismiss, velocity: e.velocityY });
runOnJS(haptics.light)();
runOnJS(close)();
return;
}
// Snap to the nearest declared snap point, carrying drag velocity
// into the settle so the sheet arrives with inertia, not a stop.
let best = sorted[0] ?? minSnap;
let bestIndex = 0;
for (let i = 0; i < sorted.length; i++) {
const p = sorted[i] ?? best;
if (Math.abs(p - projected) < Math.abs(best - projected)) {
best = p;
bestIndex = i;
}
}
const snapChanged = best !== currentSnap.value;
currentSnap.value = best;
const target = Math.max(h - best, 0);
translateY.value = reduce
? target
: withSpring(target, { ...spring.surface, velocity: e.velocityY });
runOnJS(haptics.selection)();
if (snapChanged) runOnJS(notifySnap)(bestIndex);
}),
[ctxStartY, translateY, sheetH, screenH, maxSnap, minSnap, sorted, enableDismiss, closing, currentSnap, haptics, close, reduce, notifySnap]
);
const sheetStyle = useAnimatedStyle(() => ({
transform: [{ translateY: translateY.value }],
}));
const contentStyle = useAnimatedStyle(() => ({
opacity: contentIn.value,
transform: [{ translateY: (1 - contentIn.value) * 10 }],
}));
const backdropStyle = useAnimatedStyle(() => {
const h = sheetH.value;
// Hidden (or unmeasured) sheet → invisible backdrop; the dim tracks the
// fraction of the sheet currently above the bottom edge, normalized over
// the sheet's travel range (min(h, maxSnap)).
if (h <= 0) return { opacity: 0 };
const travel = Math.min(h, maxSnap);
const t = travel <= 0 ? 1 : Math.min(Math.max((h - translateY.value) / travel, 0), 1);
return { opacity: t * backdropPress.value };
});
const onSheetLayout = React.useCallback(
(e: LayoutChangeEvent) => {
const h = e.nativeEvent.layout.height;
if (h <= 0) return;
sheetH.value = h;
setMeasuredH((prev) => (prev === h ? prev : h));
},
[sheetH]
);
const content = (
<Animated.View
ref={ref}
onLayout={onSheetLayout}
style={[{ maxHeight: maxSnap }, sheetStyle]}
className={cn(
// Sheets cap at phone width on wide canvases (docs preview, tablet
// landscape) — a sheet stretched to desktop width reads as a banner.
// Absolute-anchored in normal mode; in keyboardAware mode the sheet
// lives in flow so KeyboardAvoidingView's padding lifts it.
"w-full max-w-[480px] self-center overflow-hidden rounded-t-3xl",
!keyboardAware && "absolute bottom-0"
)}
accessibilityViewIsModal
// RN's accessibilityRole enum has no "dialog" — `role` carries the
// ARIA dialog semantics instead (matches Dialog/Popover).
role="dialog"
aria-modal
accessibilityLabel="Bottom sheet"
>
{/* Sheet surface rides on liquid glass — real iOS 26 material when
installed, frosted elsewhere. */}
<Glass effect="regular" className="w-full rounded-t-3xl">
{dragHandleOnly ? (
// The grabber owns the pan so sheet content keeps its own
// gestures — whole-surface drag would swallow scrolls inside.
<>
<GestureDetector gesture={pan}>
<View collapsable={false} className="items-center py-3" accessible={false}>
<View className="h-1.5 w-10 rounded-full bg-muted" />
</View>
</GestureDetector>
<Animated.View style={contentStyle}>{children}</Animated.View>
</>
) : (
<GestureDetector gesture={pan}>
<View collapsable={false}>
<View className="items-center py-3" accessible={false}>
<View className="h-1.5 w-10 rounded-full bg-muted" />
</View>
<Animated.View style={contentStyle}>{children}</Animated.View>
</View>
</GestureDetector>
)}
</Glass>
</Animated.View>
);
return (
<Ctx.Provider value={{ open, onOpenChange }}>
<Modal
visible={rendered}
transparent
animationType="none"
statusBarTranslucent
onRequestClose={() => {
haptics.light();
onOpenChange(false);
}}
>
<View className="flex-1" style={{ width: "100%", height: "100%" }}>
{/* Single overlay layer — the Pressable must stay transparent or
the overlay alpha stacks and the backdrop renders ~2x darker. */}
<Pressable
className="absolute inset-0"
onPress={() => {
if (!enableDismiss) return;
haptics.light();
onOpenChange(false);
}}
onPressIn={() => {
backdropPress.value = withTiming(0.92, timing.instant);
}}
onPressOut={() => {
backdropPress.value = withTiming(1, timing.fast);
}}
accessibilityLabel="Close sheet"
accessibilityRole="button"
>
<Animated.View
style={[{ flex: 1 }, backdropStyle]}
className="bg-overlay"
/>
</Pressable>
{keyboardAware ? (
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : "height"}
className="flex-1 justify-end"
pointerEvents="box-none"
>
{content}
</KeyboardAvoidingView>
) : (
content
)}
</View>
</Modal>
</Ctx.Provider>
);
});
BottomSheet.displayName = "BottomSheet";
export interface BottomSheetContentProps extends ViewProps {
className?: string;
}
export const BottomSheetContent = forwardRef<View, BottomSheetContentProps>(
function BottomSheetContent({ className, ...props }, ref) {
useSheet(); // assert inside provider
const insets = useSafeAreaInsets();
return (
<View
ref={ref}
style={{ paddingBottom: Math.max(40, insets.bottom + 16) }}
className={cn("px-5", className)}
{...props}
/>
);
}
);
BottomSheetContent.displayName = "BottomSheetContent";