Dialog

Animated modal component with a blurred backdrop

Last updated on

Edit on GitHub

Manual

Install the following dependencies:

npm install react-native-reanimated expo-blur react-native-worklets

Copy and paste the following code into your project. component/organisms/dialog

import React, {  createContext,  useContext,  useState,  useCallback,  useEffect,} from "react";import {  Modal,  Pressable,  StyleSheet,  View,  TouchableWithoutFeedback,  PressableProps,} from "react-native";import Animated, {  useAnimatedProps,  useAnimatedStyle,  useSharedValue,  withTiming,  interpolate,  Easing,  Extrapolation,  LinearTransition,  withSpring,} from "react-native-reanimated";import { BlurView } from "expo-blur";import type {  DialogCloseProps,  DialogComponent,  DialogProps,  DialogTriggerProps,  DialogBackdropProps,  ExtendedDialogContextType,  ExtendedDialogContentProps,} from "./types";import { scheduleOnRN } from "react-native-worklets";const AnimatedBlurView = Animated.createAnimatedComponent(BlurView);const DialogContext = createContext<ExtendedDialogContextType | undefined>(  undefined,);const useDialogContext = (): ExtendedDialogContextType => {  const context = useContext(DialogContext);  if (!context) {    throw new Error("Dialog components must be used within Dialog");  }  return context;};export const Dialog: DialogComponent = ({ children }: DialogProps) => {  const [isOpen, setIsOpen] = useState<boolean>(false);  const [isAnimating, setIsAnimating] = useState<boolean>(false);  const animationProgress = useSharedValue<number>(0);  const closeDialog = useCallback(() => {    setIsAnimating(true);  }, []);  const contextValue = React.useMemo(    () => ({ isOpen, setIsOpen, closeDialog, animationProgress }),    [isOpen, closeDialog, animationProgress],  );  return (    <DialogContext.Provider value={contextValue}>      {React.Children.map(children, (child) => {        if (React.isValidElement(child) && child.type === DialogContent) {          return React.cloneElement(child, {            ...child.props,            isAnimating,            setIsAnimating,          } as any);        }        return child;      })}    </DialogContext.Provider>  );};const DialogTrigger: React.FC<DialogTriggerProps> = ({ children, asChild }) => {  const { setIsOpen } = useDialogContext();  const handlePress = (): void => {    setIsOpen(true);  };  if (asChild && React.isValidElement(children)) {    return React.cloneElement(children, {      onPress: handlePress,    } as Partial<PressableProps>);  }  return <Pressable onPress={handlePress}>{children}</Pressable>;};const DialogBackdrop: React.FC<DialogBackdropProps> = ({  children,  blurAmount = 20,  backgroundColor = "rgba(0, 0, 0, 0.5)",  blurType = "dark",}) => {  const { animationProgress } = useDialogContext();  const backdropAnimatedStyle = useAnimatedStyle(() => {    const opacity = interpolate(      animationProgress.value,      [0, 1],      [0, 1],      Extrapolation.CLAMP,    );    return {      opacity,    };  });  const backdropBlurAnimatedProps = useAnimatedProps(() => {    return {      intensity: withSpring(        interpolate(          animationProgress.value,          [0, 1],          [0, blurAmount],          Extrapolation.CLAMP,        ),      ),    };  });  return (    <Animated.View style={[styles.backdrop, backdropAnimatedStyle]}>      <AnimatedBlurView        style={StyleSheet.absoluteFill}        animatedProps={backdropBlurAnimatedProps}        layout={LinearTransition.duration(300).easing(          Easing.inOut(Easing.ease),        )}        tint={blurType}      />      <View style={[StyleSheet.absoluteFill, { backgroundColor }]} />      {children}    </Animated.View>  );};const DialogContent: React.FC<ExtendedDialogContentProps> = ({  children,  onClose,  isAnimating: externalIsAnimating,  setIsAnimating: externalSetIsAnimating,}) => {  const { isOpen, setIsOpen, animationProgress } = useDialogContext();  useEffect(() => {    if (isOpen) {      animationProgress.value = withTiming(1, {        duration: 550,      });    }  }, [isOpen]);  useEffect(() => {    if (externalIsAnimating) {      animationProgress.value = withTiming(        0,        {          duration: 650,          easing: Easing.bezier(0.4, 0, 1, 1),        },        (finished) => {          if (finished) {            scheduleOnRN(setIsOpen, false);            scheduleOnRN(externalSetIsAnimating!, false);            if (onClose) {              scheduleOnRN(onClose);            }          }        },      );    }  }, [externalIsAnimating]);  const handleBackdropPress = useCallback(() => {    externalSetIsAnimating?.(true);  }, [externalSetIsAnimating]);  const handleRequestClose = useCallback(() => {    externalSetIsAnimating?.(true);  }, [externalSetIsAnimating]);  const contentStyle = useAnimatedStyle(() => {    const opacity = externalIsAnimating      ? interpolate(          animationProgress.value,          [0, 0.7, 0.4, 1],          [0, 0.7, 0.4, 1],          Extrapolation.CLAMP,        )      : interpolate(          animationProgress.value,          [0, 0.8, 0.9, 1],          [0, 0.8, 0.9, 1],          Extrapolation.CLAMP,        );    const rotateX = externalIsAnimating      ? interpolate(          animationProgress.value,          [0, 1],          [-55, 0],          Extrapolation.CLAMP,        )      : interpolate(          animationProgress.value,          [0, 1],          [-25, 0],          Extrapolation.CLAMP,        );    const translateY = interpolate(      animationProgress.value,      [0, 1],      [20, 0],      Extrapolation.EXTEND,    );    const skewX = interpolate(      animationProgress.value,      [0, 1],      [-1.5, 0],      Extrapolation.CLAMP,    );    const scale = externalIsAnimating      ? interpolate(          animationProgress.value,          [0, 1],          [0.6, 1],          Extrapolation.CLAMP,        )      : interpolate(          animationProgress.value,          [0, 1],          [0.85, 1],          Extrapolation.CLAMP,        );    return {      opacity,      transform: [        { perspective: 1000 },        { rotateX: `${rotateX}deg` },        { translateY },        { scale },        { skewX: `${skewX}deg` },      ],    };  });  const contentBlurAnimatedProps = useAnimatedProps(() => {    return {      intensity: externalIsAnimating        ? interpolate(            animationProgress.value,            [0, 0.5, 1],            [45, 30, 0],            Extrapolation.CLAMP,          )        : interpolate(            animationProgress.value,            [0, 0.7, 0.4, 1],            [13, 10, 5, 0],            Extrapolation.CLAMP,          ),    };  });  if (!isOpen) return null;  return (    <Modal      visible={isOpen}      transparent      animationType="none"      statusBarTranslucent      onRequestClose={handleRequestClose}    >      <TouchableWithoutFeedback onPress={handleBackdropPress}>        <View style={styles.modalContainer}>          <Animated.View style={[styles.contentWrapper, contentStyle]}>            <TouchableWithoutFeedback>              <View>                {children}                <AnimatedBlurView                  style={[                    StyleSheet.absoluteFill,                    {                      overflow: "hidden",                    },                  ]}                  animatedProps={contentBlurAnimatedProps}                  tint="prominent"                  pointerEvents="none"                />              </View>            </TouchableWithoutFeedback>          </Animated.View>        </View>      </TouchableWithoutFeedback>    </Modal>  );};const DialogClose: React.FC<DialogCloseProps> = ({ children, asChild }) => {  const { closeDialog } = useDialogContext();  const handlePress = (): void => {    closeDialog();  };  if (asChild && React.isValidElement(children)) {    return React.cloneElement(children, {      onPress: handlePress,    } as Partial<PressableProps>);  }  return <Pressable onPress={handlePress}>{children}</Pressable>;};Dialog.Trigger = DialogTrigger;Dialog.Content = DialogContent;Dialog.Close = DialogClose;Dialog.Backdrop = DialogBackdrop;const styles = StyleSheet.create({  backdrop: {    ...StyleSheet.absoluteFillObject,  },  modalContainer: {    flex: 1,    justifyContent: "center",    alignItems: "center",    paddingHorizontal: 20,  },  contentWrapper: {    width: "100%",    maxWidth: 400,  },  content: {},});

Usage

import {  View,  StyleSheet,  Text,  Pressable,  useWindowDimensions,} from "react-native";import { GestureHandlerRootView } from "react-native-gesture-handler";import { StatusBar } from "expo-status-bar";import { Dialog } from "@/components";import { useFonts } from "expo-font";import { Feather } from "@expo/vector-icons";export default function App<T>() {  const { width } = useWindowDimensions();  const [fontLoaded] = useFonts({    SfProRounded: require("@/assets/fonts/sf-pro-rounded.ttf"),    HelveticaNowDisplay: require("@/assets/fonts/HelveticaNowDisplayMedium.ttf"),  });  return (    <GestureHandlerRootView style={styles.container}>      <StatusBar style="light" />      <Dialog>        <Dialog.Trigger>          <View style={styles.trigger}>            <Feather name="trash-2" size={22} color="#fff" />          </View>        </Dialog.Trigger>        <Dialog.Backdrop blurAmount={25} backgroundColor="rgba(0,0,0,0.7)" />        <Dialog.Content>          <View style={[styles.content, { width: width - 48 }]}>            <View style={styles.iconCircle}>              <Feather name="trash-2" size={28} color="#ff6b6b" />            </View>            <Text              style={[                styles.title,                fontLoaded && { fontFamily: "HelveticaNowDisplay" },              ]}            >              Delete item?            </Text>            <Text              style={[                styles.subtitle,                fontLoaded && { fontFamily: "SfProRounded" },              ]}            >              This action cannot be undone.            </Text>            <View style={styles.actions}>              <Dialog.Close asChild>                <Pressable style={[styles.btn, styles.cancelBtn]}>                  <Text                    style={[                      styles.cancelText,                      fontLoaded && { fontFamily: "SfProRounded" },                    ]}                  >                    Cancel                  </Text>                </Pressable>              </Dialog.Close>              <Dialog.Close asChild>                <Pressable style={[styles.btn, styles.deleteBtn]}>                  <Feather name="trash-2" size={18} color="#fff" />                  <Text                    style={[                      styles.deleteText,                      fontLoaded && { fontFamily: "SfProRounded" },                    ]}                  >                    Delete                  </Text>                </Pressable>              </Dialog.Close>            </View>          </View>        </Dialog.Content>      </Dialog>    </GestureHandlerRootView>  );}const styles = StyleSheet.create({  container: {    flex: 1,    backgroundColor: "#000",    justifyContent: "center",    alignItems: "center",  },  trigger: {    width: 60,    height: 60,    borderRadius: 30,    backgroundColor: "#1c1c1e",    justifyContent: "center",    alignItems: "center",  },  content: {    backgroundColor: "#1c1c1e",    borderRadius: 24,    paddingVertical: 32,    paddingHorizontal: 24,    alignItems: "center",    alignSelf: "center",  },  iconCircle: {    width: 64,    height: 64,    borderRadius: 32,    backgroundColor: "rgba(255,107,107,0.15)",    justifyContent: "center",    alignItems: "center",    marginBottom: 20,  },  title: {    fontSize: 22,    fontWeight: "600",    color: "#fff",    marginBottom: 8,  },  subtitle: {    fontSize: 15,    color: "#8e8e93",    marginBottom: 28,  },  actions: {    flexDirection: "row",    gap: 12,    width: "100%",  },  btn: {    flex: 1,    paddingVertical: 16,    borderRadius: 14,    justifyContent: "center",    alignItems: "center",  },  cancelBtn: {    backgroundColor: "#2c2c2e",  },  cancelText: {    fontSize: 16,    fontWeight: "600",    color: "#fff",  },  deleteBtn: {    flexDirection: "row",    gap: 8,    backgroundColor: "#fd5252",  },  deleteText: {    fontSize: 16,    fontWeight: "600",    color: "#fff",  },});

Props

DialogComponent

DialogContextType

React Native Reanimated
React Native Worklets
Expo Blur