Ripple

A touchable ripple wrapper

Last updated on

Edit on GitHub

Manual

Install the following dependencies:

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

Copy and paste the following code into your project. component/base/ripple.tsx

// @ts-checkimport React, {  useState,  useMemo,  useCallback,  memo,  isValidElement,  useEffect,} from "react";import { View, StyleSheet, type LayoutChangeEvent } from "react-native";import Animated, {  useSharedValue,  useAnimatedStyle,  withTiming,  withSequence,  Easing,  useAnimatedProps,} from "react-native-reanimated";import { Gesture, GestureDetector } from "react-native-gesture-handler";import { BlurView, type BlurViewProps } from "expo-blur";import type {  TouchableRippleProps,  RippleConfig,  RippleData,  IRippleWave,} from "./types";import { scheduleOnRN } from "react-native-worklets";import { getBorderRadiusFromChildren } from "./helper";const AnimatedBlurView =  Animated.createAnimatedComponent<BlurViewProps>(BlurView);const RippleWave: React.FC<IRippleWave> & React.FunctionComponent<IRippleWave> =  memo<IRippleWave>(    ({      ripple,      config,      layout,      borderRadiusStyle,      isHolding,      onComplete,    }: IRippleWave): React.ReactElement &      React.ReactNode &      React.JSX.Element => {      const scale = useSharedValue<number>(0);      const opacity = useSharedValue<number>(config.opacity);      const isPaused = useSharedValue<boolean>(false);      useEffect(() => {        if (isHolding && !isPaused.value) {          isPaused.value = true;          scale.value = withTiming(0.6, {            duration: 200,            easing: Easing.out(Easing.ease),          });          opacity.value = withTiming<number>(config.opacity * 0.8, {            duration: 200,          });        } else if (!isHolding && isPaused.value) {          isPaused.value = false;          scale.value = withTiming<number>(config.scale, {            duration: config.duration * 0.5,            easing: Easing.out(Easing.ease),          });          opacity.value = withSequence(            withTiming<number>(config.opacity, {              duration: config.duration * 0.2,            }),            withTiming<number>(              0,              {                duration: config.duration * 0.5,                easing: Easing.out(Easing.ease),              },              () => {                scheduleOnRN(onComplete, ripple.id);              },            ),          );        } else if (!isHolding && !isPaused.value) {          scale.value = withTiming(config.scale, {            duration: config.duration,            easing: Easing.out(Easing.ease),          });          opacity.value = withSequence(            withTiming(config.opacity, { duration: config.duration * 0.3 }),            withTiming(              0,              {                duration: config.duration * 0.7,                easing: Easing.out(Easing.ease),              },              () => {                scheduleOnRN(onComplete, ripple.id);              },            ),          );        }      }, [isHolding]);      const animatedStylez = useAnimatedStyle(() => {        const size = Math.max(layout.width, layout.height) * 2.5;        return {          position: "absolute",          width: size,          height: size,          top: ripple.y - size / 2,          left: ripple.x - size / 2,          borderRadius: size / 2,          transform: [{ scale: scale.value }],          opacity: opacity.value,          overflow: "hidden",        };      });      const animatedBlurProps = useAnimatedProps(() => {        if (!config.blur.enabled) return { intensity: 0 };        return {          intensity: opacity.value * (config.blur.intensity || 20),        };      });      if (config.blur.enabled) {        return (          <Animated.View pointerEvents="none" style={[animatedStylez]}>            <AnimatedBlurView              animatedProps={animatedBlurProps}              tint={config.blur.tint}              style={StyleSheet.absoluteFill}            />            <Animated.View              style={[                StyleSheet.absoluteFill,                {                  backgroundColor: config.color,                },              ]}            />          </Animated.View>        );      }      return (        <Animated.View          pointerEvents="none"          style={[            animatedStylez,            {              backgroundColor: config.color,            },          ]}        />      );    },  );export const TouchableRipple: React.FC<TouchableRippleProps> &  React.FunctionComponent<TouchableRippleProps> = memo<TouchableRippleProps>(  ({    children,    onPress,    onLongPress,    onPressIn,    onPressOut,    rippleConfig,    centered = false,    disabled = false,    style,    testID,    borderRadius,    hitSlop,  }: TouchableRippleProps):    | (React.ReactNode & React.JSX.Element & React.ReactElement)    | null => {    const [layout, setLayout] = useState({ width: 0, height: 0 });    const [ripples, setRipples] = useState<RippleData[]>([]);    const [isHolding, setIsHolding] = useState<boolean>(false);    const rippleIdCounter = useSharedValue<number>(0);    const FINAL_CONFIG: Required<RippleConfig> = useMemo<      Required<RippleConfig>    >(      () => ({        enabled: true,        color: "rgba(255, 255, 255, 0.2)",        opacity: 0.6,        duration: 600,        scale: 1,        ...rippleConfig,        blur: {          enabled: false,          intensity: 40,          tint: "default" as const,          ...rippleConfig?.blur,        },      }),      [rippleConfig],    );    const borderRadiusStyle = useMemo(() => {      if (borderRadius !== undefined) {        return { borderRadius };      }      return getBorderRadiusFromChildren(children);    }, [borderRadius, children]);    const handleLayout = useCallback(      <T extends LayoutChangeEvent>(event: T) => {        const { width, height } = event.nativeEvent.layout;        setLayout({ width, height });      },      [],    );    const addRipple = useCallback(      <X extends number, Y extends number>(x: X, y: Y) => {        if (!FINAL_CONFIG.enabled || disabled) return;        const rippleX = centered ? layout.width / 2 : x;        const rippleY = centered ? layout.height / 2 : y;        const newRipple: RippleData = {          id: rippleIdCounter.value++,          x: rippleX,          y: rippleY,          timestamp: Date.now(),        };        setRipples((prev) => [...prev, newRipple]);      },      [FINAL_CONFIG.enabled, disabled, centered, layout, rippleIdCounter],    );    const removeRipple = useCallback((id: number) => {      setRipples((prev) => prev.filter((r) => r.id !== id));    }, []);    const tapGesture = Gesture.Tap()      .enabled(!disabled)      .onBegin((e) => {        if (onPressIn) scheduleOnRN(onPressIn);        scheduleOnRN(addRipple, e.x, e.y);        scheduleOnRN(setIsHolding, true);      })      .onEnd(() => {        scheduleOnRN(setIsHolding, false);        if (onPressOut) scheduleOnRN(onPressOut);        if (onPress) scheduleOnRN(onPress);      })      .onFinalize(() => {        scheduleOnRN(setIsHolding, false);        if (onPressOut) scheduleOnRN(onPressOut);      });    const longPressGesture = Gesture.LongPress()      .enabled(!disabled && !!onLongPress)      .minDuration(400)      .onStart((e) => {        scheduleOnRN(addRipple, e.x, e.y);        scheduleOnRN(setIsHolding, true);      })      .onEnd(() => {        if (onLongPress) scheduleOnRN(onLongPress);        scheduleOnRN(setIsHolding, false);      })      .onFinalize(() => {        scheduleOnRN(setIsHolding, false);      });    const composedGesture = useMemo(      () => Gesture.Race(longPressGesture, tapGesture),      [longPressGesture, tapGesture],    );    if (!isValidElement(children)) {      console.error(        "TouchableRipple expects a single valid React element as child.",      );      return null;    }    return (      <GestureDetector gesture={composedGesture}>        <View          onLayout={handleLayout}          testID={testID}          style={{ overflow: "hidden" }}        >          <View            style={[              borderRadiusStyle,              style,              {                overflow: "hidden",              },            ]}          >            {children}            <View              style={[                StyleSheet.absoluteFill,                borderRadiusStyle,                {                  overflow: "hidden",                  pointerEvents: "none",                },              ]}            >              {ripples.map<React.ReactNode>((ripple) => (                <RippleWave                  isHolding={isHolding}                  key={ripple.id}                  ripple={ripple}                  config={FINAL_CONFIG}                  layout={layout}                  borderRadiusStyle={borderRadiusStyle}                  onComplete={removeRipple}                />              ))}            </View>          </View>        </View>      </GestureDetector>    );  },);export default memo<  React.FC<TouchableRippleProps> & React.FunctionComponent<TouchableRippleProps>>(TouchableRipple);

Usage

import { View, Text, StyleSheet, Image } from "react-native";import { GestureHandlerRootView } from "react-native-gesture-handler";import { StatusBar } from "expo-status-bar";import Glow from "@/components/base/glow";import Marquee from "@/components/base/marquee";import { useFonts } from "expo-font";import QrCode from "@/components/base/qr-code";import Ripple from "@/components/base/ripple";export default function App() {  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" />      <View        style={{          top: 120,        }}      >        <Ripple          rippleConfig={{            color: "#fff",            opacity: 0.2,            scale: 0.97,            duration: 1200,            blur: {              enabled: true,              intensity: 50,              tint: "extraLight",            },            enabled: true,          }}          onLongPress={() => ({})}          centered={false}          onPressIn={() => ({})}          onPressOut={() => ({})}          onPress={() => ({})}          style={{}}          disabled={false}          testID={"touchable-ripple"}        >          <Image            source={{              uri: "https://i.pinimg.com/736x/f7/fe/5f/f7fe5fc4f88fece1853a4bf2126ecf3f.jpg",            }}            style={{ width: 400, height: 250, borderRadius: 20 }}          />        </Ripple>      </View>    </GestureHandlerRootView>  );}const styles = StyleSheet.create({  container: {    flex: 1,    backgroundColor: "#0a0a0a",    alignItems: "center",    // marginTop: 150,  },  card: {    width: 280,    height: 56,    backgroundColor: "#151515",    borderRadius: 28,    justifyContent: "center",    alignItems: "center",  },  divider: { width: 20 },  text: {    fontSize: 35,    color: "#fff",  },});

Props

RippleConfig

React Native Reanimated
React Native Worklets
React Native Gesture Handler
Expo Blur