Right-edge detail panel with spring entrance, pan-drag edge dismissal, and animated backdrop
components/ui/side-sheet/side-sheet.tsx@lumioui/core@latestreact-native-reanimated@^4.0.0react-native-gesture-handler@^2.20.0react-native-safe-area-context@^4.0.0expo-haptics@*| open | boolean | |
| onOpenChange | (open: boolean) => void | |
| width? | number | Sheet width in px. Default 320 — capped at 85% of the window width. |
| children? | React.ReactNode | Sheet content. |
| className? | string |
Right-edge detail panel with spring entrance, pan-drag edge dismissal, and animated backdrop
import { SideSheet } from "@/components/ui/side-sheet/side-sheet";
export function Example() {
return (
<SideSheet />
);
} import * as React from "react";
import { forwardRef } from "react";
import { Modal, useWindowDimensions, View, type ViewProps } from "react-native";
import Animated, {
useSharedValue,
useAnimatedStyle,
withSpring,
withTiming,
runOnJS,
} from "react-native-reanimated";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import { cn, useHaptic, useReducedMotion, spring, timing, useTheme, usePressAnimation, AnimatedPressable } from "@lumioui/core";
import { useSafeAreaInsets } from "react-native-safe-area-context";
/**
* SideSheet — PRO component. A panel that springs in from the right edge
* (like a detail panel). Drag it right to dismiss with a pan gesture, or
* tap the dimmed backdrop. Requires a dev build
* (react-native-gesture-handler).
*/
/** Drag past the open edge rubber-bands — the sheet resists at 1/5 rate so
* overshoot feels elastic instead of hitting a wall. */
const RUBBER_BAND = 0.2;
const RUBBER_BAND_MAX = -36;
export interface SideSheetProps extends Omit<ViewProps, "className"> {
open: boolean;
onOpenChange: (open: boolean) => void;
/** Sheet width in px. Default 320 — capped at 85% of the window width. */
width?: number;
/** Sheet content. */
children?: React.ReactNode;
className?: string;
}
export const SideSheet = forwardRef<View, SideSheetProps>(function SideSheet(
{
open,
onOpenChange,
width: requestedWidth = 320,
children,
className,
...props
}: SideSheetProps,
ref
) {
const haptics = useHaptic();
const { colors } = useTheme();
const insets = useSafeAreaInsets();
const reduce = useReducedMotion();
const { width: windowWidth } = useWindowDimensions();
// Responsive cap — a fixed 320px panel covers a small phone edge-to-edge.
const width = Math.min(requestedWidth, windowWidth * 0.85);
const translateX = useSharedValue(width);
const backdrop = useSharedValue(0);
const backdropPress = usePressAnimation({ pressedScale: 1, pressedOpacity: 0.7 });
// Keeps the Modal mounted while the exit runs — `visible={open}` unmounts
// instantly and the slide-out never gets a frame on screen.
const [rendered, setRendered] = React.useState(open);
const wasOpen = React.useRef(false);
const close = React.useCallback(() => onOpenChange(false), [onOpenChange]);
const closeWithHaptic = React.useCallback(() => {
haptics.light();
close();
}, [haptics, close]);
React.useEffect(() => {
if (open) {
if (!wasOpen.current) {
setRendered(true);
haptics.light();
}
translateX.value = reduce ? 0 : withSpring(0, spring.surface);
backdrop.value = reduce ? 1 : withTiming(1, timing.normal);
} else if (wasOpen.current || rendered) {
// Exit runs faster than the entrance; the Modal unmounts on the
// backdrop's completion callback, after the sheet has slid out.
if (reduce) {
translateX.value = width;
backdrop.value = 0;
setRendered(false);
} else {
translateX.value = withTiming(width, timing.fast);
backdrop.value = withTiming(0, timing.fast, (finished) => {
if (finished) runOnJS(setRendered)(false);
});
}
}
wasOpen.current = open;
}, [open, width, rendered, translateX, backdrop, reduce, haptics]);
const pan = React.useMemo(
() =>
Gesture.Pan()
.activeOffsetX(10)
.onUpdate((e: { translationX: number }) => {
"worklet";
// 1:1 toward dismissal; past the open edge the sheet rubber-bands.
translateX.value =
e.translationX >= 0
? e.translationX
: Math.max(RUBBER_BAND_MAX, e.translationX * RUBBER_BAND);
})
.onEnd((e: { translationX: number; velocityX: number }) => {
"worklet";
// Dismiss past a third of the width or on a fast (>800) fling.
const shouldClose = e.translationX > width * 0.35 || e.velocityX > 800;
translateX.value = reduce ? (shouldClose ? width : 0) : withSpring(shouldClose ? width : 0, spring.surface);
backdrop.value = reduce ? (shouldClose ? 0 : 1) : withTiming(shouldClose ? 0 : 1, timing.fast);
if (shouldClose) runOnJS(closeWithHaptic)();
}),
[width, translateX, backdrop, closeWithHaptic, reduce]
);
const sheetStyle = useAnimatedStyle(() => ({
transform: [{ translateX: translateX.value }],
}));
const backdropStyle = useAnimatedStyle(() => ({
// The dim tracks the sheet position live during the drag — the room
// brightens as the panel slides out, not just after release.
opacity: backdrop.value * 0.5 * Math.max(0, Math.min(1, 1 - translateX.value / width)),
}));
return (
<Modal
visible={rendered}
transparent
animationType="none"
statusBarTranslucent
onRequestClose={() => {
haptics.light();
close();
}}
>
<View
// Phone-width modal surface — panel pins to the window's right edge,
// matching the sheet components' 480 cap. No-op on phones.
className="w-full max-w-[480px] self-center"
style={{ height: "100%" }}
>
{/* Press feedback multiplies the backdrop fade — nested layers so
the two animated opacities compose instead of overriding. */}
<AnimatedPressable
style={[
{ position: "absolute", top: 0, left: 0, right: 0, bottom: 0 },
backdropPress.animatedStyle,
]}
onPress={() => {
haptics.light();
close();
}}
onPressIn={backdropPress.onPressIn}
onPressOut={backdropPress.onPressOut}
accessibilityRole="button"
accessibilityLabel="Close panel"
>
<Animated.View
style={[{ width: "100%", height: "100%", backgroundColor: colors.overlay }, backdropStyle]}
/>
</AnimatedPressable>
<GestureDetector gesture={pan}>
<Animated.View
ref={ref}
style={[
{
position: "absolute",
right: 0,
top: 0,
bottom: 0,
width,
paddingTop: insets.top,
paddingBottom: insets.bottom,
// EXCEPTION: the palette has no shadow token — shadows stay
// black under both schemes (a light-scheme `foreground`
// would glow white in dark mode).
shadowColor: "#000",
shadowOpacity: 0.2,
shadowRadius: 16,
shadowOffset: { width: -4, height: 0 },
elevation: 12,
},
sheetStyle,
]}
className={cn("bg-card", className)}
// RN's accessibilityRole enum has no "dialog" — `role` carries
// the ARIA dialog semantics instead (matches Dialog/Popover).
role="dialog"
aria-modal
accessibilityState={{ expanded: open }}
accessibilityLabel="Side panel"
{...props}
>
{children}
</Animated.View>
</GestureDetector>
</View>
</Modal>
);
});
SideSheet.displayName = "SideSheet";