Veltrix

gestures

Reveal Curtain

A draggable privacy surface that uncovers sensitive content and safely settles back.

AppleiOSAndroidAndroidExpoExpo Go
reanimatedworkletsgesture handler
GitHubOpen in GitHub

Installation

Install the dependencies, then copy the files for your preferred renderer.

pnpm add react-native-reanimated react-native-worklets react-native-gesture-handler

Anatomy

1 file

anatomy.tsx
<RevealCurtain>  <RevealCurtain.Cover /></RevealCurtain>

Install tailwind variants

pnpm add tailwind-variants

Usage

RevealCurtainExample.tsx
import { 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>  );}

Component files

5 files

reveal-curtain/config.ts
export 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.ts
import 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.tsx
import {  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.ts
import { 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.ts
import {  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,  };}

API reference

Generated directly from the exported component props.

RevealCurtain

RevealCurtain.Cover