gestures
Reveal Curtain
A draggable privacy surface that uncovers sensitive content and safely settles back.
iOSAndroidExpo Go
reanimatedworkletsgesture handler
Installation
Install the dependencies, then copy the files for your preferred renderer.
pnpm add react-native-reanimated react-native-worklets react-native-gesture-handleranatomy.tsx<RevealCurtain> <RevealCurtain.Cover /></RevealCurtain>Install tailwind variants
pnpm add tailwind-variantsUsage
RevealCurtainExample.tsximport { Text, View } from "react-native";import { RevealCurtain } from "@animations/ui/components/gestures/reveal-curtain/uniwind";export function RevealCurtainExample() { return ( <View className="h-40"> <RevealCurtain accessibilityLabel="Account balance" onRevealedChange={(revealed) => console.log({ revealed })} > <Text className="text-3xl font-black">$4,280</Text> <RevealCurtain.Cover> <Text className="font-extrabold text-white">Swipe to reveal</Text> </RevealCurtain.Cover> </RevealCurtain> </View> );}reveal-curtain/config.tsexport const REVEAL_CURTAIN_CONFIG = { boundaryResistance: 0.12, edgeWidth: 44, overscrollExtension: 48, revealThreshold: 0.55, velocityThreshold: 500, spring: { damping: 18, mass: 0.72, stiffness: 210, },} as const;reveal-curtain/types.tsimport type { ReactNode } from "react";export type RevealCurtainBaseProps = { accessibilityLabel?: string; children: ReactNode; defaultRevealed?: boolean; disabled?: boolean; onRevealedChange?: (revealed: boolean) => void; revealThreshold?: number; revealed?: boolean;};export type RevealCurtainCoverProps = Pick< RevealCurtainBaseProps, "children">;reveal-curtain/uniwind/index.tsximport { Children, isValidElement,} from "react";import { Feather } from "@expo/vector-icons";import { Text, View } from "react-native";import { GestureDetector } from "react-native-gesture-handler";import Animated from "react-native-reanimated";import type { SlotsToClasses } from "#shared/types/slots";import type { RevealCurtainBaseProps, RevealCurtainCoverProps,} from "../types";import { useRevealCurtain } from "../use-curtain";import { revealCurtainVariants, type RevealCurtainSlot,} from "./variants";export type RevealCurtainProps = RevealCurtainBaseProps & { className?: string; classNames?: SlotsToClasses<RevealCurtainSlot>;};function RevealCurtainCover({ children,}: RevealCurtainCoverProps) { return children;}function RevealCurtainRoot({ accessibilityLabel = "Sensitive content", children, className, classNames, defaultRevealed, disabled, onRevealedChange, revealThreshold, revealed,}: RevealCurtainProps) { const childNodes = Children.toArray(children); const cover = childNodes.find( (child) => isValidElement(child) && child.type === RevealCurtainCover, ); const contentNode = childNodes.filter( (child) => !isValidElement(child) || child.type !== RevealCurtainCover, ); const { accessibilityState, contentAnimatedStyle, curtainAnimatedStyle, gesture, isRevealed, onLayout, toggle, } = useRevealCurtain({ defaultRevealed, disabled, onRevealedChange, revealThreshold, revealed, }); const { container, content, coverContent, curtain, handle, handleText, } = revealCurtainVariants({ disabled }); return ( <View className={container({ className: [className, classNames?.container], })} onLayout={onLayout} > <Animated.View accessibilityElementsHidden={!isRevealed} className={content({ className: classNames?.content })} importantForAccessibility={ isRevealed ? "auto" : "no-hide-descendants" } style={contentAnimatedStyle} > {contentNode} </Animated.View> <GestureDetector gesture={gesture}> <Animated.View accessibilityHint="Double tap or swipe horizontally to change visibility." accessibilityLabel={accessibilityLabel} accessibilityRole="button" accessibilityState={accessibilityState} accessible className={curtain({ className: classNames?.curtain, })} onAccessibilityTap={toggle} style={curtainAnimatedStyle} > <View className={handle({ className: classNames?.handle, })} > <Text className={handleText({ className: classNames?.handleText, })} > <Feather color="#ffffff" name={isRevealed ? "chevron-left" : "chevron-right"} size={22} /> </Text> </View> <View className={coverContent({ className: classNames?.coverContent, })} > {isValidElement<RevealCurtainCoverProps>(cover) && cover.props.children} </View> </Animated.View> </GestureDetector> </View> );}export const RevealCurtain = Object.assign(RevealCurtainRoot, { Cover: RevealCurtainCover,});reveal-curtain/uniwind/variants.tsimport { tv } from "tailwind-variants";export const revealCurtainSlots = { container: "h-full w-full overflow-hidden rounded-3xl bg-[#191824]", content: "flex-1 justify-center px-6 py-5", coverContent: "flex-1 items-center justify-center gap-1", curtain: "absolute -bottom-0.5 -left-0.5 -right-12 -top-0.5 flex-row items-center bg-[#f05a47] pr-5", handle: "w-11 self-stretch items-center justify-center border-r border-white/20", handleText: "text-[28px] font-medium leading-7.5 text-white",} as const;export type RevealCurtainSlot = keyof typeof revealCurtainSlots;export const revealCurtainVariants = tv({ slots: revealCurtainSlots, variants: { disabled: { true: { container: "opacity-[0.45]", }, }, },});reveal-curtain/use-curtain.tsimport { useCallback, useMemo, useState,} from "react";import type { LayoutChangeEvent } from "react-native";import { Gesture } from "react-native-gesture-handler";import { Extrapolation, interpolate, runOnJS, useAnimatedStyle, useDerivedValue, useReducedMotion, useSharedValue, withSpring, withTiming,} from "react-native-reanimated";import { REVEAL_CURTAIN_CONFIG } from "./config";import type { RevealCurtainBaseProps } from "./types";function applyBoundaryResistance( progress: number, minimumProgress: number,) { "worklet"; if (progress < 0) { return Math.max( minimumProgress, progress * REVEAL_CURTAIN_CONFIG.boundaryResistance, ); } if (progress > 1) { return 1 + (progress - 1) * REVEAL_CURTAIN_CONFIG.boundaryResistance; } return progress;}export function useRevealCurtain({ defaultRevealed = false, disabled = false, onRevealedChange, revealThreshold = REVEAL_CURTAIN_CONFIG.revealThreshold, revealed,}: Omit<RevealCurtainBaseProps, "accessibilityLabel" | "children">) { if (revealThreshold <= 0 || revealThreshold >= 1) { throw new Error( "RevealCurtain requires revealThreshold to be between zero and one.", ); } const isControlled = revealed !== undefined; const [internalRevealed, setInternalRevealed] = useState(defaultRevealed); const isRevealed = revealed ?? internalRevealed; const initialProgress = isRevealed ? 1 : 0; const progress = useSharedValue(initialProgress); const dragStartProgress = useSharedValue(initialProgress); const curtainWidth = useSharedValue(0); const isDragging = useSharedValue(false); const pressed = useSharedValue(0); const reducedMotion = useReducedMotion(); const commitRevealed = useCallback( (nextRevealed: boolean) => { isDragging.set(false); if (disabled || nextRevealed === isRevealed) { return; } if (!isControlled) { setInternalRevealed(nextRevealed); } onRevealedChange?.(nextRevealed); }, [ disabled, isControlled, isDragging, isRevealed, onRevealedChange, ], ); const toggle = useCallback(() => { if (disabled) { return; } commitRevealed(!isRevealed); }, [commitRevealed, disabled, isRevealed]); const onLayout = useCallback( (event: LayoutChangeEvent) => { curtainWidth.set(event.nativeEvent.layout.width); }, [curtainWidth], ); const animatedProgress = useDerivedValue(() => { if (isDragging.get()) { return progress.get(); } const nextProgress = isRevealed ? 1 : 0; return reducedMotion ? nextProgress : withSpring(nextProgress, REVEAL_CURTAIN_CONFIG.spring); }); const gesture = useMemo(() => { const tap = Gesture.Tap() .enabled(!disabled) .onBegin(() => { pressed.set(withTiming(1, { duration: 80 })); }) .onEnd((_event, success) => { if (!success) { return; } const nextRevealed = !isRevealed; runOnJS(commitRevealed)(nextRevealed); }) .onFinalize(() => { pressed.set(withTiming(0, { duration: 140 })); }); const pan = Gesture.Pan() .enabled(!disabled) .minDistance(2) .onStart(() => { const currentProgress = animatedProgress.get(); progress.set(currentProgress); dragStartProgress.set(currentProgress); isDragging.set(true); pressed.set(withTiming(1, { duration: 80 })); }) .onUpdate((event) => { const availableWidth = curtainWidth.get() - REVEAL_CURTAIN_CONFIG.edgeWidth; if (availableWidth <= 0) { return; } const rawProgress = dragStartProgress.get() + event.translationX / availableWidth; progress.set( applyBoundaryResistance( rawProgress, -REVEAL_CURTAIN_CONFIG.overscrollExtension / availableWidth, ), ); }) .onEnd((event) => { const availableWidth = curtainWidth.get() - REVEAL_CURTAIN_CONFIG.edgeWidth; if (availableWidth <= 0) { return; } const velocityProgress = Math.abs(event.velocityX) > REVEAL_CURTAIN_CONFIG.velocityThreshold ? event.velocityX / availableWidth / 8 : 0; const projectedProgress = progress.get() + velocityProgress; const nextRevealed = projectedProgress >= revealThreshold; progress.set(nextRevealed ? 1 : 0); runOnJS(commitRevealed)(nextRevealed); }) .onFinalize((_event, success) => { if (!success) { isDragging.set(false); } pressed.set(withTiming(0, { duration: 140 })); }); return Gesture.Race(pan, tap); }, [ animatedProgress, commitRevealed, curtainWidth, disabled, dragStartProgress, isDragging, isRevealed, pressed, progress, revealThreshold, ]); const curtainAnimatedStyle = useAnimatedStyle(() => { const availableWidth = Math.max( 0, curtainWidth.get() - REVEAL_CURTAIN_CONFIG.edgeWidth, ); return { transform: [ { translateX: animatedProgress.get() * availableWidth, }, { scaleY: interpolate(pressed.get(), [0, 1], [1, 0.97]), }, ], }; }); const contentAnimatedStyle = useAnimatedStyle(() => ({ opacity: interpolate( animatedProgress.get(), [0, 0.35, 1], [0.18, 0.55, 1], Extrapolation.CLAMP, ), transform: [ { scale: interpolate( animatedProgress.get(), [0, 1], [0.97, 1], Extrapolation.CLAMP, ), }, ], })); return { accessibilityState: { disabled, expanded: isRevealed, }, contentAnimatedStyle, curtainAnimatedStyle, gesture, isRevealed, onLayout, toggle, };}